From 40bc15b6a920e9c8c507084ecee4d597ad64a4e5 Mon Sep 17 00:00:00 2001 From: "Diego B." Date: Tue, 22 Sep 2026 08:16:53 -0400 Subject: [PATCH 01/24] feat(plugin): safe plugin entry editing for opencode configs --- .../opencode-intellisearch.manifest.json | 45 + package-lock.json | 827 ++++++++++++++++++ package.json | 1 + plugin-config.ts | 330 +++++++ tests/plugin-config.test.ts | 264 ++++++ 5 files changed, 1467 insertions(+) create mode 100644 .opencode/opencode-intellisearch.manifest.json create mode 100644 package-lock.json create mode 100644 plugin-config.ts create mode 100644 tests/plugin-config.test.ts diff --git a/.opencode/opencode-intellisearch.manifest.json b/.opencode/opencode-intellisearch.manifest.json new file mode 100644 index 0000000..0221a04 --- /dev/null +++ b/.opencode/opencode-intellisearch.manifest.json @@ -0,0 +1,45 @@ +{ + "version": "0.6.0", + "files": [ + { + "path": "skills/intellisearch/SKILL.md", + "hash": "add0186b3f97ab6b87cec5f403ee50f11902580467c63de5f12bbcc2b42159ca" + }, + { + "path": "skills/intellisearch/references/google-search.md", + "hash": "7e2512a8fc287a25d73924db5c4eb5a3f8e51fefb4a0bce174a8570e8e58750c" + }, + { + "path": "skills/intellisearch/references/workflow.md", + "hash": "5ca1b2d917aef3580eb182e0f4d439b825121fad660b2200510b66213986dd76" + }, + { + "path": "skills/intellisearch/references/deepwiki-tools.md", + "hash": "477a4cf723c416de3a9951d0c52028da8ddb000dfd969197ad33279f0bee0e5d" + }, + { + "path": "skills/intellisearch/references/search-workflow.md", + "hash": "28b1a88688cd474129ab87785db4df8409fa4431dce1f23f56b4a396af6664b4" + }, + { + "path": "skills/intellisearch/references/examples.md", + "hash": "c26b76c700cb4d6c9f86b27b94569a128495ac78cf23ce20d173779712ee7411" + }, + { + "path": "skills/intellisearch/references/brave-search.md", + "hash": "f18136e346eecc4361b86b2814bfe316bf125a7698a58142f60e7826ed5bc118" + }, + { + "path": "skills/intellisearch/references/ddg-search.md", + "hash": "16c5ac55ce03971be752ff74919d370bc4ed6343d88f402129e7f9a1fefc5a33" + }, + { + "path": "skills/intellisearch/references/gh-cli.md", + "hash": "c5ef2eb40580aa88541bb20efe6e2e100b44ca02016bdc4d259019feb33b9bf8" + }, + { + "path": "commands/search-intelligently.md", + "hash": "d3bd05a45e8e8b6ed57b5e2575a8f6daf7e5686dea042acd80f2a4c13c966c65" + } + ] +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..9d6d57a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,827 @@ +{ + "name": "opencode-architect", + "version": "0.7.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "opencode-architect", + "version": "0.7.1", + "license": "MIT", + "dependencies": { + "@opencode-ai/plugin": "*", + "yaml": "^2.7.1" + }, + "bin": { + "opencode-architect": "cli.ts" + }, + "devDependencies": { + "@opencode-ai/sdk": "latest", + "@types/bun": "latest", + "@types/node": "latest", + "typescript": "latest" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", + "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@opencode-ai/plugin": { + "version": "1.18.32", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.18.32.tgz", + "integrity": "sha512-JJXaTHmkiU9EAshJOFuIkZsAuvBcLw46BAErePeER8bPeO2LL2uGyBRt4tuLJjJ2N7v1vlZgfvAD7bWKupke1A==", + "license": "MIT", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@opencode-ai/sdk": "1.18.32", + "effect": "4.0.0-beta.83", + "zod": "4.1.8" + }, + "peerDependencies": { + "@opentui/core": ">=0.4.5", + "@opentui/keymap": ">=0.4.5", + "@opentui/solid": ">=0.4.5" + }, + "peerDependenciesMeta": { + "@opentui/core": { + "optional": true + }, + "@opentui/keymap": { + "optional": true + }, + "@opentui/solid": { + "optional": true + } + } + }, + "node_modules/@opencode-ai/sdk": { + "version": "1.18.32", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.18.32.tgz", + "integrity": "sha512-Wi3WtH/cLA3C0ga05lyDb7zac+SzbST9MPwCve1Ffod0xwzHdy9G2gnxvMAR4RBRhKJS32Rgk2cR+fxBhAVSQw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@types/bun": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.4.2.tgz", + "integrity": "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bun-types": "1.4.2" + } + }, + "node_modules/@types/node": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.2.tgz", + "integrity": "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/bun-types": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.4.2.tgz", + "integrity": "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/effect": { + "version": "4.0.0-beta.83", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.83.tgz", + "integrity": "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "fast-check": "^4.8.0", + "find-my-way-ts": "^0.1.6", + "ini": "^7.0.0", + "kubernetes-types": "^1.30.0", + "msgpackr": "^2.0.1", + "multipasta": "^0.2.7", + "toml": "^4.1.1", + "uuid": "^14.0.0", + "yaml": "^2.9.0" + } + }, + "node_modules/fast-check": { + "version": "4.10.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.2.tgz", + "integrity": "sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, + "node_modules/find-my-way-ts": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz", + "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==", + "license": "MIT" + }, + "node_modules/ini": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-7.0.0.tgz", + "integrity": "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==", + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/kubernetes-types": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz", + "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==", + "license": "Apache-2.0" + }, + "node_modules/msgpackr": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.1.0.tgz", + "integrity": "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.4" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, + "node_modules/multipasta": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.8.tgz", + "integrity": "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==", + "license": "MIT" + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pure-rand": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/toml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/toml/-/toml-4.3.0.tgz", + "integrity": "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/uuid": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz", + "integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz", + "integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json index d04009c..d328946 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "agent-loader.ts", "cli.ts", "installer.ts", + "plugin-config.ts", "permission-registrar.ts", "assets" ], diff --git a/plugin-config.ts b/plugin-config.ts new file mode 100644 index 0000000..2721655 --- /dev/null +++ b/plugin-config.ts @@ -0,0 +1,330 @@ +import { exists, mkdir, readFile, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import path from "node:path"; + +export type ConfigScope = "local" | "global"; + +export interface EnsurePluginEntryOptions { + scope: ConfigScope; + projectDir: string; +} + +export interface EnsurePluginEntryOutcome { + action: "noop" | "updated" | "created" | "blocked"; + configPath: string | null; + warning: string | null; +} + +interface CandidateConfig { + path: string; + lenient: boolean; + writable: boolean; +} + +interface PluginArrayRange { + bracketStart: number; + bracketEnd: number; +} + +const DEFAULT_CONFIG_TEMPLATE = `{ + // OpenCode configuration + "$schema": "https://opencode.ai/config.json", + "plugin": ["__PACKAGE_NAME__"] +} +`; + +export class PluginConfigEditor { + public async ensurePluginEntry( + packageName: string, + options: EnsurePluginEntryOptions, + ): Promise { + for (const candidate of this.candidateConfigs(options)) { + if (!(await exists(candidate.path))) continue; + const text = await readFile(candidate.path, "utf-8"); + const plugins = this.parsePluginArray(text, candidate.lenient); + if (plugins === null) { + return { + action: "blocked", + configPath: candidate.path, + warning: + `Config file ${candidate.path} could not be parsed; refusing to modify it. ` + + `Fix or remove the file and re-run the install.`, + }; + } + if (this.hasMatchingEntry(plugins, packageName)) { + return { action: "noop", configPath: candidate.path, warning: null }; + } + if (!candidate.writable) continue; + const spliced = this.spliceEntry(text, packageName, candidate.lenient); + if (spliced === null) { + return { + action: "blocked", + configPath: candidate.path, + warning: `Plugin array in ${candidate.path} could not be safely edited; file left untouched.`, + }; + } + await mkdir(path.dirname(candidate.path), { recursive: true }); + await writeFile(candidate.path, spliced); + return { action: "updated", configPath: candidate.path, warning: null }; + } + + const target = this.defaultConfigPath(options); + const content = DEFAULT_CONFIG_TEMPLATE.replace("__PACKAGE_NAME__", packageName); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, content); + return { action: "created", configPath: target, warning: null }; + } + + public hasMatchingEntry(entries: string[], packageName: string): boolean { + return entries.some((entry) => this.matchesEntry(entry, packageName)); + } + + private matchesEntry(entry: string, packageName: string): boolean { + const specIndex = entry.lastIndexOf("@"); + const name = specIndex > 0 ? entry.slice(0, specIndex) : entry; + return name === packageName; + } + + private candidateConfigs(options: EnsurePluginEntryOptions): CandidateConfig[] { + const scopeBase = this.scopeBase(options.scope, options.projectDir); + const repoRoot = options.projectDir; + const configs: CandidateConfig[] = []; + if (options.scope === "local") { + for (const root of [scopeBase, repoRoot]) { + configs.push({ path: path.join(root, "opencode.json"), lenient: false, writable: true }); + configs.push({ path: path.join(root, "opencode.jsonc"), lenient: true, writable: true }); + } + } else { + for (const file of ["opencode.json", "opencode.jsonc"]) { + configs.push({ path: path.join(scopeBase, file), lenient: file.endsWith(".jsonc"), writable: true }); + } + } + configs.push({ path: path.join(scopeBase, "config.json"), lenient: false, writable: false }); + return configs; + } + + private defaultConfigPath(options: EnsurePluginEntryOptions): string { + if (options.scope === "global") { + return path.join(this.scopeBase("global", options.projectDir), "opencode.jsonc"); + } + return path.join(options.projectDir, "opencode.jsonc"); + } + + private scopeBase(scope: ConfigScope, projectDir: string): string { + if (scope === "local") return path.join(projectDir, ".opencode"); + const xdgConfigHome = process.env.XDG_CONFIG_HOME; + if (xdgConfigHome) return path.join(xdgConfigHome, "opencode"); + return path.join(homedir(), ".config", "opencode"); + } + + private parsePluginArray(text: string, lenient: boolean): string[] | null { + const config = this.parseConfig(text, lenient); + if (config === null) return null; + const plugins = config.plugin; + if (!Array.isArray(plugins)) return []; + return plugins.filter((entry): entry is string => typeof entry === "string"); + } + + private parseConfig(text: string, lenient: boolean): Record | null { + const parseable = lenient ? this.blankComments(text).replace(/,(\s*[}\]])/g, "$1") : text; + try { + return JSON.parse(parseable) as Record; + } catch { + return null; + } + } + + private blankComments(text: string): string { + const chars = text.split(""); + let inString = false; + let inLineComment = false; + let inBlockComment = false; + for (let i = 0; i < chars.length; i++) { + const current = chars[i]; + const next = i + 1 < chars.length ? chars[i + 1] : ""; + if (inLineComment) { + if (current === "\n") inLineComment = false; + else chars[i] = " "; + continue; + } + if (inBlockComment) { + if (current === "*" && next === "/") { + chars[i] = " "; + chars[i + 1] = " "; + i++; + inBlockComment = false; + } else { + chars[i] = " "; + } + continue; + } + if (inString) { + if (current === "\\") { + i++; + continue; + } + if (current === '"') inString = false; + continue; + } + if (current === '"') { + inString = true; + continue; + } + if (current === "/" && next === "/") { + chars[i] = " "; + chars[i + 1] = " "; + i++; + inLineComment = true; + continue; + } + if (current === "/" && next === "*") { + chars[i] = " "; + chars[i + 1] = " "; + i++; + inBlockComment = true; + continue; + } + } + return chars.join(""); + } + + private spliceEntry(text: string, packageName: string, lenient: boolean): string | null { + const navigable = this.blankComments(text); + const range = this.findPluginArrayRange(navigable); + const spliced = + range === null + ? this.splicePluginKey(text, navigable, packageName) + : this.spliceArrayEntry(text, navigable, range, packageName); + if (spliced === null) return null; + const plugins = this.parsePluginArray(spliced, lenient); + if (plugins === null || !this.hasMatchingEntry(plugins, packageName)) return null; + return spliced; + } + + private spliceArrayEntry( + text: string, + navigable: string, + range: PluginArrayRange, + packageName: string, + ): string | null { + const innerStart = range.bracketStart + 1; + const inner = text.slice(innerStart, range.bracketEnd); + const firstElementOffset = inner.search(/\S/); + if (firstElementOffset === -1) { + return text.slice(0, innerStart) + `"${packageName}"` + text.slice(range.bracketEnd); + } + const insertAt = innerStart + firstElementOffset; + const leadingWhitespace = inner.slice(0, firstElementOffset); + return text.slice(0, insertAt) + leadingWhitespace + `"${packageName}",` + text.slice(insertAt); + } + + private splicePluginKey(text: string, navigable: string, packageName: string): string | null { + const objectStart = this.firstStructuralChar(navigable, "{"); + if (objectStart === -1) return null; + const rest = text.slice(objectStart + 1); + const nextContentOffset = rest.search(/\S/); + if (nextContentOffset === -1) return null; + const insertAt = objectStart + 1 + nextContentOffset; + const isClosingBrace = rest[nextContentOffset] === "}"; + const entry = isClosingBrace ? `"plugin": ["${packageName}"]` : `"plugin": ["${packageName}"],`; + const leadingWhitespace = nextContentOffset > 0 ? rest.slice(0, nextContentOffset) : ""; + return text.slice(0, insertAt) + leadingWhitespace + entry + text.slice(insertAt); + } + + private findPluginArrayRange(navigable: string): PluginArrayRange | null { + let searchFrom = 0; + while (searchFrom < navigable.length) { + const keyIndex = navigable.indexOf('"plugin"', searchFrom); + if (keyIndex === -1) return null; + if (this.precededByStructuralChar(navigable, keyIndex, ["{", ","])) { + const colonIndex = this.nextOutsideString(navigable, keyIndex + 8, ":"); + if (colonIndex !== -1) { + const bracketStart = this.nextOutsideString(navigable, colonIndex + 1, "["); + if (bracketStart !== -1) { + const bracketEnd = this.matchingBracket(navigable, bracketStart); + if (bracketEnd !== null) return { bracketStart, bracketEnd }; + } + } + } + searchFrom = keyIndex + 1; + } + return null; + } + + private precededByStructuralChar(text: string, index: number, allowed: string[]): boolean { + for (let i = index - 1; i >= 0; i--) { + const current: string = text[i] ?? ""; + if (/\s/.test(current)) continue; + return allowed.includes(current); + } + return false; + } + + private firstStructuralChar(text: string, target: string): number { + let inString = false; + for (let i = 0; i < text.length; i++) { + const current = text[i]; + if (inString) { + if (current === "\\") { + i++; + continue; + } + if (current === '"') inString = false; + continue; + } + if (current === '"') { + inString = true; + continue; + } + if (current === target) return i; + } + return -1; + } + + private nextOutsideString(text: string, from: number, target: string): number { + let inString = false; + for (let i = from; i < text.length; i++) { + const current = text[i]; + if (inString) { + if (current === "\\") { + i++; + continue; + } + if (current === '"') inString = false; + continue; + } + if (current === '"') { + inString = true; + continue; + } + if (current === target) return i; + } + return -1; + } + + private matchingBracket(text: string, bracketStart: number): number | null { + let depth = 0; + let inString = false; + for (let i = bracketStart; i < text.length; i++) { + const current = text[i]; + if (inString) { + if (current === "\\") { + i++; + continue; + } + if (current === '"') inString = false; + continue; + } + if (current === '"') { + inString = true; + continue; + } + if (current === "[") depth++; + else if (current === "]") { + depth--; + if (depth === 0) return i; + } + } + return null; + } +} diff --git a/tests/plugin-config.test.ts b/tests/plugin-config.test.ts new file mode 100644 index 0000000..5bc8640 --- /dev/null +++ b/tests/plugin-config.test.ts @@ -0,0 +1,264 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { PluginConfigEditor } from "../plugin-config"; + +const ROOT = path.join(import.meta.dirname, "..", ".tmp-plugin-config-test"); + +async function makeDir(relative: string): Promise { + const dir = path.join(ROOT, relative); + await mkdir(dir, { recursive: true }); + return dir; +} + +async function write(relative: string, content: string): Promise { + const filePath = path.join(ROOT, relative); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, content); + return filePath; +} + +function editor(): PluginConfigEditor { + return new PluginConfigEditor(); +} + +function parseJsonc(text: string): Record { + const chars = text.split(""); + let inString = false; + let inLine = false; + let inBlock = false; + for (let i = 0; i < chars.length; i++) { + const current = chars[i]; + const next = i + 1 < chars.length ? chars[i + 1] : ""; + if (inLine) { + if (current === "\n") inLine = false; + else chars[i] = " "; + continue; + } + if (inBlock) { + if (current === "*" && next === "/") { + chars[i] = " "; + chars[i + 1] = " "; + i++; + inBlock = false; + } else chars[i] = " "; + continue; + } + if (inString) { + if (current === "\\") i++; + else if (current === '"') inString = false; + continue; + } + if (current === '"') { + inString = true; + continue; + } + if (current === "/" && next === "/") { + chars[i] = " "; + chars[i + 1] = " "; + i++; + inLine = true; + continue; + } + if (current === "/" && next === "*") { + chars[i] = " "; + chars[i + 1] = " "; + i++; + inBlock = true; + } + } + return JSON.parse(chars.join("").replace(/,(\s*[}\]])/g, "$1")); +} + +beforeEach(async () => { + await rm(ROOT, { recursive: true, force: true }); + await mkdir(ROOT, { recursive: true }); +}); + +afterEach(async () => { + await rm(ROOT, { recursive: true, force: true }); +}); + +describe("PluginConfigEditor.ensurePluginEntry", () => { + test("splices the entry leaving every other byte untouched", async () => { + const projectDir = await makeDir("project"); + const configPath = await write( + "project/opencode.jsonc", + [ + "{", + " // consumer comment, keep me", + ' "$schema": "https://opencode.ai/config.json",', + ' "model": "x/y",', + ' "plugin": [', + ' "some-other-plugin",', + " ],", + "}", + "", + ].join("\n"), + ); + const before = await readFile(configPath, "utf-8"); + + const outcome = await editor().ensurePluginEntry("my-pkg", { scope: "local", projectDir }); + + expect(outcome.action).toBe("updated"); + expect(outcome.configPath).toBe(configPath); + const after = await readFile(configPath, "utf-8"); + expect(after).toContain('"my-pkg"'); + const without = after.replace('"my-pkg",', "").replace(/,\s*,/g, ","); + expect(without.split('"plugin"')[0]).toBe(before.split('"plugin"')[0]); + const parsed = parseJsonc(after); + expect(parsed.plugin).toContain("my-pkg"); + expect(parsed.plugin).toContain("some-other-plugin"); + expect(parsed.model).toBe("x/y"); + }); + + test("performs no write when a semantically matching entry exists", async () => { + const projectDir = await makeDir("project"); + for (const entry of ["my-pkg", "my-pkg@1.2.3", "my-pkg@latest"]) { + const configPath = await write( + "project/opencode.json", + `{ "plugin": ["other", "${entry}"] }\n`, + ); + const before = await readFile(configPath, "utf-8"); + const outcome = await editor().ensurePluginEntry("my-pkg", { scope: "local", projectDir }); + expect(outcome.action).toBe("noop"); + expect(await readFile(configPath, "utf-8")).toBe(before); + await rm(configPath); + } + }); + + test("creates a repo-root opencode.jsonc when no config exists anywhere", async () => { + const projectDir = await makeDir("project"); + const outcome = await editor().ensurePluginEntry("my-pkg", { scope: "local", projectDir }); + expect(outcome.action).toBe("created"); + expect(outcome.configPath).toBe(path.join(projectDir, "opencode.jsonc")); + const parsed = parseJsonc(await readFile(path.join(projectDir, "opencode.jsonc"), "utf-8")); + expect(parsed.plugin).toEqual(["my-pkg"]); + expect(parsed.$schema).toContain("config.json"); + }); + + test("warns and aborts without modification when a candidate config is unparseable", async () => { + const projectDir = await makeDir("project"); + const configPath = await write("project/opencode.json", "{ not valid json !!!\n"); + const before = await readFile(configPath, "utf-8"); + + const outcome = await editor().ensurePluginEntry("my-pkg", { scope: "local", projectDir }); + + expect(outcome.action).toBe("blocked"); + expect(outcome.warning).toContain("could not be parsed"); + expect(await readFile(configPath, "utf-8")).toBe(before); + }); + + test("strict .json rejects comments and trailing commas", async () => { + const projectDir = await makeDir("project"); + await write("project/opencode.json", '{ "plugin": ["a",], }\n'); + const outcome = await editor().ensurePluginEntry("my-pkg", { scope: "local", projectDir }); + expect(outcome.action).toBe("blocked"); + }); + + test("lenient .jsonc accepts comments and trailing commas", async () => { + const projectDir = await makeDir("project"); + await write( + "project/opencode.jsonc", + '{ /* c */ "plugin": ["a",], // trailing\n}\n', + ); + const outcome = await editor().ensurePluginEntry("my-pkg", { scope: "local", projectDir }); + expect(outcome.action).toBe("updated"); + }); + + test("schema URLs with // and escaped quotes survive splicing and parsing", async () => { + const projectDir = await makeDir("project"); + const configPath = await write( + "project/opencode.jsonc", + '{ "$schema": "https://opencode.ai/config.json", "key": "a \\"quoted\\" // value", "plugin": [] }\n', + ); + const outcome = await editor().ensurePluginEntry("my-pkg", { scope: "local", projectDir }); + expect(outcome.action).toBe("updated"); + const parsed = JSON.parse((await readFile(configPath, "utf-8")).replace(/,(\s*[}\]])/g, "$1")); + expect(parsed.$schema).toBe("https://opencode.ai/config.json"); + expect(parsed.key).toBe('a "quoted" // value'); + expect(parsed.plugin).toEqual(["my-pkg"]); + }); + + test("scope base config wins over repo root; existing scope-base file is edited", async () => { + const projectDir = await makeDir("project"); + const scopeBasePath = await write( + "project/.opencode/opencode.json", + '{ "plugin": [] }\n', + ); + await write("project/opencode.json", '{ "plugin": [] }\n'); + const outcome = await editor().ensurePluginEntry("my-pkg", { scope: "local", projectDir }); + expect(outcome.action).toBe("updated"); + expect(outcome.configPath).toBe(scopeBasePath); + }); + + test("global scope creates scope-base opencode.jsonc when nothing exists", async () => { + const projectDir = await makeDir("project"); + const xdg = await makeDir("xdg"); + process.env.XDG_CONFIG_HOME = xdg; + try { + const outcome = await editor().ensurePluginEntry("my-pkg", { scope: "global", projectDir }); + expect(outcome.action).toBe("created"); + expect(outcome.configPath).toBe(path.join(xdg, "opencode", "opencode.jsonc")); + } finally { + delete process.env.XDG_CONFIG_HOME; + } + }); + + test("global config.json with the entry is a read-only no-op", async () => { + const projectDir = await makeDir("project"); + const xdg = await makeDir("xdg"); + const globalConfig = await write( + "xdg/opencode/config.json", + '{ "plugin": ["my-pkg@2.0.0"] }\n', + ); + process.env.XDG_CONFIG_HOME = xdg; + try { + const outcome = await editor().ensurePluginEntry("my-pkg", { scope: "global", projectDir }); + expect(outcome.action).toBe("noop"); + expect(outcome.configPath).toBe(globalConfig); + expect(await readFile(globalConfig, "utf-8")).toBe('{ "plugin": ["my-pkg@2.0.0"] }\n'); + } finally { + delete process.env.XDG_CONFIG_HOME; + } + }); + + test("does not write into read-only global config.json when entry is absent", async () => { + const projectDir = await makeDir("project"); + const xdg = await makeDir("xdg"); + const globalConfig = await write("xdg/opencode/config.json", '{ "model": "x/y" }\n'); + process.env.XDG_CONFIG_HOME = xdg; + try { + const outcome = await editor().ensurePluginEntry("my-pkg", { scope: "global", projectDir }); + expect(outcome.action).toBe("created"); + expect(await readFile(globalConfig, "utf-8")).toBe('{ "model": "x/y" }\n'); + const created = parseJsonc(await readFile(path.join(xdg, "opencode", "opencode.jsonc"), "utf-8")); + expect(created.plugin).toEqual(["my-pkg"]); + } finally { + delete process.env.XDG_CONFIG_HOME; + } + }); + + test("splices an inline empty plugin array in a minified config", async () => { + const projectDir = await makeDir("project"); + const configPath = await write("project/opencode.json", '{"plugin":[]}\n'); + const outcome = await editor().ensurePluginEntry("my-pkg", { scope: "local", projectDir }); + expect(outcome.action).toBe("updated"); + expect(await readFile(configPath, "utf-8")).toBe('{"plugin":["my-pkg"]}\n'); + }); + + test("config without a plugin key gets one spliced in, rest untouched", async () => { + const projectDir = await makeDir("project"); + const configPath = await write( + "project/opencode.jsonc", + '{\n "model": "x/y",\n}\n', + ); + const outcome = await editor().ensurePluginEntry("my-pkg", { scope: "local", projectDir }); + expect(outcome.action).toBe("updated"); + const after = await readFile(configPath, "utf-8"); + expect(after).toContain('"plugin": ["my-pkg"],'); + expect(after).toContain('"model": "x/y"'); + const parsed = JSON.parse(after.replace(/,(\s*[}\]])/g, "$1")); + expect(parsed.plugin).toEqual(["my-pkg"]); + }); +}); From 7bb11b5bc6df17effe164b7643b917a92b950b6b Mon Sep 17 00:00:00 2001 From: "Diego B." Date: Tue, 22 Sep 2026 09:46:56 -0400 Subject: [PATCH 02/24] feat(packaging): packages declare content (assets vs code) Packager derives the package.json content declaration from its asset inventory (ADR-0008): assets for skills/commands-only packages, code when any agent, tool, or plugin content ships. Templates carry the field defaulting to assets; publisher verifies it at intake, carries it through expansion, and covers it in post-publish verification. Closes #16. --- CONTEXT.md | 5 +++ assets/agents/opencode-packager.md | 2 +- assets/agents/opencode-publisher.md | 5 +-- assets/templates/package-basics.template.json | 7 ++++ assets/templates/package-full.template.json | 7 ++++ tests/content-declaration.test.ts | 34 +++++++++++++++++++ 6 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 tests/content-declaration.test.ts diff --git a/CONTEXT.md b/CONTEXT.md index 1c0b569..838c9b9 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -123,6 +123,11 @@ The install mechanism a package's content dictates, declared in the package's package.json: assets-only packages copy-install by default with plugin install as the opt-in; code-backed packages always plugin-install. +**Content declaration**: +The `"content"` field (`assets` or `code`) in a generated package's +package.json, derived by the packager from its asset inventory and verified +by the publisher. It is how installers read the deployment plan. + **Plugin install**: The mode where the package is listed in a config file's `plugin` array and everything registers from the package at load time; the CLI copies nothing. diff --git a/assets/agents/opencode-packager.md b/assets/agents/opencode-packager.md index 7f72065..2cc2bcf 100644 --- a/assets/agents/opencode-packager.md +++ b/assets/agents/opencode-packager.md @@ -41,7 +41,7 @@ opencode-myextension/ 6. **Create plugin.ts** from `../templates/plugin-local.template.txt`, plus `src/plugin-name.ts` from `../templates/plugin-name.template.txt`, `src/manifest.ts` from `../templates/manifest.template.txt`, and `src/registration.ts` from `../templates/registration.template.txt`: the load hook performs read-only registration-scope detection, then ensures skills, commands, and agents only for the scopes where the plugin is registered, gated by the per-scope install manifest (version + per-file sha256). Never write outside the detected scopes, never edit `plugin` arrays or root configs at load, and never rewrite a config that fails to parse. -7. **Create package.json** from `../templates/package-basics.template.json` and tsconfig.json from `../templates/tsconfig.template.json`. +7. **Create package.json** from `../templates/package-basics.template.json` and tsconfig.json from `../templates/tsconfig.template.json`. The template ships with `"content": "assets"`; the asset inventory is the source of truth for the declaration (ADR-0008): keep `"assets"` only when the package ships skills and/or commands alone; set `"code"` when the source contained any agent, tool, plugin, hook, or other plugin integration — code-backed packages may also ship skills and commands. Report the decision: "Content declaration: code (1 agent, 2 tools found)" or "Content declaration: assets (skills and commands only)". 8. **Create the README badge row.** Build the package's `README.md` with the badge row directly below the first heading (a tagline between heading and badges is non-conformant). Emit or verify the row exactly as specified in opencode-publisher step 4b; include the DeepWiki badge only when the repo is indexed (confirm via a `deepwiki.com//` fetch or a DeepWiki MCP query — never assume). The publisher re-verifies this row; emitting it here keeps locally-used packages conformant too. diff --git a/assets/agents/opencode-publisher.md b/assets/agents/opencode-publisher.md index 03a06ec..c414e66 100644 --- a/assets/agents/opencode-publisher.md +++ b/assets/agents/opencode-publisher.md @@ -17,13 +17,13 @@ You are an OpenCode extension publisher: you transform locally-packaged extensio ## Workflow -1. **Verify the incoming package.** Confirm the packager's structure exists: a bundled asset directory (`assets/skills/` etc., or repo-root `skills//`), `plugin.ts` with inline install logic, minimal `package.json`, `tsconfig.json`. Read the packager summary for extension name, description, included assets, dependencies, and warnings. Confirm every asset landed in the package and custom plugins or tools got their merge decisions. An invalid structure returns to the orchestrator for repackaging. +1. **Verify the incoming package.** Confirm the packager's structure exists: a bundled asset directory (`assets/skills/` etc., or repo-root `skills//`), `plugin.ts` with inline install logic, minimal `package.json` with a `content` declaration (`assets` or `code`), `tsconfig.json`. Read the packager summary for extension name, description, included assets, dependencies, warnings, and the content declaration. Confirm every asset landed in the package and custom plugins or tools got their merge decisions. Cross-check the declaration against the bundled assets: `assets` requires skills/commands only — any agent, tool, or plugin file in the package contradicts it and returns to the orchestrator for repackaging; `code` is valid for any inventory. An invalid structure returns to the orchestrator for repackaging. 2. **Extract install logic to src/installer.ts.** Move install(), uninstall(), status(), scope detection, path resolution, and config management out of plugin.ts, keeping the manifest module (src/manifest.ts), plugin-name normalizer (src/plugin-name.ts), and registration detector (src/registration.ts) as separate files; update plugin.ts to call install() from src/installer.ts. Preserve the invariants: manifest-gated idempotency (no `.version` markers), semantic `@latest` plugin dedup written canonically as `name@latest`, abort-with-warning on unparseable config (never rewrite from `{}`), skip consumer-modified files unless `--force`, and root-config migration CLI-only behind explicit consent. 3. **Create the CLI entry point.** Build src/cli.ts from `../templates/cli.template.txt`: install command calls install(scope, projectDir, { force }), uninstall calls uninstall(scope, projectDir), status calls status(projectDir), migrate calls migrateRootConfig only behind `--force` consent. -4. **Expand package.json** from `../templates/package-full.template.json`: bin field for the CLI, scripts (check, test), expanded dependencies, npm fields (repository, bugs, license, author). +4. **Expand package.json** from `../templates/package-full.template.json`: bin field for the CLI, scripts (check, test), expanded dependencies, npm fields (repository, bugs, license, author). Carry the packager's `content` declaration through unchanged — expansion adds npm fields, never alters the declaration. 4b. **Add the README badge row.** Place directly below the first heading line in the package's `README.md`, with `{{PACKAGE_NAME}}` from package.json, `{{TARGET_REPO}}` parsed from `git remote get-url origin` preserving exact casing, and `{{PLATFORMS}}` derived from the target repo (URL-encoded: spaces become `%20`, ` | ` becomes `%20%7C%20`): @@ -52,6 +52,7 @@ Rules: the row sits directly below the first heading line — a tagline between - [ ] `package.json` `repository.url`, `homepage`, and `bugs.url` match the GitHub repo URL byte-for-byte, including case (`My-Org/pkg` ≠ `my-org/pkg` — provenance verification is case-sensitive) - [ ] `CHANGELOG.md` has a section for the released version - [ ] Registry shows the new version: `npm view version` +- [ ] `package.json` declares `"content"` with value `assets` or `code`, matching the packager's inventory decision - [ ] Install smoke passes in a scratch dir: `bunx status` - [ ] Consumer instructions generated: npm install command, `opencode.json` plugin entry (`"@latest"`), and the verify command - [ ] README badge row matches step 4b exactly (npm version, Bun runtime, license, platforms, OpenCode plugin, DeepWiki) with correct `{{PACKAGE_NAME}}` and repo casing diff --git a/assets/templates/package-basics.template.json b/assets/templates/package-basics.template.json index 3bbcfda..87e68be 100644 --- a/assets/templates/package-basics.template.json +++ b/assets/templates/package-basics.template.json @@ -13,6 +13,12 @@ PATHS "skills/myextension" → path to your skill assets "commands/my-command.md" → path to your command file +CONTENT DECLARATION + "assets" → keep when the package ships only skills and/or commands + "code" → replace when the package ships any agent, tool, hook, or + other plugin integration (code-backed packages may also + ship skills/commands) + METADATA "Publisher Name" → your name or organization "email@example.com" → your email @@ -22,6 +28,7 @@ METADATA { "name": "opencode-myextension", "version": "1.0.0", + "content": "assets", "type": "module", "module": "index.ts", "files": ["index.ts", "plugin.ts", "assets"] diff --git a/assets/templates/package-full.template.json b/assets/templates/package-full.template.json index 05c79e0..cac276a 100644 --- a/assets/templates/package-full.template.json +++ b/assets/templates/package-full.template.json @@ -13,6 +13,12 @@ PATHS "skills/myextension" → path to your skill assets "commands/my-command.md" → path to your command file +CONTENT DECLARATION + "assets" → keep when the package ships only skills and/or commands + "code" → replace when the package ships any agent, tool, hook, or + other plugin integration (code-backed packages may also + ship skills/commands) + METADATA "Publisher Name" → your name or organization "Author Name " → your name and email @@ -23,6 +29,7 @@ METADATA { "name": "opencode-myextension", "version": "1.0.0", + "content": "assets", "type": "module", "module": "index.ts", "bin": { diff --git a/tests/content-declaration.test.ts b/tests/content-declaration.test.ts new file mode 100644 index 0000000..b55fee8 --- /dev/null +++ b/tests/content-declaration.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +const REPO_ROOT = path.resolve(import.meta.dirname, ".."); + +const readRepoFile = (relativePath: string): Promise => + readFile(path.join(REPO_ROOT, relativePath), "utf-8"); + +describe("content declaration", () => { + test("both package.json templates declare the content field", async () => { + for (const template of [ + "assets/templates/package-basics.template.json", + "assets/templates/package-full.template.json", + ]) { + const source = await readRepoFile(template); + const body = source.split("---")[1] ?? ""; + const parsed = JSON.parse(body); + expect(parsed.content, `${template} content field`).toBe("assets"); + } + }); + + test("packager derives the declaration from its inventory", async () => { + const source = await readRepoFile("assets/agents/opencode-packager.md"); + expect(source).toContain('"content": "assets"'); + expect(source).toContain('"code"'); + }); + + test("publisher verifies and carries the declaration", async () => { + const source = await readRepoFile("assets/agents/opencode-publisher.md"); + expect(source).toContain("content` declaration"); + expect(source).toContain('declares `"content"`'); + }); +}); From ddba3498b4b320f7141356fd84a35100adb03686 Mon Sep 17 00:00:00 2001 From: "Diego B." Date: Tue, 22 Sep 2026 23:55:38 -0400 Subject: [PATCH 03/24] feat(cli): install becomes plugin registration, copy installs migrate (#17) install ensures the plugin entry via the surgical editor, writes a generalized plugin-mode manifest, and is a zero-write no-op when up to date; --mode copy is refused for this code-backed package. Legacy copy installs migrate: payload removed per the old manifest (after a read-only config parse check) with a printed notice, before the entry is added. uninstall surgically removes the entry plus manifest and residual payload; status reports mode, version, and registration file. Manifest schema also covers copy mode with a folder-hash content-hash. README and CHANGELOG updated. Grounded in ADR-0008, supersedes ADR-0004. --- CHANGELOG.md | 10 + README.md | 16 +- bun.lock | 16 ++ cli.ts | 60 ++--- installer.ts | 371 +++++++++++++----------------- package.json | 6 +- plugin-config.ts | 161 +++++++++++++ tests/cli.test.ts | 41 +++- tests/installer.test.ts | 491 +++++++++++++++++++--------------------- 9 files changed, 659 insertions(+), 513 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3ea143..a66c0a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed + +- **Breaking:** `install` is now a registration manager (plugin install is the only mode for this code-backed package, per [ADR-0008](docs/adr/0008-content-based-deployment-plans.md), superseding ADR-0004): the CLI ensures the `plugin` entry in the target scope's config file via the surgical editor — comments and formatting preserved, unparseable configs abort untouched — and writes a generalized manifest (version, mode, plugin entry, target config file) at the scope base. A matching manifest with the entry present is a zero-write no-op. `--mode copy` is refused with an explanatory error; `--force` now re-registers and rewrites the manifest instead of removing the entry +- Legacy copy installs migrate automatically on install: the old manifest's file list is removed exactly, with a printed notice, before the plugin entry is added +- `uninstall` surgically removes the plugin entry (config formatting preserved) plus the manifest and any residual copy payload — manifest-gated, or a known-filenames sweep of `agents/` and `opencode-architect/` when no manifest exists; `status` reports mode, version, and the config file holding the registration +- Generalized manifest schema covers copy mode too, with a single `content-hash` property (folder-hash) for copy-installed payloads +- Nothing changes at runtime: the plugin already registers the agents from the package and resolves reference paths at load + ## [0.7.1] - 2026-09-21 ### Fixed diff --git a/README.md b/README.md index 4bd759d..9d5d1a2 100644 --- a/README.md +++ b/README.md @@ -24,13 +24,13 @@ The plugin registers the full agent suite at startup, with self-contained bundle ### Option 2 — Install with the CLI (bunx or npx) -Copy the agents, references, and templates straight into your OpenCode directories, where you can read and modify every file: +The CLI registers the package as a plugin: it adds `opencode-architect` to the `plugin` array of your OpenCode config with surgical text editing (comments and formatting elsewhere in the file are preserved), then records the registration in an `opencode-architect.json` manifest at the scope base. Nothing is copied — the agents, references, and templates all load from the package at startup. ```bash -# Project scope (default): copies into ./.opencode/ +# Project scope (default): edits the ./.opencode/ or repo-root config bunx opencode-architect install -# Global scope: copies into ~/.config/opencode/ +# Global scope: edits the XDG/home config, creating opencode.jsonc if absent bunx opencode-architect install --scope global ``` @@ -39,15 +39,15 @@ bunx opencode-architect install --scope global Useful flags and commands: ```bash -bunx opencode-architect status # show install mode and version for a scope -bunx opencode-architect uninstall # remove exactly the files a copy install wrote -bunx opencode-architect install --force # overwrite locally modified files, switch a scope from plugin to copy install +bunx opencode-architect status # show mode, version, and the config file holding the entry +bunx opencode-architect uninstall # remove the plugin entry, the manifest, and any residual payload +bunx opencode-architect install --force # re-register and rewrite the manifest even when up to date bunx opencode-architect --help # full usage ``` -A copy install writes 10 agents into `agents/`, 9 reference docs into `opencode-architect/references/`, and 9 starter templates into `opencode-architect/templates/` of the scope base, plus an `opencode-architect.json` manifest that tracks versions and file hashes for safe upgrades. Relative reference paths inside agents are rewritten to absolute paths at install time. +Re-running install when the manifest matches reality is a zero-write no-op. `--mode copy` is refused with an explanatory error: this package is code-backed (it ships agents), and copying cannot express plugin registration. -Plugin install and copy install are mutually exclusive per scope — the CLI refuses to copy over an existing plugin entry unless you pass `--force`. +**Upgrading from a copy install (pre-0.8):** if a previous version copied agents into your scope base, install detects the old manifest, removes exactly the files it lists, prints a notice, and switches the scope to plugin registration in one step. Locally modified files are tracked by hash; uninstall and migration only remove what the manifest recorded. ## What you get: ten specialist OpenCode agents diff --git a/bun.lock b/bun.lock index 0a8641b..fd5478f 100644 --- a/bun.lock +++ b/bun.lock @@ -6,11 +6,13 @@ "name": "opencode-architect", "dependencies": { "@opencode-ai/plugin": "*", + "folder-hash": "^4.1.3", "yaml": "^2.7.1", }, "devDependencies": { "@opencode-ai/sdk": "latest", "@types/bun": "latest", + "@types/folder-hash": "^4.0.4", "@types/node": "latest", "typescript": "latest", }, @@ -39,6 +41,8 @@ "@types/bun": ["@types/bun@1.4.1", "", { "dependencies": { "bun-types": "1.4.1" } }, "sha512-0AVGiTXGajf1rgKom3N+c5L7CBxuoyyv1i44M0nX4UDK0G/fnRAMiri93nHuVPIb429KKtAgj7HatVmmOjeQLA=="], + "@types/folder-hash": ["@types/folder-hash@4.0.4", "", {}, "sha512-c+PwHm51Dw3fXM8SDK+93PO3oXdk4XNouCCvV67lj4aijRkZz5g67myk+9wqWWnyv3go6q96hT6ywcd3XtoZiQ=="], + "@types/node": ["@types/node@26.4.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA=="], "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], @@ -81,10 +85,16 @@ "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "brace-expansion": ["brace-expansion@2.1.7", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g=="], + "bun-types": ["bun-types@1.4.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-loKuVrAFZKfEv+JvWkHRS9GW5IqLuLRjVXN9p+vZvBN86O5hf/pBZQ5hSoyipsrMmWObZBDvWnlmKvjKTM0PdA=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="], @@ -93,6 +103,8 @@ "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], + "folder-hash": ["folder-hash@4.1.3", "", { "dependencies": { "debug": "4.4.0", "minimatch": "7.4.9" }, "bin": { "folder-hash": "bin/folder-hash" } }, "sha512-94fj+fXj1XHT8zGumUy/VlyFARc/yrslKJ2+vjrP/U6ftTdL7u68+gQhvSBjz9wrwTuty6BpZ7JsbEK5OU9RNw=="], + "ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="], "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], @@ -101,6 +113,10 @@ "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], + "minimatch": ["minimatch@7.4.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-Brg/fp/iAVDOQoHxkuN5bEYhyQlZhxddI78yWsCbeEwTHXQjlNLtiJDUsp1GIptVqMI7/gkJMz4vVAc01mpoBw=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "msgpackr": ["msgpackr@2.1.0", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ=="], "msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="], diff --git a/cli.ts b/cli.ts index c788eea..e11e2ab 100644 --- a/cli.ts +++ b/cli.ts @@ -8,6 +8,7 @@ async function main(): Promise { const { positionals, values } = parseArgs({ options: { scope: { type: "string", short: "s" }, + mode: { type: "string", short: "m" }, force: { type: "boolean", short: "f", default: false }, help: { type: "boolean", short: "h", default: false }, version: { type: "boolean", short: "v", default: false }, @@ -32,27 +33,29 @@ async function main(): Promise { process.exit(1); } const scope: Scope = scopeInput === "global" ? "global" : "local"; + if (values.mode !== undefined && values.mode !== "plugin" && values.mode !== "copy") { + console.error(`Invalid mode: ${values.mode}. Must be "plugin" or "copy".`); + process.exit(1); + } + const mode: "plugin" | "copy" = values.mode === "copy" ? "copy" : "plugin"; const installer = new Installer(); try { switch (command) { case "install": { - const outcome = await installer.install(scope, { force: values.force, projectDir: process.cwd() }); + const outcome = await installer.install(scope, { force: values.force, mode, projectDir: process.cwd() }); if (outcome.action === "noop") { - console.log(`Already installed at version ${VERSION}; nothing to do.`); + console.log(`Already registered at version ${VERSION}; nothing to do.`); + } else if (outcome.action === "migrated") { + console.log("Migrated a legacy copy install to plugin registration."); + for (const file of outcome.removedPayload) console.log(` Removed: ${file}`); } else { - console.log(`${outcome.action === "installed" ? "Installed" : "Upgraded"} (${scope} scope):`); - console.log(` Agents: ${outcome.agentsDir}`); - console.log(` References: ${outcome.referencesDir}`); - console.log(` Templates: ${outcome.templatesDir}`); + console.log(`${outcome.action === "installed" ? "Registered" : "Updated registration"} (${scope} scope).`); } - if (outcome.pluginRemoved) { - console.log(" Removed the plugin entry from opencode.json (switched to copy install)."); - } - if (outcome.skipped.length > 0) { - console.log(" Skipped locally modified files (re-run with --force to overwrite):"); - for (const file of outcome.skipped) console.log(` ${file}`); + if (outcome.configPath !== null) { + console.log(` Plugin entry: ${outcome.configPath}`); } + console.log(` Manifest: ${outcome.manifestPath}`); break; } case "uninstall": { @@ -61,19 +64,18 @@ async function main(): Promise { console.log(`Nothing to uninstall for the ${scope} scope.`); break; } - if (outcome.mode === "copy") { - console.log(`Uninstalled (${scope} scope):`); - for (const file of outcome.removed) console.log(` Removed: ${file}`); - } - if (outcome.pluginRemoved) { - console.log(" Removed the plugin entry from opencode.json."); + console.log(`Uninstalled (${scope} scope):`); + for (const file of outcome.removed) console.log(` Removed: ${file}`); + if (outcome.pluginRemoved && outcome.configPath !== null) { + console.log(` Removed the plugin entry from ${outcome.configPath}.`); } break; } case "status": { const outcome = await installer.status(scope, process.cwd()); const version = outcome.version ?? "-"; - console.log(`${scope} scope: mode=${outcome.mode} version=${version}`); + const configPath = outcome.configPath ?? "-"; + console.log(`${scope} scope: mode=${outcome.mode} version=${version} config=${configPath}`); break; } default: @@ -92,20 +94,20 @@ function printHelp(): void { console.log(` opencode-architect v${VERSION} -Installs the opencode-architect agent suite by copying agents into the scope -base's agents/, references into opencode-architect/references/, and templates -into opencode-architect/templates/, rewriting relative reference paths to -absolute paths at install time. Copy install and plugin install are mutually -exclusive per scope. +Registers the opencode-architect plugin in a config file's plugin array so the +agent suite loads from the package at startup. Nothing is copied: the plugin +registers the agents and resolves reference paths at load time. Commands: - install Copy agents, references, and templates into the scope base - uninstall Remove exactly the files a copy install wrote (or the plugin entry) - status Show install mode and version for a scope + install Ensure the plugin entry and write the install manifest + uninstall Remove the plugin entry, the manifest, and any residual payload + status Show install mode, version, and the config file holding the entry Options: - -s, --scope "local" (project .opencode/) or "global" (~/.config/opencode/); default local - -f, --force install: remove an existing plugin entry and overwrite locally modified files + -s, --scope "local" (project) or "global" (XDG/home config); default local + -m, --mode "plugin" (default) or "copy"; copy is refused for this + code-backed package + -f, --force re-register and rewrite the manifest even when it is up to date -h, --help Show this help message -v, --version Show version diff --git a/installer.ts b/installer.ts index 2eec388..122efce 100644 --- a/installer.ts +++ b/installer.ts @@ -1,12 +1,14 @@ -import { createHash } from "node:crypto"; -import { copyFile, exists, mkdir, readdir, readFile, rm, rmdir, writeFile } from "node:fs/promises"; +import { exists, mkdir, readFile, readdir, rm, rmdir, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import path from "node:path"; -import { AGENT_FILENAMES, RELATIVE_REFERENCE_REGEX } from "./agent-loader"; +import { hashElement } from "folder-hash"; +import { AGENT_FILENAMES } from "./agent-loader"; +import { PluginConfigEditor } from "./plugin-config"; export type Scope = "local" | "global"; export type InstallMode = "none" | "copy" | "plugin"; -export type InstallAction = "installed" | "upgraded" | "noop"; +export type ManifestMode = "copy" | "plugin"; +export type InstallAction = "installed" | "upgraded" | "noop" | "migrated"; export interface ManifestHashEntry { path: string; @@ -15,28 +17,26 @@ export interface ManifestHashEntry { export interface Manifest { version: string; - agentFiles: string[]; - referencesDir: string; - templatesDir: string; - hashes: ManifestHashEntry[]; + mode: ManifestMode; + entry: string | null; + configPath: string | null; + "content-hash": string | null; + hashes: ManifestHashEntry[] | null; } export interface InstallOptions { force: boolean; + mode: "plugin" | "copy"; projectDir: string; } export interface InstallOutcome { action: InstallAction; scope: Scope; - agentsDir: string; - referencesDir: string; - templatesDir: string; manifestPath: string; - copied: string[]; - skipped: string[]; - overwritten: string[]; - pluginRemoved: boolean; + configPath: string | null; + configAction: "noop" | "updated" | "created" | "blocked"; + removedPayload: string[]; } export interface UninstallOutcome { @@ -44,276 +44,219 @@ export interface UninstallOutcome { mode: InstallMode; removed: string[]; pluginRemoved: boolean; + configPath: string | null; } export interface StatusOutcome { scope: Scope; mode: InstallMode; version: string | null; + configPath: string | null; } const PACKAGE_NAME = "opencode-architect"; const MANIFEST_NAME = "opencode-architect.json"; -const ASSETS_AGENTS_DIR = path.join(import.meta.dirname, "assets", "agents"); -const ASSETS_REFERENCES_DIR = path.join(import.meta.dirname, "assets", "references"); -const ASSETS_TEMPLATES_DIR = path.join(import.meta.dirname, "assets", "templates"); export class Installer { + private readonly editor = new PluginConfigEditor(); + public async install(scope: Scope, options: InstallOptions): Promise { + if (options.mode === "copy") { + throw new Error( + `${PACKAGE_NAME} is a code-backed package: it ships agents, which only work through ` + + `plugin registration. Copy install cannot express that. Run without --mode copy.`, + ); + } + const base = this.scopeBase(scope, options.projectDir); - const configPath = path.join(base, "opencode.json"); const manifestPath = path.join(base, MANIFEST_NAME); - const agentsDir = path.join(base, "agents"); - const referencesDir = path.join(base, "opencode-architect", "references"); - const templatesDir = path.join(base, "opencode-architect", "templates"); const version = await this.getPackageVersion(); + const existing = await this.readManifest(manifestPath); + + let removedPayload: string[] = []; + let action: InstallAction; + if (existing !== null && existing.mode === "copy") { + const check = await this.editor.checkParseable({ scope, projectDir: options.projectDir }); + if (!check.ok) throw new Error(check.warning); + removedPayload = await this.removePayloadPerManifest(base, existing.hashes ?? []); + action = "migrated"; + } else { + action = "installed"; + } - let pluginRemoved = false; - if (await this.hasPluginEntry(configPath)) { - if (!options.force) { - throw new Error( - `The "${PACKAGE_NAME}" plugin entry in ${configPath} must be removed before a copy install ` + - `(copied agents would silently shadow the plugin's agents). Re-run with --force to remove ` + - `the entry and switch this scope to copy install.`, - ); - } - await this.removePluginEntry(configPath); - pluginRemoved = true; + const registration = await this.editor.ensurePluginEntry(PACKAGE_NAME, { + scope, + projectDir: options.projectDir, + }); + if (registration.action === "blocked") { + throw new Error(registration.warning ?? "Config registration was blocked."); } - const existingManifest = await this.readManifest(manifestPath); - if (existingManifest !== null && existingManifest.version === version) { - return { - action: "noop", - scope, - agentsDir, - referencesDir, - templatesDir, - manifestPath, - copied: [], - skipped: [], - overwritten: [], - pluginRemoved, + if (existing !== null && existing.mode === "plugin") { + action = existing.version === version && registration.action === "noop" ? "noop" : "upgraded"; + } + + if (action !== "noop" || options.force) { + const manifest: Manifest = { + version, + mode: "plugin", + entry: PACKAGE_NAME, + configPath: registration.configPath, + "content-hash": null, + hashes: null, }; + await mkdir(base, { recursive: true }); + await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n"); } - const action: InstallAction = existingManifest === null ? "installed" : "upgraded"; - const copied: string[] = []; - const skipped: string[] = []; - const overwritten: string[] = []; - const hashes: ManifestHashEntry[] = []; + return { + action, + scope, + manifestPath, + configPath: registration.configPath, + configAction: registration.action, + removedPayload, + }; + } - await mkdir(agentsDir, { recursive: true }); - await mkdir(referencesDir, { recursive: true }); - await mkdir(templatesDir, { recursive: true }); + public async uninstall(scope: Scope, projectDir: string): Promise { + const base = this.scopeBase(scope, projectDir); + const manifestPath = path.join(base, MANIFEST_NAME); + const manifest = await this.readManifest(manifestPath); + const removed: string[] = []; - for (const filename of AGENT_FILENAMES) { - const relativePath = path.join("agents", filename); - const { action: fileAction, priorHash } = await this.disposition( - existingManifest, - base, - relativePath, - options.force, - ); - if (fileAction === "skip") { - skipped.push(relativePath); - hashes.push({ path: relativePath, hash: priorHash as string }); - continue; - } - const source = await readFile(path.join(ASSETS_AGENTS_DIR, filename), "utf-8"); - await writeFile(path.join(base, relativePath), rewriteReferencePaths(source, referencesDir, templatesDir)); - hashes.push({ path: relativePath, hash: await this.sha256File(path.join(base, relativePath)) }); - if (fileAction === "overwrite") overwritten.push(relativePath); - else copied.push(relativePath); + const removal = await this.editor.removePluginEntry(PACKAGE_NAME, { scope, projectDir }); + if (removal.action === "blocked") { + throw new Error(removal.warning ?? "Config cleanup was blocked."); } + const pluginRemoved = removal.action === "removed"; - for (const entry of await readdir(ASSETS_REFERENCES_DIR)) { - const relativePath = path.join("opencode-architect", "references", entry); - const { action: fileAction, priorHash } = await this.disposition( - existingManifest, - base, - relativePath, - options.force, - ); - if (fileAction === "skip") { - skipped.push(relativePath); - hashes.push({ path: relativePath, hash: priorHash as string }); - continue; - } - await copyFile(path.join(ASSETS_REFERENCES_DIR, entry), path.join(base, relativePath)); - hashes.push({ path: relativePath, hash: await this.sha256File(path.join(base, relativePath)) }); - if (fileAction === "overwrite") overwritten.push(relativePath); - else copied.push(relativePath); + if (manifest !== null && manifest.mode === "copy") { + removed.push(...(await this.removePayloadPerManifest(base, manifest.hashes ?? []))); + await rm(manifestPath); + removed.push(manifestPath); } - for (const entry of await readdir(ASSETS_TEMPLATES_DIR)) { - const relativePath = path.join("opencode-architect", "templates", entry); - const { action: fileAction, priorHash } = await this.disposition( - existingManifest, - base, - relativePath, - options.force, - ); - if (fileAction === "skip") { - skipped.push(relativePath); - hashes.push({ path: relativePath, hash: priorHash as string }); - continue; - } - await copyFile(path.join(ASSETS_TEMPLATES_DIR, entry), path.join(base, relativePath)); - hashes.push({ path: relativePath, hash: await this.sha256File(path.join(base, relativePath)) }); - if (fileAction === "overwrite") overwritten.push(relativePath); - else copied.push(relativePath); + if (manifest !== null && manifest.mode === "plugin") { + await rm(manifestPath); + removed.push(manifestPath); } - const manifest: Manifest = { version, agentFiles: [...AGENT_FILENAMES], referencesDir, templatesDir, hashes }; - await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n"); + if (manifest === null) { + removed.push(...(await this.removeResidualPayload(base))); + } - return { action, scope, agentsDir, referencesDir, templatesDir, manifestPath, copied, skipped, overwritten, pluginRemoved }; + const mode: InstallMode = + manifest !== null ? manifest.mode : pluginRemoved ? "plugin" : "none"; + return { scope, mode, removed, pluginRemoved, configPath: removal.configPath }; } - public async uninstall(scope: Scope, projectDir: string): Promise { - const base = this.scopeBase(scope, projectDir); - const configPath = path.join(base, "opencode.json"); - const manifestPath = path.join(base, MANIFEST_NAME); - const manifest = await this.readManifest(manifestPath); - const hadPluginEntry = await this.hasPluginEntry(configPath); + private async removeResidualPayload(base: string): Promise { const removed: string[] = []; - - if (manifest !== null) { - for (const entry of manifest.hashes) { - const target = path.join(base, entry.path); + const agentsDir = path.join(base, "agents"); + if (await exists(agentsDir)) { + for (const filename of AGENT_FILENAMES) { + const target = path.join(agentsDir, filename); if (await exists(target)) { await rm(target); removed.push(target); } } - await rm(manifestPath); - removed.push(manifestPath); - await this.removeIfEmpty(path.join(base, "opencode-architect", "templates")); - await this.removeIfEmpty(path.join(base, "opencode-architect", "references")); - await this.removeIfEmpty(path.join(base, "opencode-architect")); - await this.removeIfEmpty(path.join(base, "agents")); + await this.removeIfEmpty(agentsDir); } - - let pluginRemoved = false; - if (hadPluginEntry) { - await this.removePluginEntry(configPath); - pluginRemoved = true; + const packageDir = path.join(base, PACKAGE_NAME); + if (await exists(packageDir)) { + await rm(packageDir, { recursive: true }); + removed.push(packageDir); } - - const mode: InstallMode = manifest !== null ? "copy" : hadPluginEntry ? "plugin" : "none"; - return { scope, mode, removed, pluginRemoved }; + await this.removeIfEmpty(base); + return removed; } public async status(scope: Scope, projectDir: string): Promise { const base = this.scopeBase(scope, projectDir); const manifest = await this.readManifest(path.join(base, MANIFEST_NAME)); - if (manifest !== null) { - return { scope, mode: "copy", version: manifest.version }; + return { scope, mode: manifest.mode, version: manifest.version, configPath: manifest.configPath }; } - if (await this.hasPluginEntry(path.join(base, "opencode.json"))) { - return { scope, mode: "plugin", version: null }; + const registrationPath = await this.editor.findRegistration(PACKAGE_NAME, { scope, projectDir }); + if (registrationPath !== null) { + return { scope, mode: "plugin", version: null, configPath: registrationPath }; } - return { scope, mode: "none", version: null }; + return { scope, mode: "none", version: null, configPath: null }; } - private async disposition( - manifest: Manifest | null, + private async removePayloadPerManifest( base: string, - relativePath: string, - force: boolean, - ): Promise<{ action: "copy" | "overwrite" | "skip"; priorHash: string | null }> { - if (manifest === null) return { action: "copy", priorHash: null }; - const priorHash = manifest.hashes.find((entry) => entry.path === relativePath)?.hash ?? null; - if (priorHash === null) return { action: "copy", priorHash }; - const installedPath = path.join(base, relativePath); - if (!(await exists(installedPath))) return { action: "copy", priorHash }; - if ((await this.sha256File(installedPath)) === priorHash) return { action: "copy", priorHash }; - return { action: force ? "overwrite" : "skip", priorHash }; + hashes: ManifestHashEntry[], + ): Promise { + const removed: string[] = []; + for (const entry of hashes) { + const target = path.join(base, entry.path); + if (await exists(target)) { + await rm(target); + removed.push(target); + } + } + await this.prunePayloadDirs(base); + return removed; } - private async sha256File(filePath: string): Promise { - return createHash("sha256").update(await readFile(filePath)).digest("hex"); + private async prunePayloadDirs(base: string): Promise { + await this.removeIfEmpty(path.join(base, "opencode-architect", "templates")); + await this.removeIfEmpty(path.join(base, "opencode-architect", "references")); + await this.removeIfEmpty(path.join(base, "opencode-architect")); + await this.removeIfEmpty(path.join(base, "agents")); } - private scopeBase(scope: Scope, projectDir: string): string { - if (scope === "local") return path.join(projectDir, ".opencode"); - const xdgConfigHome = process.env.XDG_CONFIG_HOME; - if (xdgConfigHome) return path.join(xdgConfigHome, "opencode"); - return path.join(homedir(), ".config", "opencode"); + private async removeIfEmpty(directory: string): Promise { + if (!(await exists(directory))) return; + const contents = await readdir(directory); + if (contents.length === 0) await rmdir(directory); } private async readManifest(manifestPath: string): Promise { try { - return JSON.parse(await readFile(manifestPath, "utf-8")) as Manifest; + const parsed = JSON.parse(await readFile(manifestPath, "utf-8")) as Partial; + if (typeof parsed.version !== "string") return null; + if (typeof parsed.mode !== "string") { + if (!Array.isArray(parsed.hashes)) return null; + return { + version: parsed.version, + mode: "copy", + entry: null, + configPath: null, + "content-hash": null, + hashes: parsed.hashes as ManifestHashEntry[], + }; + } + return { + version: parsed.version, + mode: parsed.mode, + entry: parsed.entry ?? null, + configPath: parsed.configPath ?? null, + "content-hash": parsed["content-hash"] ?? null, + hashes: parsed.hashes ?? null, + }; } catch { return null; } } - private async hasPluginEntry(configPath: string): Promise { - const plugins = await this.readPluginArray(configPath); - return plugins.some((name) => this.isPluginEntry(name)); - } - - private async removePluginEntry(configPath: string): Promise { - const plugins = await this.readPluginArray(configPath); - const remaining = plugins.filter((name) => !this.isPluginEntry(name)); - const config = await this.readConfig(configPath); - if (remaining.length === 0) delete config.plugin; - else config.plugin = remaining; - await mkdir(path.dirname(configPath), { recursive: true }); - await writeFile(configPath, JSON.stringify(config, null, 2) + "\n"); - } - - private isPluginEntry(name: string): boolean { - return name === PACKAGE_NAME || name.startsWith(`${PACKAGE_NAME}@`); - } - - private async readPluginArray(configPath: string): Promise { - const config = await this.readConfig(configPath); - const plugins = config.plugin; - return Array.isArray(plugins) ? plugins.filter((name): name is string => typeof name === "string") : []; - } - - private async readConfig(configPath: string): Promise> { - try { - return JSON.parse(await readFile(configPath, "utf-8")) as Record; - } catch { - return {}; - } - } - private async getPackageVersion(): Promise { const content = await readFile(path.join(import.meta.dirname, "package.json"), "utf-8"); return (JSON.parse(content) as { version: string }).version; } - private async removeIfEmpty(directory: string): Promise { - if (!(await exists(directory))) return; - const contents = await readdir(directory); - if (contents.length === 0) await rmdir(directory); + private scopeBase(scope: Scope, projectDir: string): string { + if (scope === "local") return path.join(projectDir, ".opencode"); + const xdgConfigHome = process.env.XDG_CONFIG_HOME; + if (xdgConfigHome) return path.join(xdgConfigHome, "opencode"); + return path.join(homedir(), ".config", "opencode"); } } -export function rewriteReferencePaths(content: string, referencesDir: string, templatesDir: string): string { - return content.replace( - RELATIVE_REFERENCE_REGEX, - (token: string, relativePath: string): string => { - const normalized = relativePath.replaceAll("\\", "/"); - const packagedPath = path.resolve(ASSETS_AGENTS_DIR, normalized); - const withinReferences = path.relative(ASSETS_REFERENCES_DIR, packagedPath); - if (!withinReferences.startsWith("..")) { - const installedPath = path.resolve(referencesDir, withinReferences).replaceAll("\\", "/"); - return `\`${installedPath}\``; - } - const withinTemplates = path.relative(ASSETS_TEMPLATES_DIR, packagedPath); - if (!withinTemplates.startsWith("..")) { - const installedPath = path.resolve(templatesDir, withinTemplates).replaceAll("\\", "/"); - return `\`${installedPath}\``; - } - return token; - }, - ); +export async function contentHash(directory: string): Promise { + const result = await hashElement(directory, { encoding: "hex" }); + return result.hash; } diff --git a/package.json b/package.json index d328946..1cf90b7 100644 --- a/package.json +++ b/package.json @@ -51,12 +51,14 @@ "assets" ], "dependencies": { - "yaml": "^2.7.1", - "@opencode-ai/plugin": "*" + "@opencode-ai/plugin": "*", + "folder-hash": "^4.1.3", + "yaml": "^2.7.1" }, "devDependencies": { "@opencode-ai/sdk": "latest", "@types/bun": "latest", + "@types/folder-hash": "^4.0.4", "@types/node": "latest", "typescript": "latest" } diff --git a/plugin-config.ts b/plugin-config.ts index 2721655..032a447 100644 --- a/plugin-config.ts +++ b/plugin-config.ts @@ -21,6 +21,14 @@ interface CandidateConfig { writable: boolean; } +export interface RemovePluginEntryOutcome { + action: "noop" | "removed" | "blocked"; + configPath: string | null; + warning: string | null; +} + +export type ConfigCheck = { ok: true } | { ok: false; warning: string }; + interface PluginArrayRange { bracketStart: number; bracketEnd: number; @@ -75,6 +83,68 @@ export class PluginConfigEditor { return { action: "created", configPath: target, warning: null }; } + public async checkParseable(options: EnsurePluginEntryOptions): Promise { + for (const candidate of this.candidateConfigs(options)) { + if (!(await exists(candidate.path))) continue; + const text = await readFile(candidate.path, "utf-8"); + if (this.parsePluginArray(text, candidate.lenient) === null) { + return { + ok: false, + warning: + `Config file ${candidate.path} could not be parsed; refusing to modify it. ` + + `Fix or remove the file and re-run the command.`, + }; + } + } + return { ok: true }; + } + + public async findRegistration( + packageName: string, + options: EnsurePluginEntryOptions, + ): Promise { + for (const candidate of this.candidateConfigs(options)) { + if (!(await exists(candidate.path))) continue; + const text = await readFile(candidate.path, "utf-8"); + const plugins = this.parsePluginArray(text, candidate.lenient); + if (plugins === null) continue; + if (this.hasMatchingEntry(plugins, packageName)) return candidate.path; + } + return null; + } + + public async removePluginEntry( + packageName: string, + options: EnsurePluginEntryOptions, + ): Promise { + for (const candidate of this.candidateConfigs(options)) { + if (!(await exists(candidate.path)) || !candidate.writable) continue; + const text = await readFile(candidate.path, "utf-8"); + const plugins = this.parsePluginArray(text, candidate.lenient); + if (plugins === null) { + return { + action: "blocked", + configPath: candidate.path, + warning: + `Config file ${candidate.path} could not be parsed; refusing to modify it. ` + + `Fix or remove the file and re-run the uninstall.`, + }; + } + if (!this.hasMatchingEntry(plugins, packageName)) continue; + const spliced = this.spliceOutEntry(text, packageName, candidate.lenient); + if (spliced === null) { + return { + action: "blocked", + configPath: candidate.path, + warning: `Plugin array in ${candidate.path} could not be safely edited; file left untouched.`, + }; + } + await writeFile(candidate.path, spliced); + return { action: "removed", configPath: candidate.path, warning: null }; + } + return { action: "noop", configPath: null, warning: null }; + } + public hasMatchingEntry(entries: string[], packageName: string): boolean { return entries.some((entry) => this.matchesEntry(entry, packageName)); } @@ -201,6 +271,97 @@ export class PluginConfigEditor { return spliced; } + private spliceOutEntry(text: string, packageName: string, lenient: boolean): string | null { + const navigable = this.blankComments(text); + const range = this.findPluginArrayRange(navigable); + if (range === null) return null; + const innerStart = range.bracketStart + 1; + const innerEnd = range.bracketEnd; + const elements = this.arrayElementRanges(navigable, innerStart, innerEnd); + const target = elements.find((element) => { + const raw = text.slice(element.start, element.end); + return this.matchesEntry(this.unquote(raw), packageName); + }); + if (!target) return null; + const withComma = this.dropAdjacentComma(navigable, elements, target, innerStart, innerEnd); + const result = text.slice(0, withComma.start) + text.slice(withComma.end); + const plugins = this.parsePluginArray(result, lenient); + if (plugins === null || this.hasMatchingEntry(plugins, packageName)) return null; + return result; + } + + private arrayElementRanges( + navigable: string, + innerStart: number, + innerEnd: number, + ): Array<{ start: number; end: number }> { + const elements: Array<{ start: number; end: number }> = []; + let inString = false; + let depth = 0; + let start = -1; + for (let i = innerStart; i < innerEnd; i++) { + const current = navigable[i]; + if (inString) { + if (current === "\\") i++; + else if (current === '"') inString = false; + continue; + } + if (current === '"') { + inString = true; + if (start === -1) start = i; + continue; + } + if (current === "[" || current === "{") { + depth++; + if (start === -1) start = i; + continue; + } + if (current === "]" || current === "}") { + depth--; + continue; + } + if (current === "," && depth === 0) { + if (start !== -1) elements.push({ start, end: i }); + start = -1; + continue; + } + if (!/\s/.test(current ?? "") && start === -1) start = i; + } + if (start !== -1) elements.push({ start, end: innerEnd }); + return elements; + } + + private dropAdjacentComma( + navigable: string, + elements: Array<{ start: number; end: number }>, + target: { start: number; end: number }, + innerStart: number, + innerEnd: number, + ): { start: number; end: number } { + const index = elements.indexOf(target); + const next = elements[index + 1]; + if (next) return { start: target.start, end: next.start }; + const previous = elements[index - 1]; + if (previous) { + let commaEnd = target.start; + while (commaEnd > previous.end && /\s/.test(navigable[commaEnd - 1] ?? "")) commaEnd--; + if ((navigable[commaEnd - 1] ?? "") === ",") return { start: previous.end, end: commaEnd }; + } + let start = target.start; + while (start > innerStart && /\s/.test(navigable[start - 1] ?? "")) start--; + let end = target.end; + while (end < innerEnd && /\s/.test(navigable[end] ?? "")) end++; + return { start, end }; + } + + private unquote(raw: string): string { + const trimmed = raw.trim(); + if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) { + return trimmed.slice(1, -1); + } + return trimmed; + } + private spliceArrayEntry( text: string, navigable: string, diff --git a/tests/cli.test.ts b/tests/cli.test.ts index a481c61..ed94910 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -1,4 +1,6 @@ import { describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; import path from "node:path"; const PACKAGE_ROOT = path.resolve(import.meta.dirname, ".."); @@ -10,9 +12,9 @@ interface CliRun { stderr: string; } -async function runCli(args: string[]): Promise { +async function runCli(args: string[], cwd?: string): Promise { const proc = Bun.spawn([process.execPath, CLI_PATH, ...args], { - cwd: PACKAGE_ROOT, + cwd: cwd ?? PACKAGE_ROOT, stdout: "pipe", stderr: "pipe", }); @@ -51,4 +53,39 @@ describe("cli", () => { expect(run.exitCode).toBe(1); expect(run.stderr).toContain("Unknown command"); }); + + test("--mode copy is refused with an explanatory error", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "oa-cli-")); + try { + const run = await runCli(["install", "--mode", "copy"], dir); + + expect(run.exitCode).toBe(1); + expect(run.stderr).toContain("code-backed"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test("install in a scratch project registers the plugin and status reports it", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "oa-cli-")); + try { + const installRun = await runCli(["install"], dir); + + expect(installRun.exitCode).toBe(0); + expect(installRun.stdout).toContain("Registered"); + expect(installRun.stdout).toContain("opencode.jsonc"); + + const statusRun = await runCli(["status"], dir); + + expect(statusRun.exitCode).toBe(0); + expect(statusRun.stdout).toContain("mode=plugin"); + + const uninstallRun = await runCli(["uninstall"], dir); + + expect(uninstallRun.exitCode).toBe(0); + expect(uninstallRun.stdout).toContain("Uninstalled"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); }); diff --git a/tests/installer.test.ts b/tests/installer.test.ts index f902c19..cd7fb3a 100644 --- a/tests/installer.test.ts +++ b/tests/installer.test.ts @@ -1,17 +1,11 @@ import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import { createHash } from "node:crypto"; import { existsSync } from "node:fs"; import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import { AGENT_FILENAMES, RELATIVE_REFERENCE_REGEX } from "../agent-loader"; -import { Installer, rewriteReferencePaths, type Manifest, type Scope } from "../installer"; +import { Installer, contentHash, type Manifest, type Scope } from "../installer"; const PACKAGE_ROOT = path.resolve(import.meta.dirname, ".."); -const SOURCE_AGENTS_DIR = path.join(PACKAGE_ROOT, "assets", "agents"); -const SOURCE_REFERENCES_DIR = path.join(PACKAGE_ROOT, "assets", "references"); -const SOURCE_TEMPLATES_DIR = path.join(PACKAGE_ROOT, "assets", "templates"); -const ABSOLUTE_PATH_REGEX = /`([A-Za-z]:[\\/][^`]+|\/[^`]+)`/g; let projectDir = ""; let homeDir = ""; @@ -57,307 +51,261 @@ async function writeJson(filePath: string, value: Record): Prom await writeFile(filePath, JSON.stringify(value, null, 2)); } -async function sourceReferenceNames(): Promise { - const entries = await readdir(SOURCE_REFERENCES_DIR); - return entries.filter((name) => name.endsWith(".md")).sort(); -} - -async function sourceTemplateNames(): Promise { - return (await readdir(SOURCE_TEMPLATES_DIR)).sort(); -} - -async function sha256(filePath: string): Promise { - return sha256Text(await readFile(filePath, "utf-8")); +async function writeText(filePath: string, text: string): Promise { + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, text); } -function sha256Text(text: string): string { - return createHash("sha256").update(text).digest("hex"); +async function install(scope: Scope, options?: Partial<{ force: boolean; mode: "plugin" | "copy" }>) { + return installer.install(scope, { + force: options?.force ?? false, + mode: options?.mode ?? "plugin", + projectDir, + }); } describe("Installer.install", () => { - test("local install lays out agents, references, and manifest", async () => { - const outcome = await installer.install("local", { force: false, projectDir }); + test("registers the plugin entry and writes a plugin-mode manifest", async () => { + const configPath = path.join(scopeBase("local"), "opencode.json"); + await writeJson(configPath, { theme: "dark" }); + + const outcome = await install("local"); expect(outcome.action).toBe("installed"); - expect(outcome.scope).toBe("local"); - expect(outcome.agentsDir).toBe(path.join(scopeBase("local"), "agents")); - expect(outcome.referencesDir).toBe( - path.join(scopeBase("local"), "opencode-architect", "references"), - ); - expect(outcome.templatesDir).toBe( - path.join(scopeBase("local"), "opencode-architect", "templates"), - ); + expect(outcome.configPath).toBe(configPath); expect(outcome.manifestPath).toBe(manifestPath("local")); - const installedAgents = (await readdir(outcome.agentsDir)).sort(); - expect(installedAgents).toEqual([...AGENT_FILENAMES].sort()); - - const installedReferences = (await readdir(outcome.referencesDir)).sort(); - expect(installedReferences).toEqual(await sourceReferenceNames()); - - const installedTemplates = (await readdir(outcome.templatesDir)).sort(); - expect(installedTemplates).toEqual(await sourceTemplateNames()); + const config = await readJson(configPath); + expect(config.plugin).toEqual(["opencode-architect"]); + expect(config.theme).toBe("dark"); const manifest = (await readJson(manifestPath("local"))) as unknown as Manifest; expect(manifest.version).toBe(await readPackageVersion()); - expect(manifest.agentFiles.sort()).toEqual([...AGENT_FILENAMES].sort()); - expect(manifest.referencesDir).toBe(outcome.referencesDir); - expect(manifest.templatesDir).toBe(outcome.templatesDir); - - const hashedPaths = manifest.hashes.map((entry) => entry.path).sort(); - const expectedPaths = [ - ...AGENT_FILENAMES.map((name) => path.join("agents", name)), - ...installedReferences.map((name) => path.join("opencode-architect", "references", name)), - ...installedTemplates.map((name) => path.join("opencode-architect", "templates", name)), - ].sort(); - expect(hashedPaths).toEqual(expectedPaths); - - for (const entry of manifest.hashes) { - const installedPath = path.join(scopeBase("local"), entry.path); - expect(await sha256(installedPath)).toBe(entry.hash); - } + expect(manifest.mode).toBe("plugin"); + expect(manifest.entry).toBe("opencode-architect"); + expect(manifest.configPath).toBe(configPath); + expect(manifest["content-hash"]).toBeNull(); }); - test("payload separates templates into their own directory", async () => { - const outcome = await installer.install("local", { force: false, projectDir }); - - for (const name of await readdir(outcome.agentsDir)) { - expect(name.includes("template")).toBe(false); - } - for (const name of await readdir(outcome.referencesDir)) { - expect(name.includes("template")).toBe(false); - } - expect((await readdir(outcome.templatesDir)).sort()).toEqual(await sourceTemplateNames()); - }); + test("global install registers in the XDG config base", async () => { + const outcome = await install("global"); - test("global install respects XDG_CONFIG_HOME", async () => { - const outcome = await installer.install("global", { force: false, projectDir }); + expect(outcome.configPath).toBe(path.join(scopeBase("global"), "opencode.jsonc")); + expect(existsSync(manifestPath("global"))).toBe(true); - expect(outcome.scope).toBe("global"); - expect(outcome.agentsDir).toBe(path.join(scopeBase("global"), "agents")); - expect(existsSync(path.join(scopeBase("global"), "opencode-architect.json"))).toBe(true); - expect((await readdir(outcome.agentsDir)).sort()).toEqual([...AGENT_FILENAMES].sort()); + const text = await readFile(outcome.configPath as string, "utf-8"); + expect(text).toContain("opencode-architect"); }); - test("rewrites relative references to absolute installed paths", async () => { - const outcome = await installer.install("local", { force: false, projectDir }); - const agentPath = path.join(outcome.agentsDir, "opencode-architect.md"); - const content = await readFile(agentPath, "utf-8"); - - const leftoverRelative = [...content.matchAll(RELATIVE_REFERENCE_REGEX)].map((m) => m[1] ?? ""); - expect(leftoverRelative).toEqual([]); - - const absolutePaths = [...content.matchAll(ABSOLUTE_PATH_REGEX)].map((m) => m[1] ?? ""); - const referencePaths = absolutePaths.filter((candidate) => /\.md$/.test(candidate)); - expect(referencePaths.length).toBeGreaterThanOrEqual(9); - - const normalizedReferencesDir = outcome.referencesDir.replaceAll("\\", "/"); - for (const referencePath of referencePaths) { - expect(existsSync(referencePath), `missing rewritten path ${referencePath}`).toBe(true); - expect(referencePath.startsWith(normalizedReferencesDir)).toBe(true); - } - }); - - test("rewrites template references to absolute installed paths", async () => { - const outcome = await installer.install("local", { force: false, projectDir }); - const agentPath = path.join(outcome.agentsDir, "opencode-packager.md"); - const content = await readFile(agentPath, "utf-8"); - - const leftoverRelative = [...content.matchAll(RELATIVE_REFERENCE_REGEX)].map((m) => m[1] ?? ""); - expect(leftoverRelative).toEqual([]); - - const absolutePaths = [...content.matchAll(ABSOLUTE_PATH_REGEX)].map((m) => m[1] ?? ""); - const templatePaths = absolutePaths.filter((candidate) => /\.(txt|json|md)$/.test(candidate)); - expect(templatePaths.length).toBeGreaterThanOrEqual(5); - - const normalizedTemplatesDir = outcome.templatesDir.replaceAll("\\", "/"); - for (const templatePath of templatePaths) { - expect(existsSync(templatePath), `missing rewritten path ${templatePath}`).toBe(true); - expect(templatePath.startsWith(normalizedTemplatesDir)).toBe(true); - } - }); - - test("same-version re-run is a no-op", async () => { - await installer.install("local", { force: false, projectDir }); - - const agentPath = path.join(scopeBase("local"), "agents", "opencode-architect.md"); - const tampered = await readFile(agentPath, "utf-8") + "\ntampered"; - await writeFile(agentPath, tampered); + test("re-running with a matching manifest and entry is a zero-write no-op", async () => { + const configPath = path.join(scopeBase("local"), "opencode.json"); + await writeJson(configPath, {}); + await install("local"); const manifestBefore = await readFile(manifestPath("local"), "utf-8"); + const configBefore = await readFile(configPath, "utf-8"); - const outcome = await installer.install("local", { force: false, projectDir }); + const outcome = await install("local"); expect(outcome.action).toBe("noop"); - expect(outcome.copied).toEqual([]); - expect(outcome.skipped).toEqual([]); - expect(outcome.overwritten).toEqual([]); - expect(await readFile(agentPath, "utf-8")).toBe(tampered); expect(await readFile(manifestPath("local"), "utf-8")).toBe(manifestBefore); + expect(await readFile(configPath, "utf-8")).toBe(configBefore); }); - test("upgrade re-copies untouched files and skips locally modified ones", async () => { - await installer.install("local", { force: false, projectDir }); - - const fakeManifest = (await readJson(manifestPath("local"))) as unknown as Manifest; - fakeManifest.version = "0.0.1"; - await writeFile(manifestPath("local"), JSON.stringify(fakeManifest, null, 2)); - - const untouchedPath = path.join(scopeBase("local"), "agents", "opencode-architect.md"); - const untouchedInstalled = await readFile(untouchedPath, "utf-8"); - const doctored = untouchedInstalled + "\ndoctored but hash updated"; - await writeFile(untouchedPath, doctored); - fakeManifest.hashes = fakeManifest.hashes.map((entry) => - entry.path === path.join("agents", "opencode-architect.md") - ? { path: entry.path, hash: createHash("sha256").update(doctored).digest("hex") } - : entry, + test("an existing entry with a version spec matches semantically without a write", async () => { + const configPath = path.join(scopeBase("local"), "opencode.jsonc"); + await writeText( + configPath, + `{\n // keep this comment\n "plugin": ["other", "opencode-architect@latest"],\n}\n`, ); - await writeFile(manifestPath("local"), JSON.stringify(fakeManifest, null, 2)); - - const modifiedName = "opencode-skill-creator.md"; - const modifiedPath = path.join(scopeBase("local"), "agents", modifiedName); - const modifiedSource = await readFile(path.join(SOURCE_AGENTS_DIR, modifiedName), "utf-8"); - await writeFile(modifiedPath, modifiedSource + "\nlocally modified"); + const before = await readFile(configPath, "utf-8"); - const outcome = await installer.install("local", { force: false, projectDir }); + const outcome = await install("local"); - expect(outcome.action).toBe("upgraded"); - expect(outcome.skipped).toEqual([path.join("agents", modifiedName)]); - expect(outcome.copied).toContain(path.join("agents", "opencode-architect.md")); - expect(await readFile(untouchedPath, "utf-8")).toBe(untouchedInstalled); - expect(await readFile(modifiedPath, "utf-8")).toBe(modifiedSource + "\nlocally modified"); - expect((await readJson(manifestPath("local")) as unknown as Manifest).version).toBe( - await readPackageVersion(), - ); - - const manifestAfterUpgrade = (await readJson(manifestPath("local"))) as unknown as Manifest; - const modifiedEntry = manifestAfterUpgrade.hashes.find( - (entry) => entry.path === path.join("agents", modifiedName), - ); - const reinstalledSource = rewriteReferencePaths(modifiedSource, outcome.referencesDir, outcome.templatesDir); - expect(modifiedEntry?.hash).toBe(sha256Text(reinstalledSource)); - - manifestAfterUpgrade.version = "0.0.2"; - await writeFile(manifestPath("local"), JSON.stringify(manifestAfterUpgrade, null, 2)); - const secondUpgrade = await installer.install("local", { force: false, projectDir }); - expect(secondUpgrade.skipped).toEqual([path.join("agents", modifiedName)]); - expect(await readFile(modifiedPath, "utf-8")).toBe(modifiedSource + "\nlocally modified"); + expect(outcome.configPath).toBe(configPath); + expect(outcome.configAction).toBe("noop"); + expect(await readFile(configPath, "utf-8")).toBe(before); + expect(outcome.action).toBe("installed"); }); - test("upgrade with force overwrites locally modified files", async () => { - await installer.install("local", { force: false, projectDir }); - - const fakeManifest = (await readJson(manifestPath("local"))) as unknown as Manifest; - fakeManifest.version = "0.0.1"; - await writeFile(manifestPath("local"), JSON.stringify(fakeManifest, null, 2)); + test("splicing preserves comments and formatting byte-for-byte outside the array", async () => { + const configPath = path.join(scopeBase("local"), "opencode.jsonc"); + const original = [ + "{", + " // my precious comment", + ' "$schema": "https://opencode.ai/config.json",', + ' "plugin": [', + ' // plugin note', + ' "other-extension",', + " ],", + ' "theme": "dark",', + "}", + "", + ].join("\n"); + await writeText(configPath, original); + + await install("local"); + + const after = await readFile(configPath, "utf-8"); + expect(after).toContain("// my precious comment"); + expect(after).toContain("// plugin note"); + expect(after.indexOf("opencode-architect")).toBeLessThan(after.indexOf("other-extension")); + const withoutEntry = after.replace(`\n "opencode-architect",`, ""); + expect(withoutEntry).toBe(original); + }); - const modifiedName = "opencode-skill-creator.md"; - const modifiedPath = path.join(scopeBase("local"), "agents", modifiedName); - const modifiedSource = await readFile(path.join(SOURCE_AGENTS_DIR, modifiedName), "utf-8"); - await writeFile(modifiedPath, modifiedSource + "\nlocally modified"); + test("an unparseable config aborts with nothing written", async () => { + const configPath = path.join(scopeBase("local"), "opencode.json"); + const broken = "{ not json ]"; + await writeText(configPath, broken); - const outcome = await installer.install("local", { force: true, projectDir }); + await expect(install("local")).rejects.toThrow(/could not be parsed/); - expect(outcome.action).toBe("upgraded"); - expect(outcome.overwritten).toContain(path.join("agents", modifiedName)); - expect(await readFile(modifiedPath, "utf-8")).toBe( - rewriteReferencePaths(modifiedSource, outcome.referencesDir, outcome.templatesDir), - ); + expect(await readFile(configPath, "utf-8")).toBe(broken); + expect(existsSync(manifestPath("local"))).toBe(false); }); - test("refuses while the plugin entry exists", async () => { - await writeJson(path.join(scopeBase("local"), "opencode.json"), { - plugin: ["opencode-architect"], - }); - - expect(installer.install("local", { force: false, projectDir })).rejects.toThrow(/--force/); + test("no config anywhere creates a default config with the entry", async () => { + const outcome = await install("local"); - expect(existsSync(path.join(scopeBase("local"), "agents"))).toBe(false); + expect(outcome.configAction).toBe("created"); + expect(outcome.configPath).toBe(path.join(projectDir, "opencode.jsonc")); + const text = await readFile(path.join(projectDir, "opencode.jsonc"), "utf-8"); + expect(text).toContain('"plugin": ["opencode-architect"]'); }); - test("refuses and force-removes a versioned plugin entry", async () => { - await writeJson(path.join(scopeBase("local"), "opencode.json"), { - plugin: ["opencode-architect@^0.3.0"], - }); - - expect(installer.install("local", { force: false, projectDir })).rejects.toThrow(/--force/); - expect(existsSync(path.join(scopeBase("local"), "agents"))).toBe(false); + test("refuses copy mode with an explanatory error", async () => { + await expect(install("local", { mode: "copy" })).rejects.toThrow(/code-backed/); + expect(existsSync(manifestPath("local"))).toBe(false); + }); - const outcome = await installer.install("local", { force: true, projectDir }); + test("migrates a legacy copy install: payload removed per manifest, entry added", async () => { + const configPath = path.join(scopeBase("local"), "opencode.json"); + await writeJson(configPath, {}); + const legacy = { + version: "0.0.1", + agentFiles: ["opencode-architect.md"], + referencesDir: path.join(scopeBase("local"), "opencode-architect", "references"), + templatesDir: path.join(scopeBase("local"), "opencode-architect", "templates"), + hashes: [ + { path: path.join("agents", "opencode-architect.md"), hash: "deadbeef" }, + { + path: path.join("opencode-architect", "references", "agents.md"), + hash: "feedface", + }, + ], + }; + await writeJson(manifestPath("local"), legacy); + await mkdir(path.join(scopeBase("local"), "agents"), { recursive: true }); + await writeText(path.join(scopeBase("local"), "agents", "opencode-architect.md"), "old agent"); + await mkdir(path.join(scopeBase("local"), "opencode-architect", "references"), { recursive: true }); + await writeText(path.join(scopeBase("local"), "opencode-architect", "references", "agents.md"), "old ref"); + const consumerAgent = path.join(scopeBase("local"), "agents", "consumer-own.md"); + await writeText(consumerAgent, "# consumer's own"); + + const outcome = await install("local"); + + expect(outcome.action).toBe("migrated"); + expect(outcome.removedPayload).toContain(path.join(scopeBase("local"), "agents", "opencode-architect.md")); + expect(outcome.removedPayload).toContain( + path.join(scopeBase("local"), "opencode-architect", "references", "agents.md"), + ); + expect(existsSync(path.join(scopeBase("local"), "agents", "opencode-architect.md"))).toBe(false); + expect(existsSync(consumerAgent)).toBe(true); + expect(existsSync(path.join(scopeBase("local"), "opencode-architect"))).toBe(false); - expect(outcome.action).toBe("installed"); - expect(outcome.pluginRemoved).toBe(true); - expect((await readJson(path.join(scopeBase("local"), "opencode.json"))).plugin).toBeUndefined(); - expect(existsSync(outcome.manifestPath)).toBe(true); + const config = await readJson(path.join(scopeBase("local"), "opencode.json")); + expect(config.plugin).toEqual(["opencode-architect"]); + const manifest = (await readJson(manifestPath("local"))) as unknown as Manifest; + expect(manifest.mode).toBe("plugin"); }); - test("force removes the plugin entry and switches to copy mode", async () => { - await writeJson(path.join(scopeBase("local"), "opencode.json"), { - plugin: ["other-extension", "opencode-architect"], - }); + test("migration aborts with the payload intact when a config is unparseable", async () => { + const legacy = { + version: "0.0.1", + hashes: [{ path: path.join("agents", "opencode-architect.md"), hash: "deadbeef" }], + }; + await writeJson(manifestPath("local"), legacy); + await mkdir(path.join(scopeBase("local"), "agents"), { recursive: true }); + await writeText(path.join(scopeBase("local"), "agents", "opencode-architect.md"), "old agent"); + const configPath = path.join(scopeBase("local"), "opencode.json"); + await writeText(configPath, "{ broken ]"); + + await expect(install("local")).rejects.toThrow(/could not be parsed/); + + expect(existsSync(path.join(scopeBase("local"), "agents", "opencode-architect.md"))).toBe(true); + expect(existsSync(manifestPath("local"))).toBe(true); + }); - const outcome = await installer.install("local", { force: true, projectDir }); + test("force re-registers and rewrites an up-to-date manifest", async () => { + await install("local"); + const manifest = (await readJson(manifestPath("local"))) as unknown as Manifest; + manifest.version = "0.0.1"; + await writeFile(manifestPath("local"), JSON.stringify(manifest, null, 2)); - expect(outcome.action).toBe("installed"); - expect(outcome.pluginRemoved).toBe(true); - expect(existsSync(outcome.manifestPath)).toBe(true); - expect((await readdir(outcome.agentsDir)).sort()).toEqual([...AGENT_FILENAMES].sort()); + const outcome = await install("local", { force: true }); - const config = await readJson(path.join(scopeBase("local"), "opencode.json")); - expect(config.plugin).toEqual(["other-extension"]); + expect(outcome.action).toBe("upgraded"); + expect(((await readJson(manifestPath("local"))) as unknown as Manifest).version).toBe( + await readPackageVersion(), + ); }); }); describe("Installer.uninstall", () => { - test("copy mode removes exactly the manifest files and keeps consumer files", async () => { - await installer.install("local", { force: false, projectDir }); - - const consumerAgentPath = path.join(scopeBase("local"), "agents", "consumer-own-agent.md"); - await writeFile(consumerAgentPath, "# consumer's own agent"); - const consumerRefPath = path.join( - scopeBase("local"), - "opencode-architect", - "references", - "consumer-note.md", - ); - await writeFile(consumerRefPath, "consumer note"); + test("plugin mode removes the entry and the manifest", async () => { + const configPath = path.join(scopeBase("local"), "opencode.json"); + await writeJson(configPath, {}); + await install("local"); + const before = await readFile(configPath, "utf-8"); const outcome = await installer.uninstall("local", projectDir); - expect(outcome.mode).toBe("copy"); - expect(outcome.removed).toContain(path.join(scopeBase("local"), "agents", "opencode-architect.md")); - expect(outcome.removed).toContain( - path.join(scopeBase("local"), "opencode-architect", "references", "agents.md"), - ); - expect(outcome.removed).toContain(manifestPath("local")); + expect(outcome.mode).toBe("plugin"); + expect(outcome.pluginRemoved).toBe(true); + expect(outcome.configPath).toBe(configPath); expect(existsSync(manifestPath("local"))).toBe(false); - expect(existsSync(path.join(scopeBase("local"), "agents", "opencode-architect.md"))).toBe(false); - expect(existsSync(consumerAgentPath)).toBe(true); - expect(existsSync(consumerRefPath)).toBe(true); - expect(await readFile(consumerAgentPath, "utf-8")).toBe("# consumer's own agent"); + const config = await readJson(configPath); + expect(config.plugin).toEqual([]); + expect((await readFile(configPath, "utf-8")).length).toBeLessThan(before.length); }); - test("copy uninstall removes emptied directories", async () => { - await installer.install("local", { force: false, projectDir }); + test("legacy copy uninstall removes exactly the manifest files and keeps consumer files", async () => { + const legacy = { + version: "0.0.1", + agentFiles: [], + referencesDir: "", + templatesDir: "", + hashes: [{ path: path.join("agents", "opencode-architect.md"), hash: "deadbeef" }], + }; + await writeJson(manifestPath("local"), legacy); + await mkdir(path.join(scopeBase("local"), "agents"), { recursive: true }); + await writeText(path.join(scopeBase("local"), "agents", "opencode-architect.md"), "old agent"); + const consumerAgent = path.join(scopeBase("local"), "agents", "consumer-own.md"); + await writeText(consumerAgent, "# consumer's own"); - await installer.uninstall("local", projectDir); + const outcome = await installer.uninstall("local", projectDir); - expect(existsSync(path.join(scopeBase("local"), "agents"))).toBe(false); - expect(existsSync(path.join(scopeBase("local"), "opencode-architect"))).toBe(false); + expect(outcome.mode).toBe("copy"); + expect(outcome.removed).toContain(path.join(scopeBase("local"), "agents", "opencode-architect.md")); + expect(outcome.removed).toContain(manifestPath("local")); + expect(existsSync(consumerAgent)).toBe(true); }); - test("plugin mode removes only the plugin entry", async () => { - await writeJson(path.join(scopeBase("local"), "opencode.json"), { - plugin: ["opencode-architect", "other-extension"], - }); + test("uninstall without a manifest sweeps residual legacy payload and keeps consumer files", async () => { + await mkdir(path.join(scopeBase("local"), "agents"), { recursive: true }); + await writeText(path.join(scopeBase("local"), "agents", "opencode-architect.md"), "old agent"); + const consumerAgent = path.join(scopeBase("local"), "agents", "consumer-own.md"); + await writeText(consumerAgent, "# consumer's own"); + const packageDir = path.join(scopeBase("local"), "opencode-architect", "references"); + await mkdir(packageDir, { recursive: true }); + await writeText(path.join(packageDir, "agents.md"), "old ref"); const outcome = await installer.uninstall("local", projectDir); - expect(outcome.mode).toBe("plugin"); - expect(outcome.pluginRemoved).toBe(true); - expect(outcome.removed).toEqual([]); - - const config = await readJson(path.join(scopeBase("local"), "opencode.json")); - expect(config.plugin).toEqual(["other-extension"]); + expect(outcome.removed).toContain(path.join(scopeBase("local"), "agents", "opencode-architect.md")); + expect(outcome.removed).toContain(path.join(scopeBase("local"), "opencode-architect")); + expect(existsSync(consumerAgent)).toBe(true); + expect(existsSync(path.join(scopeBase("local"), "opencode-architect"))).toBe(false); }); test("uninstall with nothing installed is a no-op", async () => { @@ -367,6 +315,26 @@ describe("Installer.uninstall", () => { expect(outcome.removed).toEqual([]); expect(outcome.pluginRemoved).toBe(false); }); + + test("uninstall aborts untouched on an unparseable config with the entry", async () => { + const configPath = path.join(scopeBase("local"), "opencode.json"); + await writeText(configPath, "{ plugin: [broken"); + const legacy = { + version: "0.0.1", + agentFiles: [], + referencesDir: "", + templatesDir: "", + hashes: [{ path: path.join("agents", "opencode-architect.md"), hash: "deadbeef" }], + }; + await writeJson(manifestPath("local"), legacy); + await mkdir(path.join(scopeBase("local"), "agents"), { recursive: true }); + await writeText(path.join(scopeBase("local"), "agents", "opencode-architect.md"), "old agent"); + + await expect(installer.uninstall("local", projectDir)).rejects.toThrow(/could not be parsed/); + + expect(existsSync(manifestPath("local"))).toBe(true); + expect(existsSync(path.join(scopeBase("local"), "agents", "opencode-architect.md"))).toBe(true); + }); }); describe("Installer.status", () => { @@ -375,18 +343,22 @@ describe("Installer.status", () => { expect(outcome.mode).toBe("none"); expect(outcome.version).toBeNull(); + expect(outcome.configPath).toBeNull(); }); - test("reports copy with the manifest version", async () => { - await installer.install("local", { force: false, projectDir }); + test("reports plugin mode with version and registration file", async () => { + const configPath = path.join(scopeBase("local"), "opencode.json"); + await writeJson(configPath, {}); + await install("local"); const outcome = await installer.status("local", projectDir); - expect(outcome.mode).toBe("copy"); + expect(outcome.mode).toBe("plugin"); expect(outcome.version).toBe(await readPackageVersion()); + expect(outcome.configPath).toBe(configPath); }); - test("reports plugin when only the entry exists", async () => { + test("detects a registration without a manifest", async () => { await writeJson(path.join(scopeBase("local"), "opencode.json"), { plugin: ["opencode-architect"], }); @@ -395,17 +367,20 @@ describe("Installer.status", () => { expect(outcome.mode).toBe("plugin"); expect(outcome.version).toBeNull(); + expect(outcome.configPath).toBe(path.join(scopeBase("local"), "opencode.json")); }); +}); - test("prefers copy when the manifest and the plugin entry both exist", async () => { - await installer.install("local", { force: false, projectDir }); - await writeJson(path.join(scopeBase("local"), "opencode.json"), { - plugin: ["opencode-architect"], - }); +describe("contentHash", () => { + test("produces a stable hex digest for a directory", async () => { + const dir = path.join(projectDir, "payload"); + await mkdir(dir); + await writeFile(path.join(dir, "a.txt"), "hello"); - const outcome = await installer.status("local", projectDir); + const first = await contentHash(dir); + const second = await contentHash(dir); - expect(outcome.mode).toBe("copy"); - expect(outcome.version).toBe(await readPackageVersion()); + expect(first).toMatch(/^[0-9a-f]+$/); + expect(first).toBe(second); }); }); From 37472ef362c0c02333c02ac8ad57df346eb847a9 Mon Sep 17 00:00:00 2001 From: "Diego B." Date: Wed, 23 Sep 2026 02:03:35 -0400 Subject: [PATCH 04/24] docs(domain): skills-market compatibility (ADR-0009) --- CONTEXT.md | 13 +++++ docs/adr/0009-skills-market-compatibility.md | 55 ++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 docs/adr/0009-skills-market-compatibility.md diff --git a/CONTEXT.md b/CONTEXT.md index 838c9b9..e4871e0 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -140,6 +140,19 @@ and commands) into the scope base, leaving visible, editable files. The default install for assets-only packages; mutually exclusive with plugin install in the same scope. +**Skills market**: +The `npx skills add` consumption channel and its skills.sh listing. Two +distinct requirements compose market compatibility. **Discoverability**: the +package's skills sit at a market-findable file layout — the market tool scans +the package for directories containing a frontmatter-valid `SKILL.md`, with +`skills//` the canonical container — a property of the produced +package's structure regardless of install mode. **Install parity**: the copy +install default for assets-only packages leaves skills as visible, editable +files, matching the kind of end state the market tool produces. Code-backed +packages keep discoverability but lose install parity, which is why skill +collections split into assets-only packages. +_Avoid_: skills.sh (the listing site, not the channel) + **Payload**: What a copy install places in the consumer's project: the package's skills and commands. diff --git a/docs/adr/0009-skills-market-compatibility.md b/docs/adr/0009-skills-market-compatibility.md new file mode 100644 index 0000000..811d4bb --- /dev/null +++ b/docs/adr/0009-skills-market-compatibility.md @@ -0,0 +1,55 @@ +# Skills-market compatibility for skill-bearing packages + +Any package created with this suite's guidance that ships at least one skill +must remain consumable through the `npx skills add` channel, and should be +structured so it can list on skills.sh as it gains consumption. This is a +compatibility constraint on packaging, not a distribution target: the +package may also be a local `file:///` package or an npm plugin package, and +the skills inside stay market-consumable regardless. + +Market compatibility composes two distinct requirements, validated against +the market CLI's source (`vercel-labs/skills`). **Discoverability** is a +property of the produced extension package's file layout only: the market +tool scans the package for directories containing a SKILL.md with valid +`name`/`description` frontmatter — priority containers such as +`skills//` are walked first, then a full recursive scan finds any +`SKILL.md` anywhere in the package — so the layout constraint binds every +skill-bearing package, code-backed or not, independent of install mode. +**Install parity** is a property of the deployed artifact: skills must end +up in the consumer's project as visible, editable files, the kind of end +state the market tool produces (it installs OpenCode skills to +`.agents/skills/`; this suite's CLI copies to `.opencode/skills/` — a +benign divergence, since both load in OpenCode). + +The copy install default for assets-only packages (ADR-0008) is load-bearing +for install parity, not discoverability: without it, a code-backed package's +skills exist only as assets resolved from a registered package at load time, +and no copy path delivers them as consumer-visible files. + +## Considered Options + +- Compatibility constraint on all skill-bearing packages (chosen): every + package's skills stay market-discoverable by layout, and assets-only + packages keep install parity through the copy default +- Skills-market-only packages: rejected — it would forbid plugin + registration for skills and break the always-fresh opt-in +- Treating the market as just another distribution target: rejected — the + market consumes the same package artifact, so it constrains structure, not + where the package is headed + +## Consequences + +- A produced package keeps its skills in a market-scannable layout — + recommended `skills//SKILL.md` — whatever else it ships and however + it installs; discoverability never depends on install mode +- A code-backed package's skills ride load-time installation: they stay + discoverable, but no copy path delivers them as visible, editable consumer + files, so consumers who want install parity via `npx skills add` must + install the skill-bearing subset separately or the package author must + split it; the constraint pushes authors to split skill collections into + assets-only packages +- skills.sh listing follows from CLI-installed consumption (the CLI reports + telemetry on installs of public repos), not a separate submission — so + market-scannable layout plus consumption is all that listing requires +- The extension auditor checks market compatibility when reviewing a built + package that ships skills From 24d18f65e9491bb542dd00eecaee1c503c481efc Mon Sep 17 00:00:00 2001 From: "Diego B." Date: Wed, 23 Sep 2026 02:32:06 -0400 Subject: [PATCH 05/24] feat(templates): content-based deployment plans in generated packages (#18) Installer template reads the content declaration: assets-only packages copy-install by default (no config writes) with --mode plugin as the opt-in; code-backed packages always register and --mode copy throws CopyModeUnsupportedError. Plugin-array edits go through a ported surgical editor (plugin-config.template.txt), the manifest generalizes to record mode, entry, and target config file (preserving payload hashes across both paths), registration detection adds global config.json, and status/uninstall reflect mode, version, and registration. The load-time hook ensures assets only in registered scopes, performs zero writes when the manifest matches and the entry is present, and never throws. --- assets/agents/opencode-packager.md | 3 +- assets/agents/opencode-publisher.md | 5 +- assets/templates/cli.template.txt | 61 ++- assets/templates/installer.template.txt | 323 +++++++------ assets/templates/manifest.template.txt | 82 +++- assets/templates/plugin-config.template.txt | 497 ++++++++++++++++++++ assets/templates/plugin-local.template.txt | 11 +- assets/templates/prompts.template.txt | 16 +- assets/templates/registration.template.txt | 10 +- tests/deployment-plan.test.ts | 96 ++++ 10 files changed, 913 insertions(+), 191 deletions(-) create mode 100644 assets/templates/plugin-config.template.txt create mode 100644 tests/deployment-plan.test.ts diff --git a/assets/agents/opencode-packager.md b/assets/agents/opencode-packager.md index 2cc2bcf..d55a42c 100644 --- a/assets/agents/opencode-packager.md +++ b/assets/agents/opencode-packager.md @@ -39,7 +39,7 @@ opencode-myextension/ └── tsconfig.json ``` -6. **Create plugin.ts** from `../templates/plugin-local.template.txt`, plus `src/plugin-name.ts` from `../templates/plugin-name.template.txt`, `src/manifest.ts` from `../templates/manifest.template.txt`, and `src/registration.ts` from `../templates/registration.template.txt`: the load hook performs read-only registration-scope detection, then ensures skills, commands, and agents only for the scopes where the plugin is registered, gated by the per-scope install manifest (version + per-file sha256). Never write outside the detected scopes, never edit `plugin` arrays or root configs at load, and never rewrite a config that fails to parse. +6. **Create plugin.ts** from `../templates/plugin-local.template.txt`, plus `src/plugin-name.ts` from `../templates/plugin-name.template.txt`, `src/manifest.ts` from `../templates/manifest.template.txt`, `src/registration.ts` from `../templates/registration.template.txt`, and `src/plugin-config.ts` from `../templates/plugin-config.template.txt`: the load hook performs read-only registration-scope detection, then ensures skills, commands, and agents only for the scopes where the plugin is registered, gated by the per-scope install manifest (version, mode, registration, per-file sha256). Never write outside the detected scopes, never edit `plugin` arrays or root configs at load, and never rewrite a config that fails to parse. The install logic is content-based (ADR-0008): the same installer serves both the global scope base and the project scope base with identical behavior — copy install touches no config, plugin registration goes through the surgical editor, and code-backed packages refuse `--mode copy`. 7. **Create package.json** from `../templates/package-basics.template.json` and tsconfig.json from `../templates/tsconfig.template.json`. The template ships with `"content": "assets"`; the asset inventory is the source of truth for the declaration (ADR-0008): keep `"assets"` only when the package ships skills and/or commands alone; set `"code"` when the source contained any agent, tool, plugin, hook, or other plugin integration — code-backed packages may also ship skills and commands. Report the decision: "Content declaration: code (1 agent, 2 tools found)" or "Content declaration: assets (skills and commands only)". @@ -55,6 +55,7 @@ Done when the target tree matches step 5 and the plugin performs a zero-write no - `../templates/plugin-name.template.txt` - `../templates/manifest.template.txt` - `../templates/registration.template.txt` +- `../templates/plugin-config.template.txt` - `../templates/tsconfig.template.json` - `../templates/skill-structure.template.md` diff --git a/assets/agents/opencode-publisher.md b/assets/agents/opencode-publisher.md index c414e66..e7c1ecf 100644 --- a/assets/agents/opencode-publisher.md +++ b/assets/agents/opencode-publisher.md @@ -19,9 +19,9 @@ You are an OpenCode extension publisher: you transform locally-packaged extensio 1. **Verify the incoming package.** Confirm the packager's structure exists: a bundled asset directory (`assets/skills/` etc., or repo-root `skills//`), `plugin.ts` with inline install logic, minimal `package.json` with a `content` declaration (`assets` or `code`), `tsconfig.json`. Read the packager summary for extension name, description, included assets, dependencies, warnings, and the content declaration. Confirm every asset landed in the package and custom plugins or tools got their merge decisions. Cross-check the declaration against the bundled assets: `assets` requires skills/commands only — any agent, tool, or plugin file in the package contradicts it and returns to the orchestrator for repackaging; `code` is valid for any inventory. An invalid structure returns to the orchestrator for repackaging. -2. **Extract install logic to src/installer.ts.** Move install(), uninstall(), status(), scope detection, path resolution, and config management out of plugin.ts, keeping the manifest module (src/manifest.ts), plugin-name normalizer (src/plugin-name.ts), and registration detector (src/registration.ts) as separate files; update plugin.ts to call install() from src/installer.ts. Preserve the invariants: manifest-gated idempotency (no `.version` markers), semantic `@latest` plugin dedup written canonically as `name@latest`, abort-with-warning on unparseable config (never rewrite from `{}`), skip consumer-modified files unless `--force`, and root-config migration CLI-only behind explicit consent. +2. **Extract install logic to src/installer.ts.** Move install(), uninstall(), status(), scope detection, path resolution, and config management out of plugin.ts, keeping the manifest module (src/manifest.ts), plugin-name normalizer (src/plugin-name.ts), registration detector (src/registration.ts), and surgical config editor (src/plugin-config.ts) as separate files; update plugin.ts to call install() from src/installer.ts. Preserve the content-based deployment plan (ADR-0008): the installer reads the `content` declaration — assets-only packages copy-install by default (copy touches no config file) with `--mode plugin` as the opt-in; code-backed packages always register and `--mode copy` is a hard error. Preserve the invariants: manifest-gated idempotency (no `.version` markers; the plugin-mode no-op includes a present entry), semantic `@latest` plugin dedup written canonically as `name@latest` via the surgical editor only, abort-with-warning on unparseable config (never rewrite from `{}`), skip consumer-modified files unless `--force`, and root-config migration CLI-only behind explicit consent. -3. **Create the CLI entry point.** Build src/cli.ts from `../templates/cli.template.txt`: install command calls install(scope, projectDir, { force }), uninstall calls uninstall(scope, projectDir), status calls status(projectDir), migrate calls migrateRootConfig only behind `--force` consent. +3. **Create the CLI entry point.** Build src/cli.ts from `../templates/cli.template.txt`: install command calls install(scope, projectDir, { mode, force }) — it resolves the mode from the content declaration and surfaces `CopyModeUnsupportedError` as an explanatory exit-1 error — uninstall calls uninstall(scope, projectDir), status calls status(projectDir) and reports mode, version, entry, and the target config file per scope, migrate calls migrateRootConfig only behind `--force` consent. 4. **Expand package.json** from `../templates/package-full.template.json`: bin field for the CLI, scripts (check, test), expanded dependencies, npm fields (repository, bugs, license, author). Carry the packager's `content` declaration through unchanged — expansion adds npm fields, never alters the declaration. @@ -75,6 +75,7 @@ Done when the package is live and the user has the registry URL plus consumer in - `../templates/plugin-name.template.txt` - Semantic plugin-name normalizer (src/plugin-name.ts) - `../templates/manifest.template.txt` - Install manifest with per-file sha256 (src/manifest.ts) - `../templates/registration.template.txt` - Read-only registration-scope detector (src/registration.ts) +- `../templates/plugin-config.template.txt` - Surgical plugin-array config editor (src/plugin-config.ts) - `../templates/cli.template.txt` - bunx CLI entry point - `../templates/prompts.template.txt` - Interactive confirmation helpers diff --git a/assets/templates/cli.template.txt b/assets/templates/cli.template.txt index f3c5c63..98b1772 100644 --- a/assets/templates/cli.template.txt +++ b/assets/templates/cli.template.txt @@ -7,12 +7,12 @@ | `COMMANDS` | Yes | install / uninstall / status / migrate subcommands | — | | `SCOPE` → "local" | Yes | Default scope when `--scope` is not specified | `local` | -**Load-bearing — do not simplify:** `migrate` refuses to run without `--force` (root-config deletion is consent-gated and CLI-only); `install` forwards `force` so consumer-modified files are skipped without it; the module lives at `src/cli.ts` and imports `./installer.ts`. +**Load-bearing — do not simplify:** the deployment plan is content-based, so `install` resolves the mode through `resolveMode` and never forces one: assets-only packages default to copy and accept `--mode plugin`; code-backed packages always register and `--mode copy` surfaces the `CopyModeUnsupportedError` as a hard, explanatory exit-1 error; `install` forwards `force` so consumer-modified files are skipped without it; `status` reports mode, version, entry, entry config file, and registration presence per scope; `migrate` refuses to run without `--force` (root-config deletion is consent-gated and CLI-only); the module lives at `src/cli.ts` and imports `./installer.ts`. --- #!/usr/bin/env bun import { parseArgs } from "node:util"; -import { install, uninstall, status, migrateRootConfig, type Scope } from "./installer.ts"; +import { install, uninstall, status, migrateRootConfig, CopyModeUnsupportedError, type Scope, type InstallMode } from "./installer.ts"; const VERSION = JSON.parse( await Bun.file(`${import.meta.dirname}/../package.json`).text() @@ -23,13 +23,14 @@ function printHelp(): void { opencode-myextension v${VERSION} Commands: - install Install the myextension skill and command - uninstall Remove the myextension skill and command - status Check installation status + install Install myextension (copy by default; plugin registration with --mode plugin) + uninstall Remove myextension (payload files and/or plugin registration, per manifest) + status Check installation status per scope migrate Migrate root opencode.json into .opencode/ (asks for --force consent) Options: - -s, --scope Installation scope: "local" or "global" + -s, --scope Installation scope: "local" or "global" (default: local) + -m, --mode Install mode: "copy" (default for assets-only) or "plugin" (mandatory for code-backed) -f, --force Overwrite consumer-modified files / consent to migration -h, --help Show this help message -v, --version Show version @@ -37,16 +38,22 @@ Options: Examples: opencode-myextension install opencode-myextension install --scope global + opencode-myextension install --mode plugin opencode-myextension uninstall --scope local opencode-myextension status opencode-myextension migrate --force `); } +function isInstallMode(value: string): value is InstallMode { + return value === "copy" || value === "plugin"; +} + async function main(): Promise { const { positionals, values } = parseArgs({ options: { scope: { type: "string", short: "s" }, + mode: { type: "string", short: "m" }, force: { type: "boolean", short: "f", default: false }, help: { type: "boolean", short: "h", default: false }, version: { type: "boolean", short: "v", default: false }, @@ -60,38 +67,56 @@ async function main(): Promise { const command = positionals[0]; const scope: Scope | undefined = values.scope as Scope | undefined; + const mode: InstallMode | undefined = + values.mode === undefined ? undefined : isInstallMode(values.mode) ? values.mode : ("invalid" as InstallMode); const force: boolean = values.force; if (scope && scope !== "local" && scope !== "global") { console.error(`Invalid scope: ${scope}. Must be "local" or "global".`); process.exit(1); } + if (mode === ("invalid" as InstallMode)) { + console.error(`Invalid mode: ${values.mode}. Must be "copy" or "plugin".`); + process.exit(1); + } try { switch (command) { case "install": { const installScope = scope || "local"; - const result = await install(installScope, process.cwd(), { force }); - console.log(`Installed ${installScope}${result.upToDate ? " (already up to date)" : ""}:`); - console.log(` Skill: ${result.skillPath}`); - console.log(` Command: ${result.commandPath}`); - for (const skipped of result.skippedModified) { - console.log(` Skipped consumer-modified: ${skipped} (use --force to overwrite)`); + const result = await install(installScope, process.cwd(), { mode, force }); + console.log(`Installed ${installScope} (mode: ${result.mode})${result.upToDate ? " (already up to date)" : ""}:`); + if (result.mode === "copy") { + console.log(` Skill: ${result.skillPath}`); + console.log(` Command: ${result.commandPath}`); + for (const skipped of result.skippedModified) { + console.log(` Skipped consumer-modified: ${skipped} (use --force to overwrite)`); + } + } else { + console.log(` Plugin entry: ${result.entry} in ${result.entryConfigPath}`); } break; } case "uninstall": { const uninstallScope = scope || "local"; const result = await uninstall(uninstallScope, process.cwd()); - console.log(`Uninstalled ${uninstallScope}:`); - console.log(` Removed: ${result.removed.join(", ")}`); + console.log(`Uninstalled ${uninstallScope}${result.mode ? ` (mode: ${result.mode})` : ""}:`); + if (result.removed.length > 0) console.log(` Removed: ${result.removed.join(", ")}`); + if (result.pluginRemoved) console.log(` Removed plugin registration`); break; } case "status": { const result = await status(process.cwd()); console.log("Status:"); - if (result.local) console.log(` Local: installed=${result.local.installed}, version=${result.local.version ?? "no manifest"}, pluginInConfig=${result.local.pluginInConfig}`); - if (result.global) console.log(` Global: installed=${result.global.installed}, version=${result.global.version ?? "no manifest"}, pluginInConfig=${result.global.pluginInConfig}`); + for (const [label, scopeStatus] of [["Local", result.local], ["Global", result.global]] as const) { + if (!scopeStatus) continue; + console.log( + ` ${label}: installed=${scopeStatus.installed}, version=${scopeStatus.version ?? "unknown"}, ` + + `mode=${scopeStatus.mode ?? "unknown"}, ` + + `entry=${scopeStatus.entry ?? "none"} (${scopeStatus.entryConfigPath ?? "no config file"}), ` + + `pluginInConfig=${scopeStatus.pluginInConfig}` + ); + } break; } case "migrate": { @@ -106,6 +131,10 @@ async function main(): Promise { default: console.error(`Unknown command: ${command}`); printHelp(); process.exit(1); } } catch (error) { + if (error instanceof CopyModeUnsupportedError) { + console.error(`Error: ${error.message}`); + process.exit(1); + } const message = error instanceof Error ? error.message : String(error); console.error(`Error: ${message}`); process.exit(1); diff --git a/assets/templates/installer.template.txt b/assets/templates/installer.template.txt index eef90ac..c88473f 100644 --- a/assets/templates/installer.template.txt +++ b/assets/templates/installer.template.txt @@ -7,39 +7,40 @@ | `COMMAND NAME` → "my-command.md" | Yes | Command file name in assets/commands/ | — | | `PACKAGE NAME` → "opencode-myextension" | Yes | npm package name; also the manifest file base | — | -**Load-bearing — do not simplify:** manifest-gated idempotency (never `.version` markers); `readJsonConfig` returns `null` on parse errors and every writer aborts — never rewrite config from `{}`; config reading consults both `opencode.json` and `opencode.jsonc` per base, with `.jsonc` parsed leniently (comments + trailing commas) and `.json` strict; every unparseable candidate is warned about; plugin dedup is semantic with canonical `name@latest` output; bundled assets resolve through `ASSET_LAYOUT_DIR` (packages shipping repo-root `skills/` instead of `assets/` set it to `"."`), and a missing or empty asset source is a hard error naming the path, package name+version, cache dir, and install command — never a silent skip, and a manifest is never written when zero files were written; `migrateRootConfig` deletes nothing without a consent callback and is never called from `install()`. +**Load-bearing — do not simplify:** the deployment plan is content-based (ADR-0008): the content declaration (`"content"` in package.json, `"assets"` or `"code"`) decides the install modes — assets-only packages copy-install by default with `--mode plugin` as the opt-in, code-backed packages always plugin-register and `--mode copy` throws `CopyModeUnsupportedError`; copy install touches no config file at all — no permission blocks, no MCP entries, no plugin array edits; the `plugin` array is edited only by the surgical `PluginConfigEditor` (text splice, every other byte untouched, never a parse-then-reserialize, never rewrite config from `{}`, existing entry is a zero-write no-op); manifest-gated idempotency (never `.version` markers), the plugin-mode zero-write no-op includes a present entry (version match alone is not enough), and payload idempotency requires a recorded, hash-matching payload — a plugin-mode manifest with an empty file map means the load-time hook has not ensured assets yet, so the ensure path runs and records the payload hashes while preserving the recorded mode, entry, and target config file; config reading consults both `opencode.json` and `opencode.jsonc` per base, with `.jsonc` parsed leniently and `.json` strict, and every unparseable candidate is warned about; bundled assets resolve through `ASSET_LAYOUT_DIR` (packages shipping repo-root `skills/` instead of `assets/` set it to `"."`), and a missing or empty asset source is a hard error naming the path, package name+version, cache dir, and install command — never a silent skip, and a manifest is never written when zero files were written; the manifest hashes the whole payload relative to the config base (skills and commands), and `migrateRootConfig` deletes nothing without a consent callback and is never called from `install()`. Every function here is scope-parameterized: the exact same logic serves the local scope base (`/.opencode/`) and the global scope base (`~/.config/opencode/` or `$XDG_CONFIG_HOME/opencode/`), so a global install and a project install differ only in the base path — never in behavior. --- import { exists, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; -import { join } from "node:path"; +import { join, relative } from "node:path"; import { PluginNameNormalizer } from "./plugin-name.ts"; import { parseJsonc } from "./registration.ts"; -import { InstallManifest, listFilesRecursive } from "./manifest.ts"; +import { PluginConfigEditor } from "./plugin-config.ts"; +import { InstallManifest, listFilesRecursive, sha256, type InstallMode, type ManifestData } from "./manifest.ts"; + +export type { InstallMode } from "./manifest.ts"; export type Scope = "local" | "global"; export interface InstallOptions { - configurePermission?: boolean; - configureMcp?: boolean; - addPluginConfig?: boolean; + mode?: InstallMode; force?: boolean; } export interface InstallResult { scope: Scope; + mode: InstallMode; skillPath: string; commandPath: string; - configPath: string; - permissionConfigured: boolean; - mcpConfigured: boolean; - pluginAdded: boolean; + entry: string | null; + entryConfigPath: string | null; skippedModified: string[]; upToDate: boolean; } export interface UninstallResult { scope: Scope; + mode: InstallMode | null; removed: string[]; pluginRemoved: boolean; } @@ -47,6 +48,9 @@ export interface UninstallResult { export interface ScopeStatus { installed: boolean; version: string | null; + mode: InstallMode | null; + entry: string | null; + entryConfigPath: string | null; pluginInConfig: boolean; } @@ -61,6 +65,16 @@ const PLUGIN_NAME = "opencode-myextension"; const ASSET_LAYOUT_DIR = "assets"; const normalizer = new PluginNameNormalizer(); +const editor = new PluginConfigEditor(); + +export class CopyModeUnsupportedError extends Error { + constructor() { + super( + `${PLUGIN_NAME} is code-backed (agents, tools, or plugin integrations) and cannot be installed in copy mode. ` + + `Code-backed packages register as a plugin: run without --mode, or with --mode plugin.` + ); + } +} class AssetSourceMissingError extends Error { constructor(missingPath: string, cacheDir: string) { @@ -82,6 +96,19 @@ export async function getPackageName(): Promise { return JSON.parse(content).name; } +export async function getContentDeclaration(): Promise<"assets" | "code"> { + const content = await Bun.file(`${import.meta.dirname}/../package.json`).text(); + return JSON.parse(content).content === "code" ? "code" : "assets"; +} + +export async function resolveMode(requested: InstallMode | undefined): Promise { + if ((await getContentDeclaration()) === "code") { + if (requested === "copy") throw new CopyModeUnsupportedError(); + return "plugin"; + } + return requested ?? "copy"; +} + function getPackageDir(): string { return join(import.meta.dirname, ".."); } @@ -98,14 +125,20 @@ export function getLocalConfigPath(projectDir: string): string { return join(projectDir, ".opencode"); } -async function copyDir(src: string, dest: string): Promise { +function scopeBase(scope: Scope, projectDir: string): string { + return scope === "global" ? getGlobalConfigPath() : getLocalConfigPath(projectDir); +} + +async function copyDir(src: string, dest: string, skip: Set = new Set()): Promise { await mkdir(dest, { recursive: true }); + if (skip.has(dest)) return 0; let written = 0; for (const entry of await readdir(src, { withFileTypes: true })) { const s = join(src, entry.name); const d = join(dest, entry.name); + if (skip.has(d)) continue; if (entry.isDirectory()) { - written += await copyDir(s, d); + written += await copyDir(s, d, skip); } else { await Bun.write(d, Bun.file(s)); written++; @@ -121,46 +154,25 @@ async function requireAssetDir(path: string): Promise { return path; } -function parseConfig(raw: string, path: string): unknown { - return path.endsWith(".jsonc") ? parseJsonc(raw) : JSON.parse(raw); +async function payloadPaths(configBase: string): Promise<{ skillPath: string; commandPath: string }> { + return { + skillPath: join(configBase, "skills", SKILL_NAME), + commandPath: join(configBase, "commands", COMMAND_NAME), + }; } -async function readJsonConfig(path: string): Promise | null> { - if (!(await exists(path))) return {}; - try { - return parseConfig(await readFile(path, "utf-8"), path); - } catch { - console.warn(`Warning: ${path} is not valid JSON; refusing to modify it.`); - return null; - } -} - -function configCandidates(configBase: string): string[] { - return [join(configBase, "opencode.json"), join(configBase, "opencode.jsonc")]; -} - -async function isPluginInConfigBase(configBase: string): Promise { - for (const path of configCandidates(configBase)) { - if (await isPluginInConfig(path)) return true; - } - return false; -} - -async function writeJsonConfig(path: string, config: Record): Promise { - await mkdir(join(path, ".."), { recursive: true }); - await writeFile(path, JSON.stringify(config, null, 2)); -} - -async function ensureSkillFiles(scope: Scope, projectDir: string, force: boolean): Promise<{ upToDate: boolean; skippedModified: string[] }> { +async function ensurePayload(scope: Scope, projectDir: string, force: boolean): Promise<{ upToDate: boolean; skippedModified: string[] }> { const version = await getPackageVersion(); - const configBase = scope === "global" ? getGlobalConfigPath() : getLocalConfigPath(projectDir); - const skillPath = join(configBase, "skills", SKILL_NAME); + const configBase = scopeBase(scope, projectDir); + const { skillPath } = await payloadPaths(configBase); const manifest = new InstallManifest(configBase, PLUGIN_NAME); - if (await manifest.isUpToDate(version, skillPath)) { + const existing = await manifest.read(); + const payloadRecorded = existing !== null && Object.keys(existing.files).length > 0; + if (payloadRecorded && (await manifest.isUpToDate(version, configBase))) { return { upToDate: true, skippedModified: [] }; } const skippedModified: string[] = []; - const modified = await manifest.findConsumerModified(version, skillPath); + const modified = await manifest.findConsumerModified(configBase); if (!force && modified.length > 0) { for (const file of modified) { console.warn(`Warning: skipping consumer-modified file ${file} (use --force to overwrite).`); @@ -168,70 +180,67 @@ async function ensureSkillFiles(scope: Scope, projectDir: string, force: boolean skippedModified.push(...modified); } const packagedSkillDir = await requireAssetDir(join(getPackageDir(), ASSET_LAYOUT_DIR, "skills", SKILL_NAME)); - const written = await copyDir(packagedSkillDir, skillPath); - if (written === 0) throw new AssetSourceMissingError(packagedSkillDir, getPackageDir()); - await manifest.write(version, skillPath, await listFilesRecursive(skillPath)); - return { upToDate: false, skippedModified }; -} - -async function ensureSkillPermission(configPath: string): Promise { - const config = await readJsonConfig(configPath); - if (config === null) return; - if (!config.permission) config.permission = {}; - const permission = config.permission as Record; - if (!permission.skill) permission.skill = {}; - const skillPerms = permission.skill as Record; - if (skillPerms[SKILL_NAME] !== "allow") { - skillPerms[SKILL_NAME] = "allow"; - await writeJsonConfig(configPath, config); + const written = await copyDir(packagedSkillDir, skillPath, new Set(skippedModified)); + if (written === 0 && skippedModified.length === 0) throw new AssetSourceMissingError(packagedSkillDir, getPackageDir()); + const { commandPath } = await payloadPaths(configBase); + const commandSource = await requireAssetDir(join(getPackageDir(), ASSET_LAYOUT_DIR, "commands")); + await mkdir(join(configBase, "commands"), { recursive: true }); + await Bun.write(commandPath, Bun.file(join(commandSource, COMMAND_NAME))); + const skipped = new Set(skippedModified); + const hashes: Record = {}; + for (const file of [...(await listFilesRecursive(skillPath)), commandPath]) { + const rel = relative(configBase, file).replaceAll("\\", "/"); + const packagedCounterpart = file.startsWith(skillPath) + ? join(packagedSkillDir, relative(skillPath, file)) + : join(commandSource, COMMAND_NAME); + hashes[rel] = await sha256(skipped.has(file) ? packagedCounterpart : file); } + await manifest.write({ + version, + mode: existing?.mode ?? "copy", + entry: existing?.entry ?? null, + entryConfigPath: existing?.entryConfigPath ?? null, + rootDir: null, + files: null, + hashes, + }); + return { upToDate: false, skippedModified }; } -async function removeSkillPermission(configPath: string): Promise { - const config = await readJsonConfig(configPath); - if (config === null || !config.permission) return; - const permission = config.permission as Record; - if (!permission.skill) return; - const skillPerms = permission.skill as Record; - delete skillPerms[SKILL_NAME]; - if (Object.keys(skillPerms).length === 0) delete permission.skill; - if (Object.keys(permission).length === 0) delete config.permission; - await writeJsonConfig(configPath, config); -} - -async function addPluginToConfig(configPath: string): Promise { - const config = await readJsonConfig(configPath); - if (config === null) return false; - if (!config.plugin) config.plugin = []; - const plugins = config.plugin as string[]; - if (plugins.some(entry => normalizer.matches(entry, PLUGIN_NAME))) return false; - plugins.push(normalizer.normalize(PLUGIN_NAME) as string); - config.plugin = plugins; - await writeJsonConfig(configPath, config); - return true; -} - -async function removePluginFromConfig(configPath: string): Promise { - const config = await readJsonConfig(configPath); - if (config === null || !config.plugin) return false; - const plugins = config.plugin as string[]; - const index = plugins.findIndex(entry => normalizer.matches(entry, PLUGIN_NAME)); - if (index === -1) return false; - plugins.splice(index, 1); - if (plugins.length === 0) delete config.plugin; - else config.plugin = plugins; - await writeJsonConfig(configPath, config); - return true; -} - -async function isPluginInConfig(configPath: string): Promise { - const config = await readJsonConfig(configPath); - if (config === null || !config.plugin) return false; - return (config.plugin as string[]).some(entry => normalizer.matches(entry, PLUGIN_NAME)); +export async function ensureAssets(scope: Scope, projectDir: string, force: boolean): Promise { + await ensurePayload(scope, projectDir, force); } -async function addMcpServer(configPath: string): Promise { - return false; +async function registerPlugin(scope: Scope, projectDir: string): Promise<{ upToDate: boolean; entry: string | null; entryConfigPath: string | null }> { + const version = await getPackageVersion(); + const configBase = scopeBase(scope, projectDir); + const entry = normalizer.normalize(PLUGIN_NAME) ?? PLUGIN_NAME; + const manifest = new InstallManifest(configBase, PLUGIN_NAME); + const existing = await manifest.read(); + const entryPresent = existing?.mode === "plugin" && existing.entryConfigPath + ? (await editor.findRegistration(PLUGIN_NAME, { scope, projectDir })) !== null + : false; + if (existing?.version === version && entryPresent) { + return { upToDate: true, entry: existing.entry, entryConfigPath: existing.entryConfigPath }; + } + const outcome = await editor.ensurePluginEntry(PLUGIN_NAME, { scope, projectDir }); + if (outcome.action === "blocked" && outcome.warning) { + console.warn(`Warning: ${outcome.warning}`); + return { upToDate: false, entry: null, entryConfigPath: null }; + } + if (outcome.action !== "noop" && outcome.action !== "updated" && outcome.action !== "created") { + return { upToDate: false, entry: null, entryConfigPath: null }; + } + await manifest.write({ + version, + mode: "plugin", + entry, + entryConfigPath: outcome.configPath, + rootDir: null, + files: null, + hashes: existing?.files ?? null, + }); + return { upToDate: false, entry, entryConfigPath: outcome.configPath }; } export async function checkMigrationNeeded(projectDir: string): Promise<{ @@ -274,74 +283,98 @@ export async function install( projectDir: string = process.cwd(), options: InstallOptions = {} ): Promise { - const { configurePermission = true, configureMcp = true, addPluginConfig = true, force = false } = options; - const configBase = scope === "global" ? getGlobalConfigPath() : getLocalConfigPath(projectDir); - const skillPath = join(configBase, "skills", SKILL_NAME); - const commandPath = join(configBase, "commands", COMMAND_NAME); - const configPath = join(configBase, "opencode.json"); - const { upToDate, skippedModified } = await ensureSkillFiles(scope, projectDir, force); - await mkdir(join(configBase, "commands"), { recursive: true }); - const commandSource = await requireAssetDir(join(getPackageDir(), ASSET_LAYOUT_DIR, "commands")); - await Bun.write(commandPath, Bun.file(join(commandSource, COMMAND_NAME))); - let permissionConfigured = false; - if (configurePermission) { await ensureSkillPermission(configPath); permissionConfigured = true; } - let mcpConfigured = false; - if (configureMcp) mcpConfigured = await addMcpServer(configPath); - let pluginAdded = false; - if (addPluginConfig) pluginAdded = await addPluginToConfig(configPath); - return { scope, skillPath, commandPath, configPath, permissionConfigured, mcpConfigured, pluginAdded, skippedModified, upToDate }; + const { mode: requested, force = false } = options; + const mode = await resolveMode(requested); + const configBase = scopeBase(scope, projectDir); + const { skillPath, commandPath } = await payloadPaths(configBase); + if (mode === "copy") { + const { upToDate, skippedModified } = await ensurePayload(scope, projectDir, force); + return { scope, mode, skillPath, commandPath, entry: null, entryConfigPath: null, skippedModified, upToDate }; + } + const { upToDate, entry, entryConfigPath } = await registerPlugin(scope, projectDir); + return { scope, mode, skillPath, commandPath, entry, entryConfigPath, skippedModified: [], upToDate }; } export async function uninstall(scope: Scope, projectDir: string = process.cwd()): Promise { - const configBase = scope === "global" ? getGlobalConfigPath() : getLocalConfigPath(projectDir); - const skillPath = join(configBase, "skills", SKILL_NAME); - const commandPath = join(configBase, "commands", COMMAND_NAME); - const configPath = join(configBase, "opencode.json"); + const configBase = scopeBase(scope, projectDir); + const { skillPath, commandPath } = await payloadPaths(configBase); + const manifest = new InstallManifest(configBase, PLUGIN_NAME); + const manifestData = await manifest.read(); const removed: string[] = []; + let pluginRemoved = false; + if (manifestData?.mode === "plugin") { + const outcome = await editor.removePluginEntry(PLUGIN_NAME, { scope, projectDir }); + if (outcome.action === "blocked" && outcome.warning) console.warn(`Warning: ${outcome.warning}`); + pluginRemoved = outcome.action === "removed"; + } if (await exists(skillPath)) { await rm(skillPath, { recursive: true }); removed.push(skillPath); } if (await exists(commandPath)) { await rm(commandPath); removed.push(commandPath); } - const manifest = new InstallManifest(configBase, PLUGIN_NAME); - if (await manifest.read() !== null) { await rm(join(configBase, `${PLUGIN_NAME}.manifest.json`)); removed.push(join(configBase, `${PLUGIN_NAME}.manifest.json`)); } - let pluginRemoved = false; - if (await exists(configPath)) { await removeSkillPermission(configPath); pluginRemoved = await removePluginFromConfig(configPath); } + if (manifestData !== null) { await rm(join(configBase, `${PLUGIN_NAME}.manifest.json`)); removed.push(join(configBase, `${PLUGIN_NAME}.manifest.json`)); } const skillsDir = join(configBase, "skills"); const commandsDir = join(configBase, "commands"); try { const skillsContents = await readdir(skillsDir); if (skillsContents.length === 0) await rm(skillsDir, { recursive: true }); } catch {} try { const commandsContents = await readdir(commandsDir); if (commandsContents.length === 0) await rm(commandsDir, { recursive: true }); } catch {} - return { scope, removed, pluginRemoved }; + return { scope, mode: manifestData?.mode ?? null, removed, pluginRemoved }; +} + +async function isPluginInConfigScope(scope: Scope, projectDir: string): Promise { + return (await editor.findRegistration(PLUGIN_NAME, { scope, projectDir })) !== null; } export async function status(projectDir: string = process.cwd()): Promise { - const configBases: Array<[Scope, string]> = [ + const scopes: Array<[Scope, string]> = [ ["local", getLocalConfigPath(projectDir)], ["global", getGlobalConfigPath()], ]; const result: StatusResult = { local: null, global: null }; - for (const [scope, configBase] of configBases) { - const skillPath = join(configBase, "skills", SKILL_NAME); - const skillFiles = await exists(skillPath) ? await readdir(skillPath) : []; + for (const [scope, configBase] of scopes) { + const { skillPath } = await payloadPaths(configBase); const manifest = new InstallManifest(configBase, PLUGIN_NAME); - const manifestData = await manifest.read(); - if (skillFiles.length === 0 || manifestData === null) continue; - const installedVersion = manifestData.version; + const manifestData: ManifestData | null = await manifest.read(); + const pluginInConfig = await isPluginInConfigScope(scope, projectDir); + if (manifestData === null && !pluginInConfig) continue; result[scope] = { installed: true, - version: installedVersion, - pluginInConfig: await isPluginInConfigBase(configBase), + version: manifestData?.version ?? null, + mode: manifestData?.mode ?? null, + entry: manifestData?.entry ?? null, + entryConfigPath: manifestData?.entryConfigPath ?? null, + pluginInConfig, }; } return result; } -export async function isUpToDateAnywhere(projectDir: string = process.cwd()): Promise { +export async function isInstalledAnywhere(projectDir: string = process.cwd()): Promise { const version = await getPackageVersion(); - for (const configBase of [getGlobalConfigPath(), getLocalConfigPath(projectDir)]) { - const skillPath = join(configBase, "skills", SKILL_NAME); - const skillFiles = await exists(skillPath) ? await readdir(skillPath) : []; - if (skillFiles.length === 0) continue; - if (await new InstallManifest(configBase, PLUGIN_NAME).isUpToDate(version, skillPath)) { - return true; + for (const scope of ["global", "local"] as const) { + const manifest = new InstallManifest(scopeBase(scope, projectDir), PLUGIN_NAME); + const data = await manifest.read(); + if (data === null) continue; + if (data.mode === "plugin" && data.entryConfigPath) { + if ((await editor.findRegistration(PLUGIN_NAME, { scope, projectDir })) !== null) return true; + continue; } + if (data.version === version) return true; } return false; } + +function parseConfig(raw: string, path: string): unknown { + return path.endsWith(".jsonc") ? parseJsonc(raw) : JSON.parse(raw); +} + +async function readJsonConfig(path: string): Promise | null> { + if (!(await exists(path))) return {}; + try { + return parseConfig(await readFile(path, "utf-8"), path) as Record; + } catch { + console.warn(`Warning: ${path} is not valid JSON; refusing to modify it.`); + return null; + } +} + +async function writeJsonConfig(path: string, config: Record): Promise { + await mkdir(join(path, ".."), { recursive: true }); + await writeFile(path, JSON.stringify(config, null, 2)); +} diff --git a/assets/templates/manifest.template.txt b/assets/templates/manifest.template.txt index 09102a0..4c339c9 100644 --- a/assets/templates/manifest.template.txt +++ b/assets/templates/manifest.template.txt @@ -4,17 +4,34 @@ |---|---|---|---| | `PACKAGE NAME` → "opencode-myextension" | Yes | npm package name; the manifest file is `/.manifest.json` | — | -**Load-bearing — do not simplify:** idempotency is per-file sha256 (`Bun.CryptoHasher`), never a `.version` marker; a missing or malformed manifest reads as "not installed" (drift), never as a reason to write without an ensure path. +**Load-bearing — do not simplify:** idempotency is per-file sha256 (`Bun.CryptoHasher`), never a `.version` marker; a missing or malformed manifest reads as "not installed" (drift), never as a reason to write without an ensure path; a manifest without a `mode` field reads as `"copy"` so pre-generalization copy installs migrate cleanly; the manifest is one record per scope for BOTH install modes — `mode` and the registration fields record the deployment plan, while `files` records whatever payload the load-time hook has ensured (so a plugin-mode manifest gains payload hashes after the first load, and the CLI must preserve them when it rewrites registration); plugin-mode up-to-dateness includes the registration — the recorded entry must still be present in the recorded config file (semantic name match, `@latest`-aware) — plus the recorded payload hashes; consumer-modified files that were skipped during install are recorded with their **packaged** hash — never the consumer's on-disk content — so the modification stays detectable and `--force` can still take ownership of it. --- import { readdir, readFile, writeFile, mkdir } from "node:fs/promises"; import { join, relative } from "node:path"; +import { parseJsonc } from "./registration.ts"; +import { PluginNameNormalizer } from "./plugin-name.ts"; + +export type InstallMode = "copy" | "plugin"; export interface ManifestData { version: string; + mode: InstallMode; + entry: string | null; + entryConfigPath: string | null; files: Record; } +export interface ManifestWrite { + version: string; + mode: InstallMode; + entry: string | null; + entryConfigPath: string | null; + rootDir: string | null; + files: string[] | null; + hashes: Record | null; +} + const MANIFEST_SUFFIX = ".manifest.json"; export function manifestPath(configBase: string, packageName: string): string { @@ -23,6 +40,7 @@ export function manifestPath(configBase: string, packageName: string): string { export class InstallManifest { private readonly path: string; + private readonly normalizer = new PluginNameNormalizer(); constructor(configBase: string, packageName: string) { this.path = manifestPath(configBase, packageName); @@ -35,24 +53,41 @@ export class InstallManifest { if (typeof parsed.version !== "string" || typeof parsed.files !== "object" || parsed.files === null) { return null; } - return { version: parsed.version, files: parsed.files }; + return { + version: parsed.version, + mode: parsed.mode === "plugin" ? "plugin" : "copy", + entry: typeof parsed.entry === "string" ? parsed.entry : null, + entryConfigPath: typeof parsed.entryConfigPath === "string" ? parsed.entryConfigPath : null, + files: parsed.files, + }; } catch { return null; } } - async write(version: string, rootDir: string, files: string[]): Promise { - const data: ManifestData = { version, files: {} }; - for (const file of files) { - data.files[this.toRelative(rootDir, file)] = await sha256(file); + async write(data: ManifestWrite): Promise { + const record: ManifestData = { + version: data.version, + mode: data.mode, + entry: data.entry, + entryConfigPath: data.entryConfigPath, + files: {}, + }; + if (data.hashes) { + record.files = data.hashes; + } else if (data.rootDir !== null && data.files !== null) { + for (const file of data.files) { + record.files[this.toRelative(data.rootDir, file)] = await sha256(file); + } } await mkdir(join(this.path, ".."), { recursive: true }); - await writeFile(this.path, JSON.stringify(data, null, 2)); + await writeFile(this.path, JSON.stringify(record, null, 2)); } async isUpToDate(version: string, rootDir: string): Promise { const data = await this.read(); if (data === null || data.version !== version) return false; + if (data.mode === "plugin" && !(await this.registrationPresent(data))) return false; for (const [relPath, hash] of Object.entries(data.files)) { const absolute = join(rootDir, relPath); if (await sha256(absolute).catch(() => null) !== hash) return false; @@ -60,9 +95,23 @@ export class InstallManifest { return true; } - async findConsumerModified(version: string, rootDir: string): Promise { + private async registrationPresent(data: ManifestData): Promise { + if (!data.entry) return false; + if (!data.entryConfigPath) return false; + let text: string; + try { + text = await readFile(data.entryConfigPath, "utf-8"); + } catch { + return false; + } + const packageName = this.baseName(data.entry); + const entries = pluginEntries(text, data.entryConfigPath); + return entries.some(entry => this.normalizer.matches(entry, packageName)); + } + + async findConsumerModified(rootDir: string): Promise { const data = await this.read(); - if (data === null || data.version === version) return []; + if (data === null || data.mode !== "copy") return []; const modified: string[] = []; for (const [relPath, hash] of Object.entries(data.files)) { const absolute = join(rootDir, relPath); @@ -72,11 +121,26 @@ export class InstallManifest { return modified; } + private baseName(entry: string): string { + const specIndex = entry.lastIndexOf("@"); + return specIndex > 0 ? entry.slice(0, specIndex) : entry; + } + private toRelative(rootDir: string, file: string): string { return relative(rootDir, file).replaceAll("\\", "/"); } } +function pluginEntries(text: string, configPath: string): string[] { + try { + const config = (configPath.endsWith(".jsonc") ? parseJsonc(text) : JSON.parse(text)) as { plugin?: unknown }; + if (!Array.isArray(config.plugin)) return []; + return config.plugin.filter((entry): entry is string => typeof entry === "string"); + } catch { + return []; + } +} + export async function listFilesRecursive(rootDir: string): Promise { const entries = await readdir(rootDir, { withFileTypes: true, recursive: true }); return entries.filter(entry => entry.isFile()).map(entry => join(entry.parentPath, entry.name)); diff --git a/assets/templates/plugin-config.template.txt b/assets/templates/plugin-config.template.txt new file mode 100644 index 0000000..af005e1 --- /dev/null +++ b/assets/templates/plugin-config.template.txt @@ -0,0 +1,497 @@ +# Inputs + +None — emit as-is as src/plugin-config.ts. + +**Load-bearing — do not simplify:** the editor is a surgical text splice into the `plugin` array with every other byte of the config untouched — never a parse-then-reserialize (comments, key order, and formatting survive); candidates are consulted in priority order: for local scope the nested `.opencode/opencode.json` then `opencode.jsonc`, then the repo-root `opencode.json` then `opencode.jsonc`; for global scope the same pair in the global scope base; and finally the global `config.json` which is read-only (detection only, never written); a config that fails to parse blocks the edit — never rewrite config from `{}`; `.jsonc` parses leniently (comments blanked string-aware so `$schema` URLs and escaped quotes survive, trailing commas removed) and `.json` stays strict; matching is semantic via `PluginNameNormalizer` (`name`, `name@latest`, `name@x.y.z` are the same package); an existing semantically matching entry is a zero-write no-op; when no config exists a minimal `opencode.jsonc` is created; every splice re-parses the result and aborts untouched if the entry is not present after the edit. + +--- +import { exists, mkdir, readFile, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import path from "node:path"; +import { PluginNameNormalizer } from "./plugin-name.ts"; + +export type ConfigScope = "local" | "global"; + +export interface EnsurePluginEntryOptions { + scope: ConfigScope; + projectDir: string; +} + +export interface EnsurePluginEntryOutcome { + action: "noop" | "updated" | "created" | "blocked"; + configPath: string | null; + warning: string | null; +} + +export interface RemovePluginEntryOutcome { + action: "noop" | "removed" | "blocked"; + configPath: string | null; + warning: string | null; +} + +interface CandidateConfig { + path: string; + lenient: boolean; + writable: boolean; +} + +export type ConfigCheck = { ok: true } | { ok: false; warning: string }; + +interface PluginArrayRange { + bracketStart: number; + bracketEnd: number; +} + +const DEFAULT_CONFIG_TEMPLATE = `{ + // OpenCode configuration + "$schema": "https://opencode.ai/config.json", + "plugin": ["__PACKAGE_NAME__"] +} +`; + +export class PluginConfigEditor { + public async ensurePluginEntry( + packageName: string, + options: EnsurePluginEntryOptions, + ): Promise { + for (const candidate of this.candidateConfigs(options)) { + if (!(await exists(candidate.path))) continue; + const text = await readFile(candidate.path, "utf-8"); + const plugins = this.parsePluginArray(text, candidate.lenient); + if (plugins === null) { + return { + action: "blocked", + configPath: candidate.path, + warning: + `Config file ${candidate.path} could not be parsed; refusing to modify it. ` + + `Fix or remove the file and re-run the install.`, + }; + } + if (this.hasMatchingEntry(plugins, packageName)) { + return { action: "noop", configPath: candidate.path, warning: null }; + } + if (!candidate.writable) continue; + const spliced = this.spliceEntry(text, packageName, candidate.lenient); + if (spliced === null) { + return { + action: "blocked", + configPath: candidate.path, + warning: `Plugin array in ${candidate.path} could not be safely edited; file left untouched.`, + }; + } + await mkdir(path.dirname(candidate.path), { recursive: true }); + await writeFile(candidate.path, spliced); + return { action: "updated", configPath: candidate.path, warning: null }; + } + + const target = this.defaultConfigPath(options); + const content = DEFAULT_CONFIG_TEMPLATE.replace("__PACKAGE_NAME__", packageName); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, content); + return { action: "created", configPath: target, warning: null }; + } + + public async checkParseable(options: EnsurePluginEntryOptions): Promise { + for (const candidate of this.candidateConfigs(options)) { + if (!(await exists(candidate.path))) continue; + const text = await readFile(candidate.path, "utf-8"); + if (this.parsePluginArray(text, candidate.lenient) === null) { + return { + ok: false, + warning: + `Config file ${candidate.path} could not be parsed; refusing to modify it. ` + + `Fix or remove the file and re-run the command.`, + }; + } + } + return { ok: true }; + } + + public async findRegistration( + packageName: string, + options: EnsurePluginEntryOptions, + ): Promise { + for (const candidate of this.candidateConfigs(options)) { + if (!(await exists(candidate.path))) continue; + const text = await readFile(candidate.path, "utf-8"); + const plugins = this.parsePluginArray(text, candidate.lenient); + if (plugins === null) continue; + if (this.hasMatchingEntry(plugins, packageName)) return candidate.path; + } + return null; + } + + public async removePluginEntry( + packageName: string, + options: EnsurePluginEntryOptions, + ): Promise { + for (const candidate of this.candidateConfigs(options)) { + if (!(await exists(candidate.path)) || !candidate.writable) continue; + const text = await readFile(candidate.path, "utf-8"); + const plugins = this.parsePluginArray(text, candidate.lenient); + if (plugins === null) { + return { + action: "blocked", + configPath: candidate.path, + warning: + `Config file ${candidate.path} could not be parsed; refusing to modify it. ` + + `Fix or remove the file and re-run the uninstall.`, + }; + } + if (!this.hasMatchingEntry(plugins, packageName)) continue; + const spliced = this.spliceOutEntry(text, packageName, candidate.lenient); + if (spliced === null) { + return { + action: "blocked", + configPath: candidate.path, + warning: `Plugin array in ${candidate.path} could not be safely edited; file left untouched.`, + }; + } + await writeFile(candidate.path, spliced); + return { action: "removed", configPath: candidate.path, warning: null }; + } + return { action: "noop", configPath: null, warning: null }; + } + + public hasMatchingEntry(entries: string[], packageName: string): boolean { + return entries.some((entry) => this.matchesEntry(entry, packageName)); + } + + private matchesEntry(entry: string, packageName: string): boolean { + const specIndex = entry.lastIndexOf("@"); + const name = specIndex > 0 ? entry.slice(0, specIndex) : entry; + return name === packageName; + } + + private candidateConfigs(options: EnsurePluginEntryOptions): CandidateConfig[] { + const scopeBase = this.scopeBase(options.scope, options.projectDir); + const repoRoot = options.projectDir; + const configs: CandidateConfig[] = []; + if (options.scope === "local") { + for (const root of [scopeBase, repoRoot]) { + configs.push({ path: path.join(root, "opencode.json"), lenient: false, writable: true }); + configs.push({ path: path.join(root, "opencode.jsonc"), lenient: true, writable: true }); + } + } else { + for (const file of ["opencode.json", "opencode.jsonc"]) { + configs.push({ path: path.join(scopeBase, file), lenient: file.endsWith(".jsonc"), writable: true }); + } + } + configs.push({ path: path.join(scopeBase, "config.json"), lenient: false, writable: false }); + return configs; + } + + private defaultConfigPath(options: EnsurePluginEntryOptions): string { + if (options.scope === "global") { + return path.join(this.scopeBase("global", options.projectDir), "opencode.jsonc"); + } + return path.join(options.projectDir, "opencode.jsonc"); + } + + private scopeBase(scope: ConfigScope, projectDir: string): string { + if (scope === "local") return path.join(projectDir, ".opencode"); + const xdgConfigHome = process.env.XDG_CONFIG_HOME; + if (xdgConfigHome) return path.join(xdgConfigHome, "opencode"); + return path.join(homedir(), ".config", "opencode"); + } + + private parsePluginArray(text: string, lenient: boolean): string[] | null { + const config = this.parseConfig(text, lenient); + if (config === null) return null; + const plugins = config.plugin; + if (!Array.isArray(plugins)) return []; + return plugins.filter((entry): entry is string => typeof entry === "string"); + } + + private parseConfig(text: string, lenient: boolean): Record | null { + const parseable = lenient ? this.blankComments(text).replace(/,(\s*[}\]])/g, "$1") : text; + try { + return JSON.parse(parseable) as Record; + } catch { + return null; + } + } + + private blankComments(text: string): string { + const chars = text.split(""); + let inString = false; + let inLineComment = false; + let inBlockComment = false; + for (let i = 0; i < chars.length; i++) { + const current = chars[i]; + const next = i + 1 < chars.length ? chars[i + 1] : ""; + if (inLineComment) { + if (current === "\n") inLineComment = false; + else chars[i] = " "; + continue; + } + if (inBlockComment) { + if (current === "*" && next === "/") { + chars[i] = " "; + chars[i + 1] = " "; + i++; + inBlockComment = false; + } else { + chars[i] = " "; + } + continue; + } + if (inString) { + if (current === "\\") { + i++; + continue; + } + if (current === '"') inString = false; + continue; + } + if (current === '"') { + inString = true; + continue; + } + if (current === "/" && next === "/") { + chars[i] = " "; + chars[i + 1] = " "; + inLineComment = true; + continue; + } + if (current === "/" && next === "*") { + chars[i] = " "; + chars[i + 1] = " "; + inBlockComment = true; + continue; + } + } + return chars.join(""); + } + + private spliceEntry(text: string, packageName: string, lenient: boolean): string | null { + const navigable = this.blankComments(text); + const range = this.findPluginArrayRange(navigable); + const spliced = + range === null + ? this.splicePluginKey(text, navigable, packageName) + : this.spliceArrayEntry(text, navigable, range, packageName); + if (spliced === null) return null; + const plugins = this.parsePluginArray(spliced, lenient); + if (plugins === null || !this.hasMatchingEntry(plugins, packageName)) return null; + return spliced; + } + + private spliceOutEntry(text: string, packageName: string, lenient: boolean): string | null { + const navigable = this.blankComments(text); + const range = this.findPluginArrayRange(navigable); + if (range === null) return null; + const innerStart = range.bracketStart + 1; + const innerEnd = range.bracketEnd; + const elements = this.arrayElementRanges(navigable, innerStart, innerEnd); + const target = elements.find((element) => { + const raw = text.slice(element.start, element.end); + return this.matchesEntry(this.unquote(raw), packageName); + }); + if (!target) return null; + const withComma = this.dropAdjacentComma(navigable, elements, target, innerStart, innerEnd); + const result = text.slice(0, withComma.start) + text.slice(withComma.end); + const plugins = this.parsePluginArray(result, lenient); + if (plugins === null || this.hasMatchingEntry(plugins, packageName)) return null; + return result; + } + + private arrayElementRanges( + navigable: string, + innerStart: number, + innerEnd: number, + ): Array<{ start: number; end: number }> { + const elements: Array<{ start: number; end: number }> = []; + let inString = false; + let depth = 0; + let start = -1; + for (let i = innerStart; i < innerEnd; i++) { + const current = navigable[i]; + if (inString) { + if (current === "\\") i++; + else if (current === '"') inString = false; + continue; + } + if (current === '"') { + inString = true; + if (start === -1) start = i; + continue; + } + if (current === "[" || current === "{") { + depth++; + if (start === -1) start = i; + continue; + } + if (current === "]" || current === "}") { + depth--; + continue; + } + if (current === "," && depth === 0) { + if (start !== -1) elements.push({ start, end: i }); + start = -1; + continue; + } + if (!/\s/.test(current ?? "") && start === -1) start = i; + } + if (start !== -1) elements.push({ start, end: innerEnd }); + return elements; + } + + private dropAdjacentComma( + navigable: string, + elements: Array<{ start: number; end: number }>, + target: { start: number; end: number }, + innerStart: number, + innerEnd: number, + ): { start: number; end: number } { + const index = elements.indexOf(target); + const next = elements[index + 1]; + if (next) return { start: target.start, end: next.start }; + const previous = elements[index - 1]; + if (previous) { + let commaEnd = target.start; + while (commaEnd > previous.end && /\s/.test(navigable[commaEnd - 1] ?? "")) commaEnd--; + if ((navigable[commaEnd - 1] ?? "") === ",") return { start: previous.end, end: commaEnd }; + } + let start = target.start; + while (start > innerStart && /\s/.test(navigable[start - 1] ?? "")) start--; + let end = target.end; + while (end < innerEnd && /\s/.test(navigable[end] ?? "")) end++; + return { start, end }; + } + + private unquote(raw: string): string { + const trimmed = raw.trim(); + if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) { + return trimmed.slice(1, -1); + } + return trimmed; + } + + private spliceArrayEntry( + text: string, + navigable: string, + range: PluginArrayRange, + packageName: string, + ): string | null { + const innerStart = range.bracketStart + 1; + const inner = text.slice(innerStart, range.bracketEnd); + const firstElementOffset = inner.search(/\S/); + if (firstElementOffset === -1) { + return text.slice(0, innerStart) + `"${packageName}"` + text.slice(range.bracketEnd); + } + const insertAt = innerStart + firstElementOffset; + const leadingWhitespace = inner.slice(0, firstElementOffset); + return text.slice(0, insertAt) + leadingWhitespace + `"${packageName}",` + text.slice(insertAt); + } + + private splicePluginKey(text: string, navigable: string, packageName: string): string | null { + const objectStart = this.firstStructuralChar(navigable, "{"); + if (objectStart === -1) return null; + const rest = text.slice(objectStart + 1); + const nextContentOffset = rest.search(/\S/); + if (nextContentOffset === -1) return null; + const insertAt = objectStart + 1 + nextContentOffset; + const isClosingBrace = rest[nextContentOffset] === "}"; + const entry = isClosingBrace ? `"plugin": ["${packageName}"]` : `"plugin": ["${packageName}"],`; + const leadingWhitespace = nextContentOffset > 0 ? rest.slice(0, nextContentOffset) : ""; + return text.slice(0, insertAt) + leadingWhitespace + entry + text.slice(insertAt); + } + + private findPluginArrayRange(navigable: string): PluginArrayRange | null { + let searchFrom = 0; + while (searchFrom < navigable.length) { + const keyIndex = navigable.indexOf('"plugin"', searchFrom); + if (keyIndex === -1) return null; + if (this.precededByStructuralChar(navigable, keyIndex, ["{", ","])) { + const colonIndex = this.nextOutsideString(navigable, keyIndex + 8, ":"); + if (colonIndex !== -1) { + const bracketStart = this.nextOutsideString(navigable, colonIndex + 1, "["); + if (bracketStart !== -1) { + const bracketEnd = this.matchingBracket(navigable, bracketStart); + if (bracketEnd !== null) return { bracketStart, bracketEnd }; + } + } + } + searchFrom = keyIndex + 1; + } + return null; + } + + private precededByStructuralChar(text: string, index: number, allowed: string[]): boolean { + for (let i = index - 1; i >= 0; i--) { + const current: string = text[i] ?? ""; + if (/\s/.test(current)) continue; + return allowed.includes(current); + } + return false; + } + + private firstStructuralChar(text: string, target: string): number { + let inString = false; + for (let i = 0; i < text.length; i++) { + const current = text[i]; + if (inString) { + if (current === "\\") { + i++; + continue; + } + if (current === '"') inString = false; + continue; + } + if (current === '"') { + inString = true; + continue; + } + if (current === target) return i; + } + return -1; + } + + private nextOutsideString(text: string, from: number, target: string): number { + let inString = false; + for (let i = from; i < text.length; i++) { + const current = text[i]; + if (inString) { + if (current === "\\") { + i++; + continue; + } + if (current === '"') inString = false; + continue; + } + if (current === '"') { + inString = true; + continue; + } + if (current === target) return i; + } + return -1; + } + + private matchingBracket(text: string, bracketStart: number): number | null { + let depth = 0; + let inString = false; + for (let i = bracketStart; i < text.length; i++) { + const current = text[i]; + if (inString) { + if (current === "\\") { + i++; + continue; + } + if (current === '"') inString = false; + continue; + } + if (current === '"') { + inString = true; + continue; + } + if (current === "[") depth++; + else if (current === "]") { + depth--; + if (depth === 0) return i; + } + } + return null; + } +} diff --git a/assets/templates/plugin-local.template.txt b/assets/templates/plugin-local.template.txt index 09de683..56571c4 100644 --- a/assets/templates/plugin-local.template.txt +++ b/assets/templates/plugin-local.template.txt @@ -7,13 +7,12 @@ | `COMMAND NAME` → "my-command.md" | Yes | Command file name in assets/commands/ | — | | `PACKAGE NAME` → "opencode-myextension" | Yes | npm package name; must match package.json | — | -**Load-bearing — do not simplify:** scope detection is read-only config inspection (both `opencode.json` and `opencode.jsonc`, global and project), never launch-directory checks and never any directory-identity comparison (`import.meta.dirname`, `process.cwd()`, realpath) — a maintainer's own checkout is served by that repo's own config registration; the hook passes `addPluginConfig: false`, `configurePermission: false`, `configureMcp: false` to `install()` — it never edits `plugin` arrays or permission/MCP config; writes stay inside the detected scope(s) only; the **entire hook body** (detection, installs, advisory) is wrapped in try/catch so a failure degrades to a warning and OpenCode still launches — hooks must never throw; the failure advisory names both the remediation command (`bunx install --scope global`) and the package-qualified cache dir (`~/.cache/opencode/packages/@`), is built inside its own try/catch with a static fallback, and each emitter swallows independently; the failure advisory and the not-installed advisory carry **separate** once-guards so neither suppresses the other; the hook never deletes the cache — it instructs only. +**Load-bearing — do not simplify:** scope detection is read-only config inspection (both `opencode.json` and `opencode.jsonc`, global and project, plus global `config.json` via the detector), never launch-directory checks and never any directory-identity comparison (`import.meta.dirname`, `process.cwd()`, realpath) — a maintainer's own checkout is served by that repo's own config registration; the hook calls only `ensureAssets` — it never resolves an install mode, never edits `plugin` arrays, permission, or any config file, and writes stay inside the detected registration scope(s) only; assets-only and code-backed packages share this hook unchanged: assets are ensured in registered scopes either way, and code-backed assets resolve from the package at load; the **entire hook body** (detection, ensures, advisory) is wrapped in try/catch so a failure degrades to a warning and OpenCode still launches — hooks must never throw; the failure advisory names both the remediation command (`bunx install --scope global`) and the package-qualified cache dir (`~/.cache/opencode/packages/@`), is built inside its own try/catch with a static fallback, and each emitter swallows independently; the failure advisory and the not-installed advisory carry **separate** once-guards so neither suppresses the other; the zero-write no-op covers a fully matching manifest **and** a present plugin entry (an up-to-date manifest alone is not enough for plugin installs), so a healthy startup performs no writes at all; the hook never deletes the cache — it instructs only. --- import type { Plugin } from "@opencode-ai/plugin"; -import { install, isUpToDateAnywhere } from "./src/installer.ts"; +import { ensureAssets, isInstalledAnywhere } from "./src/installer.ts"; import { RegistrationDetector, type RegistrationScope } from "./src/registration.ts"; -import { join } from "node:path"; const PACKAGE_NAME = "opencode-myextension"; @@ -55,12 +54,12 @@ const plugin: Plugin = async ({ directory }) => ({ return; } if (scope === "global" || scope === "both") { - await install("global", directory, { force: false }); + await ensureAssets("global", directory, false); } if (scope === "repo-local" || scope === "both") { - await install("local", directory, { force: false }); + await ensureAssets("local", directory, false); } - if (await isUpToDateAnywhere(directory)) return; + if (await isInstalledAnywhere(directory)) return; await adviseNotInstalledOnce(); } catch (error) { await adviseFailureOnce(error instanceof Error ? error.message : String(error)); diff --git a/assets/templates/prompts.template.txt b/assets/templates/prompts.template.txt index 53d7e0a..30d97bd 100644 --- a/assets/templates/prompts.template.txt +++ b/assets/templates/prompts.template.txt @@ -15,14 +15,10 @@ export async function confirmOverwrite(message: string): Promise { return confirm({ message, default: false }); } -export async function confirmPermissionConfig(): Promise { - return confirm({ message: "Configure skill permission (allow myextension)?", default: true }); +export async function confirmInstallMode(message: string): Promise<"copy" | "plugin"> { + const asPlugin = await confirm({ + message: message || "Register as a plugin (always fresh) instead of copying files? Choose No to copy editable files.", + default: false, + }); + return asPlugin ? "plugin" : "copy"; } - -export async function confirmMcpConfig(): Promise { - return confirm({ message: "Configure MCP server?", default: true }); -} - -export async function confirmPluginConfig(): Promise { - return confirm({ message: "Add plugin to opencode.json config?", default: true }); -} \ No newline at end of file diff --git a/assets/templates/registration.template.txt b/assets/templates/registration.template.txt index ab9b0ce..673fdca 100644 --- a/assets/templates/registration.template.txt +++ b/assets/templates/registration.template.txt @@ -2,7 +2,7 @@ None — emit as-is as src/registration.ts. -**Load-bearing — do not simplify:** detection is read-only (no writes, ever); it checks the global config, the repo's `.opencode/opencode.json(c)`, and a repo-root `opencode.json(c)` — both extensions at every base — with `@latest`-aware name matching, never the launch directory, which opencode always sets to the consumer repo, and never any directory-identity comparison (plugin dir vs `directory`, `import.meta.dirname`, `process.cwd()`, realpath); a maintainer's own checkout is served by that repo's own config registration, not by a directory check. `.jsonc` parses leniently (string-aware `//` and `/* */` comment stripping plus trailing-comma removal, so a `$schema` URL's `//` and escaped quotes survive); `.json` stays strict. Every unparseable candidate is warned about before any short-circuit. Nested and root configs combine with OR — a present-but-unregistered nested file never masks a repo-root registration. +**Load-bearing — do not simplify:** detection is read-only (no writes, ever); it checks the global config base (`opencode.json`, `opencode.jsonc`, and the legacy `config.json`), the repo's `.opencode/opencode.json(c)`, and a repo-root `opencode.json(c)` — both extensions at every base plus global `config.json` — with `@latest`-aware name matching, never the launch directory, which opencode always sets to the consumer repo, and never any directory-identity comparison (plugin dir vs `directory`, `import.meta.dirname`, `process.cwd()`, realpath); a maintainer's own checkout is served by that repo's own config registration, not by a directory check. `.jsonc` parses leniently (string-aware `//` and `/* */` comment stripping plus trailing-comma removal, so a `$schema` URL's `//` and escaped quotes survive); `.json` stays strict. Every unparseable candidate is warned about before any short-circuit. Nested and root configs combine with OR — a present-but-unregistered nested file never masks a repo-root registration. --- import { exists, readFile } from "node:fs/promises"; @@ -98,13 +98,19 @@ async function readEntriesForBase(base: string): Promise { return null; } +async function readGlobalEntries(): Promise { + const entries = await readEntriesForBase(getGlobalConfigPath()); + if (entries !== null) return entries; + return readPluginEntries(join(getGlobalConfigPath(), "config.json")); +} + export class RegistrationDetector { private readonly normalizer = new PluginNameNormalizer(); constructor(private readonly packageName: string) {} async detect(projectDir: string): Promise { - const globalEntries = await readEntriesForBase(getGlobalConfigPath()); + const globalEntries = await readGlobalEntries(); const nestedEntries = await readEntriesForBase(join(projectDir, ".opencode")); const rootEntries = await readEntriesForBase(projectDir); const inGlobal = globalEntries !== null && this.matchesAny(globalEntries); diff --git a/tests/deployment-plan.test.ts b/tests/deployment-plan.test.ts new file mode 100644 index 0000000..b9280ad --- /dev/null +++ b/tests/deployment-plan.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test } from "bun:test"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +const REPO_ROOT = path.resolve(import.meta.dirname, ".."); + +describe("content-based deployment plan (issue #18)", () => { + test("installer reads the content declaration", async () => { + const source = await readTemplate("installer.template.txt"); + expect(source).toContain("getContentDeclaration"); + expect(source).toMatch(/InstallMode/); + }); + + test("assets-only default is copy and plugin mode is opt-in", async () => { + const source = await readTemplate("installer.template.txt"); + expect(source).toMatch(/requested \?\? "copy"/); + expect(source).toContain('"plugin"'); + }); + + test("code-backed copy mode is a hard error", async () => { + const source = await readTemplate("installer.template.txt"); + expect(source).toMatch(/CopyModeUnsupportedError/); + }); + + test("copy install writes no config files", async () => { + const source = await readTemplate("installer.template.txt"); + expect(source).not.toContain("ensureSkillPermission"); + expect(source).not.toContain("addMcpServer"); + expect(source).not.toContain("configurePermission"); + }); + + test("installer delegates plugin-array edits to the surgical editor", async () => { + const source = await readTemplate("installer.template.txt"); + expect(source).toContain("PluginConfigEditor"); + expect(source).not.toContain("addPluginToConfig"); + expect(source).not.toContain("removePluginFromConfig"); + }); + + test("surgical editor template exists and never rewrites whole configs", async () => { + const source = await readTemplate("plugin-config.template.txt"); + expect(source).toContain("class PluginConfigEditor"); + expect(source).toContain("spliceEntry"); + expect(source).not.toMatch(/JSON\.stringify\(config/); + }); + + test("manifest records mode, entry, and target config file", async () => { + const source = await readTemplate("manifest.template.txt"); + expect(source).toMatch(/mode: InstallMode/); + expect(source).toContain("entry: string | null"); + expect(source).toContain("entryConfigPath: string | null"); + }); + + test("registration detection covers both bases, repo root, both extensions, and global config.json", async () => { + const source = await readTemplate("registration.template.txt"); + expect(source).toContain("opencode.json"); + expect(source).toContain("opencode.jsonc"); + expect(source).toContain('"config.json"'); + }); + + test("hook never edits the plugin array and ensures assets in registered scopes only", async () => { + const source = await readTemplate("plugin-local.template.txt"); + expect(source).toContain("ensureAssets"); + expect(source).not.toMatch(/addPluginConfig:\s*true|ensurePluginEntry/); + }); + + test("hook body degrades to a warning plus advisory", async () => { + const source = await readTemplate("plugin-local.template.txt"); + expect(source).toContain("adviseFailureOnce"); + expect(source).toContain("adviseNotInstalledOnce"); + }); + + test("CLI exposes --mode and reflects mode and registration in status", async () => { + const source = await readTemplate("cli.template.txt"); + expect(source).toContain("--mode"); + expect(source).toContain("entryConfigPath"); + expect(source).toContain("CopyModeUnsupportedError"); + }); + + test("packager and publisher instructions reference the generalized flow", async () => { + const packager = await readAgent("opencode-packager.md"); + const publisher = await readAgent("opencode-publisher.md"); + expect(packager).toContain("plugin-config.template.txt"); + expect(packager).toContain("content-based"); + expect(publisher).toContain("plugin-config.template.txt"); + expect(publisher).toContain("--mode"); + }); +}); + +async function readTemplate(name: string): Promise { + const source = await readFile(path.join(REPO_ROOT, "assets/templates", name), "utf-8"); + return source.split("---").slice(1).join("---"); +} + +async function readAgent(name: string): Promise { + return readFile(path.join(REPO_ROOT, "assets/agents", name), "utf-8"); +} From 5c9a632a5f60c5f4dde4f0139d2fa74e72203dd7 Mon Sep 17 00:00:00 2001 From: "Diego B." Date: Wed, 23 Sep 2026 03:41:21 -0400 Subject: [PATCH 06/24] feat(conformance): rubric and references catch up with ADR-0008 Checklist adds B5 (surgical writes), B6 (registration zero-write no-op), E1 (content declaration consistency), E2 (binary mode enforcement), with E1-E2/B5-B6 in the hard non-conformance set; the auditor reviews A-E. Plugins and config references document the allowed config patterns (including global config.json), the repo-root opencode.jsonc create-default, and the surgical-writer rule. Closes #19 --- CHANGELOG.md | 5 +++ assets/agents/opencode-extension-auditor.md | 4 +-- assets/references/config.md | 10 ++++-- assets/references/conformance-checklist.md | 36 +++++++++++++++++++-- assets/references/plugins.md | 10 ++++++ 5 files changed, 57 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a66c0a1..1c0b239 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Conformance checklist items for the content-based deployment plan (ADR-0008): surgical config writes (B5), zero-write registration no-op (B6), content declaration present and consistent (E1), and binary mode enforcement (E2); E1–E2 and B5–B6 join the hard non-conformance set and the auditor's conformance review covers section E +- Plugins and config references document the allowed config patterns (including global `config.json`), the repo-root `opencode.jsonc` create-default, and the surgical-writer rule + ### Changed - **Breaking:** `install` is now a registration manager (plugin install is the only mode for this code-backed package, per [ADR-0008](docs/adr/0008-content-based-deployment-plans.md), superseding ADR-0004): the CLI ensures the `plugin` entry in the target scope's config file via the surgical editor — comments and formatting preserved, unparseable configs abort untouched — and writes a generalized manifest (version, mode, plugin entry, target config file) at the scope base. A matching manifest with the entry present is a zero-write no-op. `--mode copy` is refused with an explanatory error; `--force` now re-registers and rewrites the manifest instead of removing the entry diff --git a/assets/agents/opencode-extension-auditor.md b/assets/agents/opencode-extension-auditor.md index e487c66..8df1d5f 100644 --- a/assets/agents/opencode-extension-auditor.md +++ b/assets/agents/opencode-extension-auditor.md @@ -60,10 +60,10 @@ Suggest packaging when criteria are met (3+ skills OR 2+ commands OR 1+ agent), Run this mode when asked whether a package is aligned with this suite's guidance or best practice, whether it accounts for the manifest implementation, or to assess/report conformance generally. The subject is a built package (a repo or directory with `plugin.ts`, install logic, and a bundled asset directory — `assets/` or repo-root `skills/`), not a project's `.opencode/`. -1. Read `../references/conformance-checklist.md` and treat **every item in the checklist** (all A, B, C, D items) as the review rubric — never a hardcoded range. State the **absolute path and version of the checklist copy you used** in the report header (version comes from that package tree's own `package.json` / CHANGELOG). When a newer criteria copy exists than the one your relative path resolved (e.g. an installed cache copy older than the suite repo), say so explicitly and **refuse to return a Conformant verdict against the stale criteria** — report at most "Partially conformant, pending review against current criteria". +1. Read `../references/conformance-checklist.md` and treat **every item in the checklist** (all A, B, C, D, E items) as the review rubric — never a hardcoded range. State the **absolute path and version of the checklist copy you used** in the report header (version comes from that package tree's own `package.json` / CHANGELOG). When a newer criteria copy exists than the one your relative path resolved (e.g. an installed cache copy older than the suite repo), say so explicitly and **refuse to return a Conformant verdict against the stale criteria** — report at most "Partially conformant, pending review against current criteria". 2. The review is executed, not just read. Run the package's own test suite and typecheck (`bun test`, `bun run check`) and report their results as evidence. For any parse, hash, manifest, or detection logic, construct at least one adversarial input and **execute** it (e.g. `bun -e` / `node -e` with a `.jsonc` containing a comment between a trailing comma and its closer) before declaring the item Conformant; cite the command and observed output. Stay within the bash allowlist above; never write to the subject repo. 3. Locate the install logic (installer module, load hook, CLI) and trace each item against the actual code, citing file and line evidence. Absence of evidence for an item is itself a finding. Grep the detection path for `getPackageDir|import.meta.dirname|process.cwd|realpath` and report any hit in the detection logic. 4. Distinguish live-path violations from latent ones (dead code, unreachable fallbacks) - the verdict scale in the checklist depends on it. -5. Report per section (A-D) with item ID, verdict (pass/fail/latent), evidence, and a fix sketch for each failure keyed to the corrected pattern in the checklist. Verify README badge links resolve (relative paths like `LICENSE`), not just that the badges exist. +5. Report per section (A-E) with item ID, verdict (pass/fail/latent), evidence, and a fix sketch for each failure keyed to the corrected pattern in the checklist. Verify README badge links resolve (relative paths like `LICENSE`), not just that the badges exist. Done when the report inventories every extension found across the six targets, states a readiness verdict, and lands recommendations with a complexity estimate (inventory mode), or when every checklist item carries a verdict with cited evidence and an overall conformant/partially/non-conformant call (conformance mode). diff --git a/assets/references/config.md b/assets/references/config.md index 56ce77a..6350086 100644 --- a/assets/references/config.md +++ b/assets/references/config.md @@ -1,21 +1,25 @@ # OpenCode config — fundamentals -OpenCode is configured with `opencode.json` (or `.jsonc`). Schema: `https://opencode.ai/config.json`. TUI settings live in a separate `tui.json` (`https://opencode.ai/tui.json`). +OpenCode is configured with `opencode.json` or `opencode.jsonc`; the global config may also be `config.json`. Schema: `https://opencode.ai/config.json`. TUI settings live in a separate `tui.json` (`https://opencode.ai/tui.json`). ## Locations and precedence Configs are **merged, not replaced**; later sources override earlier ones only for conflicting keys: 1. Remote config (`.well-known/opencode`, organizational defaults) -2. Global config (`~/.config/opencode/opencode.json`) +2. Global config (`~/.config/opencode/`: `opencode.json`, `opencode.jsonc`, or `config.json`) 3. Custom config (`OPENCODE_CONFIG` env var) -4. Project config (`opencode.json` at project root, searched up to the git root) +4. Project config (`opencode.json`/`opencode.jsonc` at project root, searched up to the git root) 5. `.opencode/` directories (agents, commands, plugins, skills, tools) 6. Inline config (`OPENCODE_CONFIG_CONTENT` env var) 7. Managed files (`/etc/opencode/`, `%ProgramData%\opencode`, macOS app support) and macOS MDM preferences — highest, not user-overridable So: defaults/remote < global < project; managed settings override everything. +## Editing configs programmatically + +When a tool adds a `plugin` entry (e.g. a plugin package's installer): check both `.json`/`.jsonc` extensions at every base plus global `config.json`; when no config exists at a base, create a repo-root `opencode.jsonc`; edit by text splice into the `plugin` array only, leaving every other byte untouched, and write nothing when a semantically matching entry already exists. See the plugins reference ("Editing consumer configs") for the full rule set. + ## Key schema options | Key | Purpose | diff --git a/assets/references/conformance-checklist.md b/assets/references/conformance-checklist.md index 18f8797..c9ada1c 100644 --- a/assets/references/conformance-checklist.md +++ b/assets/references/conformance-checklist.md @@ -64,6 +64,20 @@ finding. never deletes the cache (deletion races OpenCode's in-flight installs); it instructs only. +- **B5 Surgical config writes.** Every registration write to a consumer + config file is a text splice into the `plugin` array with every other + byte untouched — indentation, comments, trailing commas, key order, and + unrelated keys all preserved. A parse-then-reserialize of the whole file + (which reformats or drops comments) is non-conformant. The splice never + touches anything outside the array, and a file that cannot be spliced is + reported, not rewritten. +- **B6 Zero-write registration no-op.** When a semantically matching entry + (`name`, `name@latest`, `name@x.y.z`) already exists in a candidate + config, registration for that config is a no-op: zero bytes written, even + when the entry's spelling differs from the canonical form. Conversely, a + plugin-mode up-to-date check requires the recorded entry to still be + present — a version match alone is not enough when the entry was removed. + ## C. Scope discipline - **C1 Read-only, config-based scope detection.** Registration scope is @@ -140,12 +154,28 @@ finding. `name@latest` or a `file:///` URL. Verify emitted keys against `https://opencode.ai/config.json` (the `Config` definition sets `additionalProperties: false`, so an invalid key is rejected at load). A - shipped snippet using an invalid key is non-conformant. + shipped snippet using an invalid key is non-conformant. + +## E. Deployment plan (ADR-0008) + +- **E1 Content declaration present and consistent.** `package.json` carries + a `"content"` field (`"assets"` or `"code"`), and it matches what the + package actually ships: any package containing agents, tools, hooks, or + other plugin integrations declares `"code"`; an assets-only package + declares `"assets"`. A missing declaration, or one contradicting the + payload (e.g. `"assets"` on a package shipping a `plugin.ts` hook) is + non-conformant. +- **E2 Binary mode enforcement.** The deployment plan is binary and + content-decided: assets-only packages copy-install by default (`--mode + plugin` opts into registration); code-backed packages always register and + `--mode copy` is a hard, explanatory error (`CopyModeUnsupportedError`), + never a hybrid copy-plus-register. A per-scope mode choice, a mixed + copy-and-register install, or a silent mode fallback is non-conformant. ## Verdict scale - **Conformant** — every item evidenced. - **Partially conformant** — violations are latent (dead code, fallback paths not yet exercised); list item IDs with evidence. -- **Non-conformant** — any A1–A4, B1, B4, C1–C3 violation on a live code path; - these are the historically destructive patterns. +- **Non-conformant** — any A1–A4, B1, B4–B6, C1–C3, E1–E2 violation on a + live code path; these are the historically destructive patterns. diff --git a/assets/references/plugins.md b/assets/references/plugins.md index f265c74..b1be870 100644 --- a/assets/references/plugins.md +++ b/assets/references/plugins.md @@ -84,4 +84,14 @@ Verified against the OpenCode source; rely on these when writing plugins that se - Error handling: install/entry/import failures are caught and logged — startup continues. But the wait that joins background npm-install fibers has no timeout, and a hook that rejects during config assembly propagates into config loading: either can stall startup with no visible escape. A plugin must therefore never throw from hooks. - Config files: global config may be `opencode.json`, `opencode.jsonc`, or `config.json`; project config likewise `.json`/`.jsonc` (`.opencode/opencode.json(c)` and repo root). Anything reading consumer registration must check both extensions. - `.jsonc` parse semantics: string-aware stripping of `//` and `/* */` comments plus trailing commas, for `.jsonc` only; `.json` stays strict. A `$schema` URL containing `//` must survive; escaped quotes must not break string tracking; the trailing-comma lookahead must skip comments (strip comments first, then trailing commas). Any `.jsonc` reader must pass this fixture matrix: line comment; block comment; trailing comma at array end; trailing comma at object end; comment between a trailing comma and its closer; `$schema` URL containing `//`; a string containing `/*`; an escaped quote inside a string; and a genuinely malformed file, which must stay unparseable and be preserved byte-for-byte. +- Config files: global config may be `opencode.json`, `opencode.jsonc`, or `config.json`; project config likewise `.json`/`.jsonc` at either base (`.opencode/opencode.json(c)` and repo root). Anything reading consumer registration must check both extensions, and `config.json` at the global base. - Precedence: the effective `plugin` list is the union of global and project entries (project wins on name collision). Agents, commands, and skills are scanned global-first, project-last, with later (project) definitions overriding the same name — so a consumer can override one installed skill or command file per project without touching the global install. + +## Editing consumer configs (surgical writer) + +Rules for any code that adds a `plugin` entry to a consumer's config — treat as load-bearing invariants: + +- **Allowed config patterns.** Registration candidates are: `.opencode/opencode.json` and `.opencode/opencode.jsonc` (repo-local), `opencode.json` and `opencode.jsonc` at the repo root, and `config.json` at the global base (`~/.config/opencode/` or `$XDG_CONFIG_HOME/opencode/`). `.json` is parsed strictly; `.jsonc` leniently (comments and trailing commas). Read both extensions at every base — reading only `.json` is non-conformant. +- **Create-default.** When no config exists at a base, create a repo-root `opencode.jsonc` with a minimal `plugin` array — do not create `.opencode/` dirs or `.json` files as a default, and never create anything at the global base implicitly. +- **Surgical writes.** An edit is a text splice into the `plugin` array only: every other byte — indentation, comments, trailing commas, key order, unrelated keys — must be untouched. Never parse-then-reserialize the whole file; never write a config rebuilt from `{}` after a parse error (a parse error aborts, preserving the file byte-for-byte). +- **Zero-write no-op.** If a semantically matching entry already exists (`name`, `name@latest`, `name@x.y.z` are the same package), write nothing — even when the existing spelling is non-canonical. From 88ec04198bbb4d5a1cae4814694c5ecc91457b84 Mon Sep 17 00:00:00 2001 From: "Diego B." Date: Wed, 23 Sep 2026 03:43:28 -0400 Subject: [PATCH 07/24] docs(references): drop duplicated config-files bullet in plugins reference --- assets/references/plugins.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/assets/references/plugins.md b/assets/references/plugins.md index b1be870..adc4c38 100644 --- a/assets/references/plugins.md +++ b/assets/references/plugins.md @@ -82,9 +82,8 @@ Verified against the OpenCode source; rely on these when writing plugins that se - Resolution: a `plugin` entry starting with `file://`, `.`, or an absolute path loads from disk as-is; anything else is treated as an npm spec and installed with arborist into `~/.cache/opencode/packages//node_modules/`. An existing cached `node_modules/` is reused verbatim — including a partial or corrupt install; nothing re-validates or repairs it. - Import: the entrypoint is picked from `exports["./server"]`, then `main`, then a root `index.{ts,tsx,js,mjs,cjs}`; it must resolve inside the package directory. The module is imported from the real directory (no bundling), so `import.meta.dirname` is the package dir and bundled `assets/` resolve normally. - Error handling: install/entry/import failures are caught and logged — startup continues. But the wait that joins background npm-install fibers has no timeout, and a hook that rejects during config assembly propagates into config loading: either can stall startup with no visible escape. A plugin must therefore never throw from hooks. -- Config files: global config may be `opencode.json`, `opencode.jsonc`, or `config.json`; project config likewise `.json`/`.jsonc` (`.opencode/opencode.json(c)` and repo root). Anything reading consumer registration must check both extensions. -- `.jsonc` parse semantics: string-aware stripping of `//` and `/* */` comments plus trailing commas, for `.jsonc` only; `.json` stays strict. A `$schema` URL containing `//` must survive; escaped quotes must not break string tracking; the trailing-comma lookahead must skip comments (strip comments first, then trailing commas). Any `.jsonc` reader must pass this fixture matrix: line comment; block comment; trailing comma at array end; trailing comma at object end; comment between a trailing comma and its closer; `$schema` URL containing `//`; a string containing `/*`; an escaped quote inside a string; and a genuinely malformed file, which must stay unparseable and be preserved byte-for-byte. - Config files: global config may be `opencode.json`, `opencode.jsonc`, or `config.json`; project config likewise `.json`/`.jsonc` at either base (`.opencode/opencode.json(c)` and repo root). Anything reading consumer registration must check both extensions, and `config.json` at the global base. +- `.jsonc` parse semantics: string-aware stripping of `//` and `/* */` comments plus trailing commas, for `.jsonc` only; `.json` stays strict. A `$schema` URL containing `//` must survive; escaped quotes must not break string tracking; the trailing-comma lookahead must skip comments (strip comments first, then trailing commas). Any `.jsonc` reader must pass this fixture matrix: line comment; block comment; trailing comma at array end; trailing comma at object end; comment between a trailing comma and its closer; `$schema` URL containing `//`; a string containing `/*`; an escaped quote inside a string; and a genuinely malformed file, which must stay unparseable and be preserved byte-for-byte. - Precedence: the effective `plugin` list is the union of global and project entries (project wins on name collision). Agents, commands, and skills are scanned global-first, project-last, with later (project) definitions overriding the same name — so a consumer can override one installed skill or command file per project without touching the global install. ## Editing consumer configs (surgical writer) From 5f34739f4f28e0ca89d8e01de96f14d109b06c9d Mon Sep 17 00:00:00 2001 From: "Diego B." Date: Wed, 23 Sep 2026 04:13:15 -0400 Subject: [PATCH 08/24] feat(cli): install prunes stale package-cache copies (#12) Every install invocation, including a no-op, removes opencode-architect, opencode-architect@latest, and opencode-architect@ from the OpenCode package cache (XDG_CACHE_HOME || ~/.cache + opencode/packages) so OpenCode re-fetches the just-installed version on next start. Pinned versions and other packages are preserved; removal failures warn without failing the install, and cleared paths are reported. --- CHANGELOG.md | 1 + README.md | 2 + cli.ts | 6 +++ installer.ts | 33 +++++++++++++ tests/cli.test.ts | 28 ++++++++++- tests/installer.test.ts | 102 ++++++++++++++++++++++++++++++++++++++-- 6 files changed, 165 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c0b239..93a782e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `install` prunes this package's stale copies from OpenCode's package cache (`$XDG_CACHE_HOME/opencode/packages`, falling back to `~/.cache/opencode/packages`): `opencode-architect`, `opencode-architect@latest`, and `opencode-architect@` are removed on every install invocation, including no-ops, so OpenCode re-fetches the just-installed version on next start. Pinned versions and other packages' cache dirs are preserved; removal failures warn without failing the install, and cleared paths are reported - Conformance checklist items for the content-based deployment plan (ADR-0008): surgical config writes (B5), zero-write registration no-op (B6), content declaration present and consistent (E1), and binary mode enforcement (E2); E1–E2 and B5–B6 join the hard non-conformance set and the auditor's conformance review covers section E - Plugins and config references document the allowed config patterns (including global `config.json`), the repo-root `opencode.jsonc` create-default, and the surgical-writer rule diff --git a/README.md b/README.md index 9d5d1a2..0c27198 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,8 @@ bunx opencode-architect --help # full usage Re-running install when the manifest matches reality is a zero-write no-op. `--mode copy` is refused with an explanatory error: this package is code-backed (it ships agents), and copying cannot express plugin registration. +Every install — including a no-op — also clears this package's stale copies from OpenCode's package cache (`$XDG_CACHE_HOME/opencode/packages`, falling back to `~/.cache/opencode/packages`): `opencode-architect`, `opencode-architect@latest`, and `opencode-architect@`. Pinned copies like `opencode-architect@0.6.0` and other packages' cache dirs are left untouched. This makes OpenCode re-fetch the just-installed version on next start instead of reusing a stale or partial extraction. Removal is best-effort: a failure prints a warning but the install still succeeds. + **Upgrading from a copy install (pre-0.8):** if a previous version copied agents into your scope base, install detects the old manifest, removes exactly the files it lists, prints a notice, and switches the scope to plugin registration in one step. Locally modified files are tracked by hash; uninstall and migration only remove what the manifest recorded. ## What you get: ten specialist OpenCode agents diff --git a/cli.ts b/cli.ts index e11e2ab..77aa4db 100644 --- a/cli.ts +++ b/cli.ts @@ -56,6 +56,12 @@ async function main(): Promise { console.log(` Plugin entry: ${outcome.configPath}`); } console.log(` Manifest: ${outcome.manifestPath}`); + for (const cachePath of outcome.clearedCache) { + console.log(` Cleared cache: ${cachePath}`); + } + for (const warning of outcome.cacheWarnings) { + console.warn(` Warning: ${warning}`); + } break; } case "uninstall": { diff --git a/installer.ts b/installer.ts index 122efce..af36b3d 100644 --- a/installer.ts +++ b/installer.ts @@ -37,6 +37,8 @@ export interface InstallOutcome { configPath: string | null; configAction: "noop" | "updated" | "created" | "blocked"; removedPayload: string[]; + clearedCache: string[]; + cacheWarnings: string[]; } export interface UninstallOutcome { @@ -109,6 +111,8 @@ export class Installer { await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n"); } + const cache = await this.prunePackageCache(version); + return { action, scope, @@ -116,9 +120,38 @@ export class Installer { configPath: registration.configPath, configAction: registration.action, removedPayload, + clearedCache: cache.removed, + cacheWarnings: cache.warnings, }; } + private async prunePackageCache(version: string): Promise<{ removed: string[]; warnings: string[] }> { + const removed: string[] = []; + const warnings: string[] = []; + const targets = [ + PACKAGE_NAME, + `${PACKAGE_NAME}@latest`, + `${PACKAGE_NAME}@${version}`, + ].map((name) => path.join(this.packageCacheRoot(), name)); + for (const target of targets) { + if (!(await exists(target))) continue; + try { + await rm(target, { recursive: true }); + removed.push(target); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + warnings.push(`Could not clear cached package ${target}: ${message}`); + } + } + return { removed, warnings }; + } + + private packageCacheRoot(): string { + const xdgCacheHome = process.env.XDG_CACHE_HOME; + if (xdgCacheHome) return path.join(xdgCacheHome, "opencode", "packages"); + return path.join(homedir(), ".cache", "opencode", "packages"); + } + public async uninstall(scope: Scope, projectDir: string): Promise { const base = this.scopeBase(scope, projectDir); const manifestPath = path.join(base, MANIFEST_NAME); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index ed94910..eb63bd8 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { mkdtemp, rm } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -12,11 +13,12 @@ interface CliRun { stderr: string; } -async function runCli(args: string[], cwd?: string): Promise { +async function runCli(args: string[], cwd?: string, env?: Record): Promise { const proc = Bun.spawn([process.execPath, CLI_PATH, ...args], { cwd: cwd ?? PACKAGE_ROOT, stdout: "pipe", stderr: "pipe", + env: env ? { ...process.env, ...env } : undefined, }); const [stdout, stderr, exitCode] = await Promise.all([ new Response(proc.stdout).text(), @@ -88,4 +90,26 @@ describe("cli", () => { await rm(dir, { recursive: true, force: true }); } }); + + test("a cache-clearing failure warns but install exits 0", async () => { + if (process.getuid?.() === 0) return; + const dir = await mkdtemp(path.join(tmpdir(), "oa-cli-")); + const cacheDir = await mkdtemp(path.join(tmpdir(), "oa-cli-cache-")); + const blocked = path.join(cacheDir, "opencode", "packages", "opencode-architect@latest", "nested"); + await mkdir(blocked, { recursive: true }); + await writeFile(path.join(blocked, "file.txt"), "cached"); + await chmod(blocked, 0o500); + try { + const run = await runCli(["install"], dir, { XDG_CACHE_HOME: cacheDir }); + + expect(run.exitCode).toBe(0); + expect(run.stdout).toContain("Registered"); + expect(run.stderr).toContain(`Could not clear cached package ${path.join(cacheDir, "opencode", "packages", "opencode-architect@latest")}`); + expect(existsSync(path.join(cacheDir, "opencode", "packages", "opencode-architect"))).toBe(false); + } finally { + await chmod(blocked, 0o700); + await rm(dir, { recursive: true, force: true }); + await rm(cacheDir, { recursive: true, force: true }); + } + }); }); diff --git a/tests/installer.test.ts b/tests/installer.test.ts index cd7fb3a..5624afa 100644 --- a/tests/installer.test.ts +++ b/tests/installer.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test, beforeEach, afterEach } from "bun:test"; import { existsSync } from "node:fs"; -import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { Installer, contentHash, type Manifest, type Scope } from "../installer"; @@ -9,24 +9,35 @@ const PACKAGE_ROOT = path.resolve(import.meta.dirname, ".."); let projectDir = ""; let homeDir = ""; -let originalXdg: string | undefined; +let cacheDir = ""; +let originalXdgConfig: string | undefined; +let originalXdgCache: string | undefined; const installer = new Installer(); beforeEach(async () => { projectDir = await mkdtemp(path.join(tmpdir(), "oa-installer-project-")); homeDir = await mkdtemp(path.join(tmpdir(), "oa-installer-home-")); - originalXdg = process.env.XDG_CONFIG_HOME; + cacheDir = await mkdtemp(path.join(tmpdir(), "oa-installer-cache-")); + originalXdgConfig = process.env.XDG_CONFIG_HOME; + originalXdgCache = process.env.XDG_CACHE_HOME; process.env.XDG_CONFIG_HOME = homeDir; + process.env.XDG_CACHE_HOME = cacheDir; }); afterEach(async () => { - if (originalXdg === undefined) { + if (originalXdgConfig === undefined) { delete process.env.XDG_CONFIG_HOME; } else { - process.env.XDG_CONFIG_HOME = originalXdg; + process.env.XDG_CONFIG_HOME = originalXdgConfig; + } + if (originalXdgCache === undefined) { + delete process.env.XDG_CACHE_HOME; + } else { + process.env.XDG_CACHE_HOME = originalXdgCache; } await rm(projectDir, { recursive: true, force: true }); await rm(homeDir, { recursive: true, force: true }); + await rm(cacheDir, { recursive: true, force: true }); }); function scopeBase(scope: Scope): string { @@ -251,6 +262,87 @@ describe("Installer.install", () => { }); }); +describe("Installer.install cache pruning", () => { + function cacheRoot(): string { + return path.join(cacheDir, "opencode", "packages"); + } + + async function seedCache(): Promise { + const version = await readPackageVersion(); + for (const name of [ + "opencode-architect", + "opencode-architect@latest", + `opencode-architect@${version}`, + "opencode-architect@0.6.0", + "other-package", + ]) { + await mkdir(path.join(cacheRoot(), name, "nested"), { recursive: true }); + await writeFile(path.join(cacheRoot(), name, "nested", "file.txt"), "cached"); + } + } + + test("removes stale non-pinned copies and preserves pinned and foreign dirs", async () => { + await seedCache(); + const version = await readPackageVersion(); + + const outcome = await install("local"); + + expect(outcome.clearedCache).toEqual([ + path.join(cacheRoot(), "opencode-architect"), + path.join(cacheRoot(), "opencode-architect@latest"), + path.join(cacheRoot(), `opencode-architect@${version}`), + ]); + expect(outcome.cacheWarnings).toEqual([]); + expect(existsSync(path.join(cacheRoot(), "opencode-architect"))).toBe(false); + expect(existsSync(path.join(cacheRoot(), "opencode-architect@latest"))).toBe(false); + expect(existsSync(path.join(cacheRoot(), `opencode-architect@${version}`))).toBe(false); + expect(existsSync(path.join(cacheRoot(), "opencode-architect@0.6.0"))).toBe(true); + expect(existsSync(path.join(cacheRoot(), "other-package"))).toBe(true); + }); + + test("prunes even when the install is a no-op", async () => { + await install("local"); + await seedCache(); + const version = await readPackageVersion(); + + const outcome = await install("local"); + + expect(outcome.action).toBe("noop"); + expect(outcome.clearedCache).toEqual([ + path.join(cacheRoot(), "opencode-architect"), + path.join(cacheRoot(), "opencode-architect@latest"), + path.join(cacheRoot(), `opencode-architect@${version}`), + ]); + expect(await readdir(cacheRoot())).toEqual( + expect.arrayContaining(["opencode-architect@0.6.0", "other-package"]), + ); + expect(await readdir(cacheRoot())).toHaveLength(2); + }); + + test("removal failure warns but the install still succeeds", async () => { + await seedCache(); + const blocked = path.join(cacheRoot(), "opencode-architect@latest", "nested"); + await chmod(blocked, 0o500); + try { + const outcome = await install("local"); + + expect(outcome.action).toBe("installed"); + expect(outcome.cacheWarnings).toHaveLength(1); + expect(outcome.cacheWarnings[0]).toContain(path.join(cacheRoot(), "opencode-architect@latest")); + expect(existsSync(path.join(cacheRoot(), "opencode-architect"))).toBe(false); + expect(existsSync(path.join(cacheRoot(), "opencode-architect@latest"))).toBe(true); + } finally { + await chmod(blocked, 0o700); + } + }); + + test("cache root honors XDG_CACHE_HOME with no cache present", async () => { + const outcome = await install("local"); + expect(outcome.clearedCache).toEqual([]); + expect(outcome.cacheWarnings).toEqual([]); + }); +}); + describe("Installer.uninstall", () => { test("plugin mode removes the entry and the manifest", async () => { const configPath = path.join(scopeBase("local"), "opencode.json"); From 0aedf4e516c300b6cd5f35565142bc4100023df0 Mon Sep 17 00:00:00 2001 From: "Diego B." Date: Wed, 23 Sep 2026 05:41:15 -0400 Subject: [PATCH 09/24] docs(agents): add issue implementation workflow to issue-tracker guide --- docs/agents/issue-tracker.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md index 079760e..897c967 100644 --- a/docs/agents/issue-tracker.md +++ b/docs/agents/issue-tracker.md @@ -6,6 +6,7 @@ Issues and specs for this repo live as GitHub issues. Use the `gh` CLI for all o - **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies. - **Read an issue**: `gh issue view --json number,title,body,labels,state,comments`, filtering the `comments` array with `jq` as needed. +- **Implement an issue**: First make sure it is not blocked. Then claim it. Use `implement` skill to do work and review. Finally, confirm with user to close ticket. - **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters. - **Comment on an issue**: `gh issue comment --body "..."` - **Apply / remove labels**: `gh issue edit --add-label "..."` / `--remove-label "..."` From ee5a70d56e03e927a3b45a988f21dc68cf352283 Mon Sep 17 00:00:00 2001 From: "Diego B." Date: Wed, 23 Sep 2026 05:46:50 -0400 Subject: [PATCH 10/24] feat(cache): add clearCache module for package-cache removal --- cache-cleaner.ts | 97 ++++++++++++++++++++++ package.json | 1 + tests/cache-cleaner.test.ts | 155 ++++++++++++++++++++++++++++++++++++ 3 files changed, 253 insertions(+) create mode 100644 cache-cleaner.ts create mode 100644 tests/cache-cleaner.test.ts diff --git a/cache-cleaner.ts b/cache-cleaner.ts new file mode 100644 index 0000000..8668172 --- /dev/null +++ b/cache-cleaner.ts @@ -0,0 +1,97 @@ +import { readdir, rm } from "node:fs/promises"; +import { homedir } from "node:os"; +import path from "node:path"; + +export const PACKAGE_NAME = "opencode-architect"; + +export interface ClearCacheOptions { + packageName?: string; + all?: boolean; + yes?: boolean; +} + +export interface ClearCacheOutcome { + removed: string[]; + warnings: string[]; +} + +export class ClearCacheUsageError extends Error {} + +export function packagesCacheRoot(): string { + const xdgCacheHome = process.env.XDG_CACHE_HOME; + if (xdgCacheHome) return path.join(xdgCacheHome, "opencode", "packages"); + return path.join(homedir(), ".cache", "opencode", "packages"); +} + +export function opencodeCacheRoot(): string { + return path.dirname(packagesCacheRoot()); +} + +export function isUnsafePackageName(name: string): boolean { + return name.includes("/") || name.includes("\\") || name === ".." || name === "."; +} + +export async function clearCache(options: ClearCacheOptions = {}): Promise { + const { packageName, all = false, yes = false } = options; + + if (packageName !== undefined && all) { + throw new ClearCacheUsageError("--package and --all are mutually exclusive."); + } + if (packageName !== undefined && !yes) { + throw new ClearCacheUsageError(`--package requires --yes to confirm deletion. Re-run with: clear-cache --package ${packageName} --yes`); + } + if (all && !yes) { + throw new ClearCacheUsageError("--all requires --yes to confirm deletion. Re-run with: clear-cache --all --yes"); + } + if (packageName !== undefined && isUnsafePackageName(packageName)) { + throw new ClearCacheUsageError(`Invalid package name: ${packageName}. Package names must not contain path separators or "..".`); + } + + const removed: string[] = []; + const warnings: string[] = []; + + if (all) { + const target = opencodeCacheRoot(); + try { + await rm(target, { recursive: true, force: true }); + removed.push(target); + } catch (error) { + warnings.push(`Could not remove ${target}: ${errorMessage(error)}`); + } + return { removed, warnings }; + } + + const name = packageName ?? PACKAGE_NAME; + const packagesRoot = packagesCacheRoot(); + let entries: string[]; + try { + entries = await readdir(packagesRoot); + } catch (error) { + if (isMissingError(error)) return { removed, warnings }; + throw error; + } + + const prefix = `${name}@`; + const targets = entries + .filter((entry) => entry === name || entry.startsWith(prefix)) + .sort() + .map((entry) => path.join(packagesRoot, entry)); + + for (const target of targets) { + try { + await rm(target, { recursive: true }); + removed.push(target); + } catch (error) { + warnings.push(`Could not clear cached package ${target}: ${errorMessage(error)}`); + } + } + return { removed, warnings }; +} + +function isMissingError(error: unknown): boolean { + return (error as NodeJS.ErrnoException)?.code === "ENOENT"; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/package.json b/package.json index 1cf90b7..d53fd16 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "files": [ "index.ts", "agent-loader.ts", + "cache-cleaner.ts", "cli.ts", "installer.ts", "plugin-config.ts", diff --git a/tests/cache-cleaner.test.ts b/tests/cache-cleaner.test.ts new file mode 100644 index 0000000..1179dc1 --- /dev/null +++ b/tests/cache-cleaner.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { existsSync } from "node:fs"; +import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { clearCache, ClearCacheUsageError, isUnsafePackageName, packagesCacheRoot, opencodeCacheRoot } from "../cache-cleaner"; + +let cacheDir = ""; +let originalXdgCache: string | undefined; + +beforeEach(async () => { + cacheDir = await mkdtemp(path.join(tmpdir(), "oa-clear-cache-")); + originalXdgCache = process.env.XDG_CACHE_HOME; + process.env.XDG_CACHE_HOME = cacheDir; +}); + +afterEach(async () => { + if (originalXdgCache === undefined) delete process.env.XDG_CACHE_HOME; + else process.env.XDG_CACHE_HOME = originalXdgCache; + await rm(cacheDir, { recursive: true, force: true }); +}); + +function cacheRoot(): string { + return packagesCacheRoot(); +} + +async function seedPackage(name: string): Promise { + const dir = path.join(cacheRoot(), name); + await mkdir(path.join(dir, "nested"), { recursive: true }); + await writeFile(path.join(dir, "nested", "file.txt"), "cached"); + return dir; +} + +describe("clearCache default mode", () => { + test("removes opencode-architect and every opencode-architect@* copy", async () => { + const plain = await seedPackage("opencode-architect"); + const latest = await seedPackage("opencode-architect@latest"); + const versioned = await seedPackage("opencode-architect@0.7.1"); + const other = await seedPackage("other-package"); + + const outcome = await clearCache(); + + expect(outcome.warnings).toEqual([]); + expect(outcome.removed.sort()).toEqual([plain, latest, versioned].sort()); + expect(existsSync(plain)).toBe(false); + expect(existsSync(latest)).toBe(false); + expect(existsSync(versioned)).toBe(false); + expect(existsSync(other)).toBe(true); + }); + + test("exits successfully with nothing cached", async () => { + const outcome = await clearCache(); + + expect(outcome.removed).toEqual([]); + expect(outcome.warnings).toEqual([]); + }); + + test("succeeds when the packages directory does not exist at all", async () => { + const outcome = await clearCache(); + + expect(outcome.removed).toEqual([]); + expect(outcome.warnings).toEqual([]); + }); +}); + +describe("clearCache --package mode", () => { + test("removes only that package's cache dirs", async () => { + const target = await seedPackage("some-pkg"); + await seedPackage("some-pkg@1.0.0"); + const ours = await seedPackage("opencode-architect"); + + const outcome = await clearCache({ packageName: "some-pkg", yes: true }); + + expect(outcome.warnings).toEqual([]); + expect(existsSync(target)).toBe(false); + expect(existsSync(path.join(cacheRoot(), "some-pkg@1.0.0"))).toBe(false); + expect(existsSync(ours)).toBe(true); + }); + + test("requires --yes and deletes nothing without it", async () => { + const dir = await seedPackage("some-pkg"); + + expect(() => clearCache({ packageName: "some-pkg" })).toThrow(ClearCacheUsageError); + try { + await clearCache({ packageName: "some-pkg" }); + } catch { + // expected + } + expect(existsSync(dir)).toBe(true); + }); + + test("rejects unsafe package names", async () => { + for (const name of ["../escape", "foo/bar", "foo\\bar", "..", "."]) { + expect(() => clearCache({ packageName: name, yes: true })).toThrow(ClearCacheUsageError); + expect(isUnsafePackageName(name)).toBe(true); + } + }); +}); + +describe("clearCache --all mode", () => { + test("removes the entire opencode cache directory", async () => { + await seedPackage("opencode-architect"); + const unrelated = path.join(opencodeCacheRoot(), "other-tool"); + await mkdir(unrelated, { recursive: true }); + await writeFile(path.join(unrelated, "data"), "x"); + + const outcome = await clearCache({ all: true, yes: true }); + + expect(outcome.warnings).toEqual([]); + expect(existsSync(opencodeCacheRoot())).toBe(false); + }); + + test("requires --yes and deletes nothing without it", async () => { + await seedPackage("opencode-architect"); + + try { + await clearCache({ all: true }); + throw new Error("should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ClearCacheUsageError); + } + expect(existsSync(path.join(cacheRoot(), "opencode-architect"))).toBe(true); + }); +}); + +describe("clearCache argument validation", () => { + test("--package together with --all exits with a usage error", async () => { + try { + await clearCache({ packageName: "some-pkg", all: true, yes: true }); + throw new Error("should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ClearCacheUsageError); + expect((error as Error).message).toContain("mutually exclusive"); + } + }); +}); + +describe("clearCache failure tolerance", () => { + test("warns and continues when a removal fails", async () => { + const kept = await seedPackage("opencode-architect"); + const removed = await seedPackage("opencode-architect@0.1.0"); + await chmod(kept, 0o500); + + try { + const outcome = await clearCache(); + + expect(outcome.removed).toContain(removed); + expect(existsSync(removed)).toBe(false); + expect(outcome.warnings.length).toBe(1); + expect(outcome.warnings[0]).toContain(kept); + } finally { + await chmod(kept, 0o700); + } + }); +}); From 6b9aa8fc6f55a8b5ca6ffd5fde65d6b38116d327 Mon Sep 17 00:00:00 2001 From: "Diego B." Date: Wed, 23 Sep 2026 05:47:58 -0400 Subject: [PATCH 11/24] feat(cli): add clear-cache subcommand with confirmation gates --- cli.ts | 40 ++++++++++++++++++ tests/cli.test.ts | 104 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+) diff --git a/cli.ts b/cli.ts index 77aa4db..1b0efdc 100644 --- a/cli.ts +++ b/cli.ts @@ -1,6 +1,7 @@ #!/usr/bin/env bun import { parseArgs } from "node:util"; import { Installer, type Scope } from "./installer"; +import { clearCache, ClearCacheUsageError } from "./cache-cleaner"; const VERSION = (JSON.parse(await Bun.file(`${import.meta.dirname}/package.json`).text()) as { version: string }).version; @@ -10,6 +11,9 @@ async function main(): Promise { scope: { type: "string", short: "s" }, mode: { type: "string", short: "m" }, force: { type: "boolean", short: "f", default: false }, + package: { type: "string" }, + all: { type: "boolean", default: false }, + yes: { type: "boolean", default: false }, help: { type: "boolean", short: "h", default: false }, version: { type: "boolean", short: "v", default: false }, }, @@ -84,6 +88,32 @@ async function main(): Promise { console.log(`${scope} scope: mode=${outcome.mode} version=${version} config=${configPath}`); break; } + case "clear-cache": { + if (positionals.length > 1) { + console.error(`Unexpected arguments for clear-cache: ${positionals.slice(1).join(" ")}`); + process.exit(1); + } + let outcome; + try { + outcome = await clearCache({ packageName: values.package, all: values.all, yes: values.yes }); + } catch (error) { + if (error instanceof ClearCacheUsageError) { + console.error(error.message); + process.exit(1); + } + throw error; + } + if (outcome.removed.length === 0) { + console.log("No cached copies found; nothing to remove."); + } else { + console.log("Removed cached copies:"); + for (const target of outcome.removed) console.log(` Removed: ${target}`); + } + for (const warning of outcome.warnings) { + console.warn(` Warning: ${warning}`); + } + break; + } default: console.error(`Unknown command: ${command}`); printHelp(); @@ -108,12 +138,19 @@ Commands: install Ensure the plugin entry and write the install manifest uninstall Remove the plugin entry, the manifest, and any residual payload status Show install mode, version, and the config file holding the entry + clear-cache Remove cached copies from OpenCode's package cache; default + targets this package only Options: -s, --scope "local" (project) or "global" (XDG/home config); default local -m, --mode "plugin" (default) or "copy"; copy is refused for this code-backed package -f, --force re-register and rewrite the manifest even when it is up to date + --package clear-cache: remove and every @* instead; + requires --yes + --all clear-cache: remove the whole OpenCode cache directory; + requires --yes + --yes clear-cache: confirm a destructive broad mode -h, --help Show this help message -v, --version Show version @@ -122,6 +159,9 @@ Examples: opencode-architect install --scope global opencode-architect uninstall opencode-architect status + opencode-architect clear-cache + opencode-architect clear-cache --package some-pkg --yes + opencode-architect clear-cache --all --yes `); } diff --git a/tests/cli.test.ts b/tests/cli.test.ts index eb63bd8..b47d918 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -113,3 +113,107 @@ describe("cli", () => { } }); }); + +describe("cli clear-cache", () => { + async function seedCache(cacheDir: string, name: string): Promise { + const dir = path.join(cacheDir, "opencode", "packages", name); + await mkdir(path.join(dir, "nested"), { recursive: true }); + await writeFile(path.join(dir, "nested", "file.txt"), "cached"); + return dir; + } + + test("default mode removes only this package's cache dirs", async () => { + const cacheDir = await mkdtemp(path.join(tmpdir(), "oa-cli-cc-")); + try { + const ours = await seedCache(cacheDir, "opencode-architect"); + await seedCache(cacheDir, "opencode-architect@latest"); + const other = await seedCache(cacheDir, "other-pkg"); + + const run = await runCli(["clear-cache"], undefined, { XDG_CACHE_HOME: cacheDir }); + + expect(run.exitCode).toBe(0); + expect(existsSync(ours)).toBe(false); + expect(existsSync(path.join(cacheDir, "opencode", "packages", "opencode-architect@latest"))).toBe(false); + expect(existsSync(other)).toBe(true); + } finally { + await rm(cacheDir, { recursive: true, force: true }); + } + }); + + test("--package removes only that package and requires --yes", async () => { + const cacheDir = await mkdtemp(path.join(tmpdir(), "oa-cli-cc-")); + try { + const target = await seedCache(cacheDir, "some-pkg"); + const ours = await seedCache(cacheDir, "opencode-architect"); + + const refused = await runCli(["clear-cache", "--package", "some-pkg"], undefined, { XDG_CACHE_HOME: cacheDir }); + expect(refused.exitCode).toBe(1); + expect(refused.stderr).toContain("--yes"); + expect(existsSync(target)).toBe(true); + + const ok = await runCli(["clear-cache", "--package", "some-pkg", "--yes"], undefined, { XDG_CACHE_HOME: cacheDir }); + expect(ok.exitCode).toBe(0); + expect(existsSync(target)).toBe(false); + expect(existsSync(ours)).toBe(true); + } finally { + await rm(cacheDir, { recursive: true, force: true }); + } + }); + + test("--all removes the whole OpenCode cache dir and requires --yes", async () => { + const cacheDir = await mkdtemp(path.join(tmpdir(), "oa-cli-cc-")); + try { + await seedCache(cacheDir, "opencode-architect"); + + const refused = await runCli(["clear-cache", "--all"], undefined, { XDG_CACHE_HOME: cacheDir }); + expect(refused.exitCode).toBe(1); + expect(existsSync(path.join(cacheDir, "opencode"))).toBe(true); + + const ok = await runCli(["clear-cache", "--all", "--yes"], undefined, { XDG_CACHE_HOME: cacheDir }); + expect(ok.exitCode).toBe(0); + expect(existsSync(path.join(cacheDir, "opencode"))).toBe(false); + } finally { + await rm(cacheDir, { recursive: true, force: true }); + } + }); + + test("--package and --all together exit 1", async () => { + const run = await runCli(["clear-cache", "--package", "x", "--all", "--yes"]); + + expect(run.exitCode).toBe(1); + expect(run.stderr).toContain("mutually exclusive"); + }); + + test("traversal-y --package values exit 1 without deleting anything", async () => { + const cacheDir = await mkdtemp(path.join(tmpdir(), "oa-cli-cc-")); + try { + const ours = await seedCache(cacheDir, "opencode-architect"); + + const run = await runCli(["clear-cache", "--package", "../escape", "--yes"], undefined, { XDG_CACHE_HOME: cacheDir }); + + expect(run.exitCode).toBe(1); + expect(existsSync(ours)).toBe(true); + } finally { + await rm(cacheDir, { recursive: true, force: true }); + } + }); + + test("running with nothing cached exits 0", async () => { + const cacheDir = await mkdtemp(path.join(tmpdir(), "oa-cli-cc-")); + try { + const run = await runCli(["clear-cache"], undefined, { XDG_CACHE_HOME: cacheDir }); + + expect(run.exitCode).toBe(0); + expect(run.stdout).toContain("nothing to remove"); + } finally { + await rm(cacheDir, { recursive: true, force: true }); + } + }); + + test("--help lists clear-cache", async () => { + const run = await runCli(["--help"]); + + expect(run.exitCode).toBe(0); + expect(run.stdout).toContain("clear-cache"); + }); +}); From f9fa5864a2c98cc441a9c8047d0abbaa0fcb8c65 Mon Sep 17 00:00:00 2001 From: "Diego B." Date: Wed, 23 Sep 2026 05:48:51 -0400 Subject: [PATCH 12/24] docs: document the clear-cache subcommand in README and CHANGELOG --- CHANGELOG.md | 1 + README.md | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93a782e..fee77a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `clear-cache` CLI subcommand (manual-only, never invoked at load): with no flags it removes `opencode-architect` and every `opencode-architect@*` copy from OpenCode's package cache (`$XDG_CACHE_HOME/opencode/packages`, falling back to `~/.cache/opencode/packages`). `--package ` removes `` and every `@*`; `--all` removes the whole OpenCode cache directory; both broad modes require `--yes`, are mutually exclusive, and unsafe package names (path separators, `..`) are rejected. Idempotent — nothing cached is a success — and removal failures warn without failing the command - `install` prunes this package's stale copies from OpenCode's package cache (`$XDG_CACHE_HOME/opencode/packages`, falling back to `~/.cache/opencode/packages`): `opencode-architect`, `opencode-architect@latest`, and `opencode-architect@` are removed on every install invocation, including no-ops, so OpenCode re-fetches the just-installed version on next start. Pinned versions and other packages' cache dirs are preserved; removal failures warn without failing the install, and cleared paths are reported - Conformance checklist items for the content-based deployment plan (ADR-0008): surgical config writes (B5), zero-write registration no-op (B6), content declaration present and consistent (E1), and binary mode enforcement (E2); E1–E2 and B5–B6 join the hard non-conformance set and the auditor's conformance review covers section E - Plugins and config references document the allowed config patterns (including global `config.json`), the repo-root `opencode.jsonc` create-default, and the surgical-writer rule diff --git a/README.md b/README.md index 0c27198..2331006 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,8 @@ Useful flags and commands: ```bash bunx opencode-architect status # show mode, version, and the config file holding the entry bunx opencode-architect uninstall # remove the plugin entry, the manifest, and any residual payload +bunx opencode-architect clear-cache # remove cached copies of this package from OpenCode's package cache +bunx opencode-architect clear-cache --all --yes # remove the whole OpenCode cache directory (destructive) bunx opencode-architect install --force # re-register and rewrite the manifest even when up to date bunx opencode-architect --help # full usage ``` @@ -51,6 +53,8 @@ Every install — including a no-op — also clears this package's stale copies **Upgrading from a copy install (pre-0.8):** if a previous version copied agents into your scope base, install detects the old manifest, removes exactly the files it lists, prints a notice, and switches the scope to plugin registration in one step. Locally modified files are tracked by hash; uninstall and migration only remove what the manifest recorded. +`clear-cache` is a manual-only command (never invoked at load time) for removing cached copies from OpenCode's package cache (`$XDG_CACHE_HOME/opencode/packages`, falling back to `~/.cache/opencode/packages`). With no flags it removes `opencode-architect` and every `opencode-architect@*` copy. `--package ` removes `` and every `@*`; `--all` removes the whole OpenCode cache directory (`~/.cache/opencode`). Both broad modes require `--yes` to confirm, are mutually exclusive, and package names containing path separators or `..` are rejected. The command is idempotent — running with nothing cached succeeds — and removal failures warn without changing the exit code. + ## What you get: ten specialist OpenCode agents Ten specialist agents, one router: From eb9e7a9cc8bdd607a5a05f906d4fc2e2b1387ed2 Mon Sep 17 00:00:00 2001 From: "Diego B." Date: Wed, 23 Sep 2026 06:07:03 -0400 Subject: [PATCH 13/24] refactor(cache): class-based CacheCleaner, dedicated usage-error file, review fixes --- README.md | 2 +- cache-cleaner.ts | 120 +++++++++++++++++------------------- clear-cache-usage-error.ts | 1 + cli.ts | 5 +- package.json | 1 + tests/cache-cleaner.test.ts | 91 +++++++++++---------------- tests/cli.test.ts | 23 +++---- tests/test-helpers.ts | 21 +++++++ 8 files changed, 128 insertions(+), 136 deletions(-) create mode 100644 clear-cache-usage-error.ts create mode 100644 tests/test-helpers.ts diff --git a/README.md b/README.md index 2331006..0db9986 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ Every install — including a no-op — also clears this package's stale copies **Upgrading from a copy install (pre-0.8):** if a previous version copied agents into your scope base, install detects the old manifest, removes exactly the files it lists, prints a notice, and switches the scope to plugin registration in one step. Locally modified files are tracked by hash; uninstall and migration only remove what the manifest recorded. -`clear-cache` is a manual-only command (never invoked at load time) for removing cached copies from OpenCode's package cache (`$XDG_CACHE_HOME/opencode/packages`, falling back to `~/.cache/opencode/packages`). With no flags it removes `opencode-architect` and every `opencode-architect@*` copy. `--package ` removes `` and every `@*`; `--all` removes the whole OpenCode cache directory (`~/.cache/opencode`). Both broad modes require `--yes` to confirm, are mutually exclusive, and package names containing path separators or `..` are rejected. The command is idempotent — running with nothing cached succeeds — and removal failures warn without changing the exit code. +`clear-cache` is a manual-only command (never invoked at load time) for removing cached copies from OpenCode's package cache (`$XDG_CACHE_HOME/opencode/packages`, falling back to `~/.cache/opencode/packages`). With no flags it removes `opencode-architect` and every `opencode-architect@*` copy. `--package ` removes `` and every `@*`; `--all` removes the whole OpenCode cache directory (`$XDG_CACHE_HOME/opencode`, falling back to `~/.cache/opencode`). Both broad modes require `--yes` to confirm, are mutually exclusive, and package names containing path separators or `..` are rejected. The command is idempotent — running with nothing cached succeeds — and removal failures warn without changing the exit code. ## What you get: ten specialist OpenCode agents diff --git a/cache-cleaner.ts b/cache-cleaner.ts index 8668172..5aea3ff 100644 --- a/cache-cleaner.ts +++ b/cache-cleaner.ts @@ -1,13 +1,14 @@ import { readdir, rm } from "node:fs/promises"; import { homedir } from "node:os"; import path from "node:path"; +import { ClearCacheUsageError } from "./clear-cache-usage-error"; export const PACKAGE_NAME = "opencode-architect"; export interface ClearCacheOptions { - packageName?: string; - all?: boolean; - yes?: boolean; + packageName: string | null; + all: boolean; + yes: boolean; } export interface ClearCacheOutcome { @@ -15,83 +16,74 @@ export interface ClearCacheOutcome { warnings: string[]; } -export class ClearCacheUsageError extends Error {} +export class CacheCleaner { + public packagesCacheRoot(): string { + const xdgCacheHome = process.env.XDG_CACHE_HOME; + if (xdgCacheHome) return path.join(xdgCacheHome, "opencode", "packages"); + return path.join(homedir(), ".cache", "opencode", "packages"); + } -export function packagesCacheRoot(): string { - const xdgCacheHome = process.env.XDG_CACHE_HOME; - if (xdgCacheHome) return path.join(xdgCacheHome, "opencode", "packages"); - return path.join(homedir(), ".cache", "opencode", "packages"); -} + public opencodeCacheRoot(): string { + return path.dirname(this.packagesCacheRoot()); + } -export function opencodeCacheRoot(): string { - return path.dirname(packagesCacheRoot()); -} + public isUnsafePackageName(name: string): boolean { + return name.includes("/") || name.includes("\\") || name.includes("..") || name === "."; + } -export function isUnsafePackageName(name: string): boolean { - return name.includes("/") || name.includes("\\") || name === ".." || name === "."; -} + public async clear(options: ClearCacheOptions): Promise { + const { packageName = null, all = false, yes = false } = options; -export async function clearCache(options: ClearCacheOptions = {}): Promise { - const { packageName, all = false, yes = false } = options; + if (packageName !== null && all) { + throw new ClearCacheUsageError("--package and --all are mutually exclusive."); + } + if (packageName !== null && !yes) { + throw new ClearCacheUsageError(`--package requires --yes to confirm deletion. Re-run with: clear-cache --package ${packageName} --yes`); + } + if (all && !yes) { + throw new ClearCacheUsageError("--all requires --yes to confirm deletion. Re-run with: clear-cache --all --yes"); + } + if (packageName !== null && this.isUnsafePackageName(packageName)) { + throw new ClearCacheUsageError(`Invalid package name: ${packageName}. Package names must not contain path separators or "..".`); + } - if (packageName !== undefined && all) { - throw new ClearCacheUsageError("--package and --all are mutually exclusive."); - } - if (packageName !== undefined && !yes) { - throw new ClearCacheUsageError(`--package requires --yes to confirm deletion. Re-run with: clear-cache --package ${packageName} --yes`); - } - if (all && !yes) { - throw new ClearCacheUsageError("--all requires --yes to confirm deletion. Re-run with: clear-cache --all --yes"); - } - if (packageName !== undefined && isUnsafePackageName(packageName)) { - throw new ClearCacheUsageError(`Invalid package name: ${packageName}. Package names must not contain path separators or "..".`); - } + const removed: string[] = []; + const warnings: string[] = []; - const removed: string[] = []; - const warnings: string[] = []; + if (all) { + await this.removeTarget(this.opencodeCacheRoot(), removed, warnings, true); + return { removed, warnings }; + } - if (all) { - const target = opencodeCacheRoot(); + const name = packageName ?? PACKAGE_NAME; + const packagesRoot = this.packagesCacheRoot(); + let entries: string[]; try { - await rm(target, { recursive: true, force: true }); - removed.push(target); + entries = await readdir(packagesRoot); } catch (error) { - warnings.push(`Could not remove ${target}: ${errorMessage(error)}`); + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return { removed, warnings }; + throw error; } - return { removed, warnings }; - } - const name = packageName ?? PACKAGE_NAME; - const packagesRoot = packagesCacheRoot(); - let entries: string[]; - try { - entries = await readdir(packagesRoot); - } catch (error) { - if (isMissingError(error)) return { removed, warnings }; - throw error; - } + const prefix = `${name}@`; + const targets = entries + .filter((entry) => entry === name || entry.startsWith(prefix)) + .sort() + .map((entry) => path.join(packagesRoot, entry)); - const prefix = `${name}@`; - const targets = entries - .filter((entry) => entry === name || entry.startsWith(prefix)) - .sort() - .map((entry) => path.join(packagesRoot, entry)); + for (const target of targets) { + await this.removeTarget(target, removed, warnings, false); + } + return { removed, warnings }; + } - for (const target of targets) { + private async removeTarget(target: string, removed: string[], warnings: string[], force: boolean): Promise { try { - await rm(target, { recursive: true }); + await rm(target, { recursive: true, force }); removed.push(target); } catch (error) { - warnings.push(`Could not clear cached package ${target}: ${errorMessage(error)}`); + const message = error instanceof Error ? error.message : String(error); + warnings.push(`Could not remove ${target}: ${message}`); } } - return { removed, warnings }; -} - -function isMissingError(error: unknown): boolean { - return (error as NodeJS.ErrnoException)?.code === "ENOENT"; -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); } diff --git a/clear-cache-usage-error.ts b/clear-cache-usage-error.ts new file mode 100644 index 0000000..719d43c --- /dev/null +++ b/clear-cache-usage-error.ts @@ -0,0 +1 @@ +export class ClearCacheUsageError extends Error {} diff --git a/cli.ts b/cli.ts index 1b0efdc..aceb156 100644 --- a/cli.ts +++ b/cli.ts @@ -1,7 +1,8 @@ #!/usr/bin/env bun import { parseArgs } from "node:util"; import { Installer, type Scope } from "./installer"; -import { clearCache, ClearCacheUsageError } from "./cache-cleaner"; +import { CacheCleaner } from "./cache-cleaner"; +import { ClearCacheUsageError } from "./clear-cache-usage-error"; const VERSION = (JSON.parse(await Bun.file(`${import.meta.dirname}/package.json`).text()) as { version: string }).version; @@ -95,7 +96,7 @@ async function main(): Promise { } let outcome; try { - outcome = await clearCache({ packageName: values.package, all: values.all, yes: values.yes }); + outcome = await new CacheCleaner().clear({ packageName: values.package ?? null, all: values.all, yes: values.yes }); } catch (error) { if (error instanceof ClearCacheUsageError) { console.error(error.message); diff --git a/package.json b/package.json index d53fd16..4651db2 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "index.ts", "agent-loader.ts", "cache-cleaner.ts", + "clear-cache-usage-error.ts", "cli.ts", "installer.ts", "plugin-config.ts", diff --git a/tests/cache-cleaner.test.ts b/tests/cache-cleaner.test.ts index 1179dc1..2e7e892 100644 --- a/tests/cache-cleaner.test.ts +++ b/tests/cache-cleaner.test.ts @@ -1,17 +1,20 @@ import { describe, expect, test, beforeEach, afterEach } from "bun:test"; import { existsSync } from "node:fs"; -import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { chmod, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import { clearCache, ClearCacheUsageError, isUnsafePackageName, packagesCacheRoot, opencodeCacheRoot } from "../cache-cleaner"; +import { CacheCleaner } from "../cache-cleaner"; +import { seedCachedPackage, expectClearCacheUsageError } from "./test-helpers"; let cacheDir = ""; let originalXdgCache: string | undefined; +let cleaner = new CacheCleaner(); beforeEach(async () => { cacheDir = await mkdtemp(path.join(tmpdir(), "oa-clear-cache-")); originalXdgCache = process.env.XDG_CACHE_HOME; process.env.XDG_CACHE_HOME = cacheDir; + cleaner = new CacheCleaner(); }); afterEach(async () => { @@ -21,24 +24,17 @@ afterEach(async () => { }); function cacheRoot(): string { - return packagesCacheRoot(); -} - -async function seedPackage(name: string): Promise { - const dir = path.join(cacheRoot(), name); - await mkdir(path.join(dir, "nested"), { recursive: true }); - await writeFile(path.join(dir, "nested", "file.txt"), "cached"); - return dir; + return cleaner.packagesCacheRoot(); } describe("clearCache default mode", () => { test("removes opencode-architect and every opencode-architect@* copy", async () => { - const plain = await seedPackage("opencode-architect"); - const latest = await seedPackage("opencode-architect@latest"); - const versioned = await seedPackage("opencode-architect@0.7.1"); - const other = await seedPackage("other-package"); + const plain = await seedCachedPackage(cacheRoot(), "opencode-architect"); + const latest = await seedCachedPackage(cacheRoot(), "opencode-architect@latest"); + const versioned = await seedCachedPackage(cacheRoot(), "opencode-architect@0.7.1"); + const other = await seedCachedPackage(cacheRoot(), "other-package"); - const outcome = await clearCache(); + const outcome = await cleaner.clear({ packageName: null, all: false, yes: false }); expect(outcome.warnings).toEqual([]); expect(outcome.removed.sort()).toEqual([plain, latest, versioned].sort()); @@ -48,15 +44,15 @@ describe("clearCache default mode", () => { expect(existsSync(other)).toBe(true); }); - test("exits successfully with nothing cached", async () => { - const outcome = await clearCache(); + test("succeeds with nothing cached", async () => { + const outcome = await cleaner.clear({ packageName: null, all: false, yes: false }); expect(outcome.removed).toEqual([]); expect(outcome.warnings).toEqual([]); }); test("succeeds when the packages directory does not exist at all", async () => { - const outcome = await clearCache(); + const outcome = await cleaner.clear({ packageName: null, all: false, yes: false }); expect(outcome.removed).toEqual([]); expect(outcome.warnings).toEqual([]); @@ -65,11 +61,11 @@ describe("clearCache default mode", () => { describe("clearCache --package mode", () => { test("removes only that package's cache dirs", async () => { - const target = await seedPackage("some-pkg"); - await seedPackage("some-pkg@1.0.0"); - const ours = await seedPackage("opencode-architect"); + const target = await seedCachedPackage(cacheRoot(), "some-pkg"); + await seedCachedPackage(cacheRoot(), "some-pkg@1.0.0"); + const ours = await seedCachedPackage(cacheRoot(), "opencode-architect"); - const outcome = await clearCache({ packageName: "some-pkg", yes: true }); + const outcome = await cleaner.clear({ packageName: "some-pkg", all: false, yes: true }); expect(outcome.warnings).toEqual([]); expect(existsSync(target)).toBe(false); @@ -78,71 +74,54 @@ describe("clearCache --package mode", () => { }); test("requires --yes and deletes nothing without it", async () => { - const dir = await seedPackage("some-pkg"); + const dir = await seedCachedPackage(cacheRoot(), "some-pkg"); - expect(() => clearCache({ packageName: "some-pkg" })).toThrow(ClearCacheUsageError); - try { - await clearCache({ packageName: "some-pkg" }); - } catch { - // expected - } + await expectClearCacheUsageError(cleaner.clear({ packageName: "some-pkg", all: false, yes: false })); expect(existsSync(dir)).toBe(true); }); test("rejects unsafe package names", async () => { - for (const name of ["../escape", "foo/bar", "foo\\bar", "..", "."]) { - expect(() => clearCache({ packageName: name, yes: true })).toThrow(ClearCacheUsageError); - expect(isUnsafePackageName(name)).toBe(true); + for (const name of ["../escape", "foo/bar", "foo\\bar", "..", "foo..bar", "."]) { + expect(cleaner.isUnsafePackageName(name)).toBe(true); + await expectClearCacheUsageError(cleaner.clear({ packageName: name, all: false, yes: true })); } }); }); describe("clearCache --all mode", () => { test("removes the entire opencode cache directory", async () => { - await seedPackage("opencode-architect"); - const unrelated = path.join(opencodeCacheRoot(), "other-tool"); - await mkdir(unrelated, { recursive: true }); - await writeFile(path.join(unrelated, "data"), "x"); + await seedCachedPackage(cacheRoot(), "opencode-architect"); + const unrelated = path.join(cleaner.opencodeCacheRoot(), "other-tool"); + await seedCachedPackage(unrelated, "data"); - const outcome = await clearCache({ all: true, yes: true }); + const outcome = await cleaner.clear({ packageName: null, all: true, yes: true }); expect(outcome.warnings).toEqual([]); - expect(existsSync(opencodeCacheRoot())).toBe(false); + expect(existsSync(cleaner.opencodeCacheRoot())).toBe(false); }); test("requires --yes and deletes nothing without it", async () => { - await seedPackage("opencode-architect"); + await seedCachedPackage(cacheRoot(), "opencode-architect"); - try { - await clearCache({ all: true }); - throw new Error("should have thrown"); - } catch (error) { - expect(error).toBeInstanceOf(ClearCacheUsageError); - } + await expectClearCacheUsageError(cleaner.clear({ packageName: null, all: true, yes: false })); expect(existsSync(path.join(cacheRoot(), "opencode-architect"))).toBe(true); }); }); describe("clearCache argument validation", () => { - test("--package together with --all exits with a usage error", async () => { - try { - await clearCache({ packageName: "some-pkg", all: true, yes: true }); - throw new Error("should have thrown"); - } catch (error) { - expect(error).toBeInstanceOf(ClearCacheUsageError); - expect((error as Error).message).toContain("mutually exclusive"); - } + test("--package together with --all rejects with a usage error", async () => { + await expectClearCacheUsageError(cleaner.clear({ packageName: "some-pkg", all: true, yes: true })); }); }); describe("clearCache failure tolerance", () => { test("warns and continues when a removal fails", async () => { - const kept = await seedPackage("opencode-architect"); - const removed = await seedPackage("opencode-architect@0.1.0"); + const kept = await seedCachedPackage(cacheRoot(), "opencode-architect"); + const removed = await seedCachedPackage(cacheRoot(), "opencode-architect@0.1.0"); await chmod(kept, 0o500); try { - const outcome = await clearCache(); + const outcome = await cleaner.clear({ packageName: null, all: false, yes: false }); expect(outcome.removed).toContain(removed); expect(existsSync(removed)).toBe(false); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index b47d918..48ecccd 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -3,6 +3,7 @@ import { existsSync } from "node:fs"; import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; +import { seedCachedPackage } from "./test-helpers"; const PACKAGE_ROOT = path.resolve(import.meta.dirname, ".."); const CLI_PATH = path.join(PACKAGE_ROOT, "cli.ts"); @@ -115,19 +116,15 @@ describe("cli", () => { }); describe("cli clear-cache", () => { - async function seedCache(cacheDir: string, name: string): Promise { - const dir = path.join(cacheDir, "opencode", "packages", name); - await mkdir(path.join(dir, "nested"), { recursive: true }); - await writeFile(path.join(dir, "nested", "file.txt"), "cached"); - return dir; - } + const packagesDir = (cacheDir: string) => path.join(cacheDir, "opencode", "packages"); + const seedCache = seedCachedPackage; test("default mode removes only this package's cache dirs", async () => { const cacheDir = await mkdtemp(path.join(tmpdir(), "oa-cli-cc-")); try { - const ours = await seedCache(cacheDir, "opencode-architect"); - await seedCache(cacheDir, "opencode-architect@latest"); - const other = await seedCache(cacheDir, "other-pkg"); + const ours = await seedCache(packagesDir(cacheDir), "opencode-architect"); + await seedCache(packagesDir(cacheDir), "opencode-architect@latest"); + const other = await seedCache(packagesDir(cacheDir), "other-pkg"); const run = await runCli(["clear-cache"], undefined, { XDG_CACHE_HOME: cacheDir }); @@ -143,8 +140,8 @@ describe("cli clear-cache", () => { test("--package removes only that package and requires --yes", async () => { const cacheDir = await mkdtemp(path.join(tmpdir(), "oa-cli-cc-")); try { - const target = await seedCache(cacheDir, "some-pkg"); - const ours = await seedCache(cacheDir, "opencode-architect"); + const target = await seedCache(packagesDir(cacheDir), "some-pkg"); + const ours = await seedCache(packagesDir(cacheDir), "opencode-architect"); const refused = await runCli(["clear-cache", "--package", "some-pkg"], undefined, { XDG_CACHE_HOME: cacheDir }); expect(refused.exitCode).toBe(1); @@ -163,7 +160,7 @@ describe("cli clear-cache", () => { test("--all removes the whole OpenCode cache dir and requires --yes", async () => { const cacheDir = await mkdtemp(path.join(tmpdir(), "oa-cli-cc-")); try { - await seedCache(cacheDir, "opencode-architect"); + await seedCache(packagesDir(cacheDir), "opencode-architect"); const refused = await runCli(["clear-cache", "--all"], undefined, { XDG_CACHE_HOME: cacheDir }); expect(refused.exitCode).toBe(1); @@ -187,7 +184,7 @@ describe("cli clear-cache", () => { test("traversal-y --package values exit 1 without deleting anything", async () => { const cacheDir = await mkdtemp(path.join(tmpdir(), "oa-cli-cc-")); try { - const ours = await seedCache(cacheDir, "opencode-architect"); + const ours = await seedCache(packagesDir(cacheDir), "opencode-architect"); const run = await runCli(["clear-cache", "--package", "../escape", "--yes"], undefined, { XDG_CACHE_HOME: cacheDir }); diff --git a/tests/test-helpers.ts b/tests/test-helpers.ts new file mode 100644 index 0000000..8fccfbf --- /dev/null +++ b/tests/test-helpers.ts @@ -0,0 +1,21 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { expect } from "bun:test"; +import { ClearCacheUsageError } from "../clear-cache-usage-error"; + +export async function seedCachedPackage(dir: string, name: string): Promise { + const target = path.join(dir, name); + await mkdir(path.join(target, "nested"), { recursive: true }); + await writeFile(path.join(target, "nested", "file.txt"), "cached"); + return target; +} + +export async function expectClearCacheUsageError(promise: Promise): Promise { + try { + await promise; + } catch (error) { + expect(error).toBeInstanceOf(ClearCacheUsageError); + return; + } + throw new Error("Expected the promise to reject with ClearCacheUsageError."); +} From 946820f7e18b0a2d8ce742c497f35c28770ca269 Mon Sep 17 00:00:00 2001 From: "Diego B." Date: Wed, 23 Sep 2026 07:35:48 -0400 Subject: [PATCH 14/24] feat(cli): add --dry-run preview flag to clear-cache --- CHANGELOG.md | 2 +- README.md | 3 +- cache-cleaner.ts | 29 ++++++++++----- cli.ts | 8 ++++- tests/cache-cleaner.test.ts | 72 +++++++++++++++++++++++++++++++------ tests/cli.test.ts | 38 ++++++++++++++++++++ 6 files changed, 131 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fee77a6..4424f37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- `clear-cache` CLI subcommand (manual-only, never invoked at load): with no flags it removes `opencode-architect` and every `opencode-architect@*` copy from OpenCode's package cache (`$XDG_CACHE_HOME/opencode/packages`, falling back to `~/.cache/opencode/packages`). `--package ` removes `` and every `@*`; `--all` removes the whole OpenCode cache directory; both broad modes require `--yes`, are mutually exclusive, and unsafe package names (path separators, `..`) are rejected. Idempotent — nothing cached is a success — and removal failures warn without failing the command +- `clear-cache` CLI subcommand (manual-only, never invoked at load): with no flags it removes `opencode-architect` and every `opencode-architect@*` copy from OpenCode's package cache (`$XDG_CACHE_HOME/opencode/packages`, falling back to `~/.cache/opencode/packages`). `--package ` removes `` and every `@*`; `--all` removes the whole OpenCode cache directory; both broad modes require `--yes`, are mutually exclusive, and unsafe package names (path separators, `..`) are rejected. `--dry-run` lists what any mode would remove without deleting. Idempotent — nothing cached is a success — and removal failures warn without failing the command - `install` prunes this package's stale copies from OpenCode's package cache (`$XDG_CACHE_HOME/opencode/packages`, falling back to `~/.cache/opencode/packages`): `opencode-architect`, `opencode-architect@latest`, and `opencode-architect@` are removed on every install invocation, including no-ops, so OpenCode re-fetches the just-installed version on next start. Pinned versions and other packages' cache dirs are preserved; removal failures warn without failing the install, and cleared paths are reported - Conformance checklist items for the content-based deployment plan (ADR-0008): surgical config writes (B5), zero-write registration no-op (B6), content declaration present and consistent (E1), and binary mode enforcement (E2); E1–E2 and B5–B6 join the hard non-conformance set and the auditor's conformance review covers section E - Plugins and config references document the allowed config patterns (including global `config.json`), the repo-root `opencode.jsonc` create-default, and the surgical-writer rule diff --git a/README.md b/README.md index 0db9986..1a795b9 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ bunx opencode-architect status # show mode, version, and the co bunx opencode-architect uninstall # remove the plugin entry, the manifest, and any residual payload bunx opencode-architect clear-cache # remove cached copies of this package from OpenCode's package cache bunx opencode-architect clear-cache --all --yes # remove the whole OpenCode cache directory (destructive) +bunx opencode-architect clear-cache --dry-run # preview what clear-cache would remove without deleting bunx opencode-architect install --force # re-register and rewrite the manifest even when up to date bunx opencode-architect --help # full usage ``` @@ -53,7 +54,7 @@ Every install — including a no-op — also clears this package's stale copies **Upgrading from a copy install (pre-0.8):** if a previous version copied agents into your scope base, install detects the old manifest, removes exactly the files it lists, prints a notice, and switches the scope to plugin registration in one step. Locally modified files are tracked by hash; uninstall and migration only remove what the manifest recorded. -`clear-cache` is a manual-only command (never invoked at load time) for removing cached copies from OpenCode's package cache (`$XDG_CACHE_HOME/opencode/packages`, falling back to `~/.cache/opencode/packages`). With no flags it removes `opencode-architect` and every `opencode-architect@*` copy. `--package ` removes `` and every `@*`; `--all` removes the whole OpenCode cache directory (`$XDG_CACHE_HOME/opencode`, falling back to `~/.cache/opencode`). Both broad modes require `--yes` to confirm, are mutually exclusive, and package names containing path separators or `..` are rejected. The command is idempotent — running with nothing cached succeeds — and removal failures warn without changing the exit code. +`clear-cache` is a manual-only command (never invoked at load time) for removing cached copies from OpenCode's package cache (`$XDG_CACHE_HOME/opencode/packages`, falling back to `~/.cache/opencode/packages`). With no flags it removes `opencode-architect` and every `opencode-architect@*` copy. `--package ` removes `` and every `@*`; `--all` removes the whole OpenCode cache directory (`$XDG_CACHE_HOME/opencode`, falling back to `~/.cache/opencode`). `--dry-run` lists what any mode would remove without deleting anything, and previews broad modes without `--yes`. Both broad modes require `--yes` to confirm, are mutually exclusive, and package names containing path separators or `..` are rejected. The command is idempotent — running with nothing cached succeeds — and removal failures warn without changing the exit code. ## What you get: ten specialist OpenCode agents diff --git a/cache-cleaner.ts b/cache-cleaner.ts index 5aea3ff..0f2d2a3 100644 --- a/cache-cleaner.ts +++ b/cache-cleaner.ts @@ -1,4 +1,4 @@ -import { readdir, rm } from "node:fs/promises"; +import { exists, readdir, rm } from "node:fs/promises"; import { homedir } from "node:os"; import path from "node:path"; import { ClearCacheUsageError } from "./clear-cache-usage-error"; @@ -9,11 +9,13 @@ export interface ClearCacheOptions { packageName: string | null; all: boolean; yes: boolean; + dryRun: boolean; } export interface ClearCacheOutcome { removed: string[]; warnings: string[]; + dryRun: boolean; } export class CacheCleaner { @@ -32,15 +34,16 @@ export class CacheCleaner { } public async clear(options: ClearCacheOptions): Promise { - const { packageName = null, all = false, yes = false } = options; + const { packageName = null, all = false, yes = false, dryRun = false } = options; + const dryRunOutcome = (removed: string[], warnings: string[]): ClearCacheOutcome => ({ removed, warnings, dryRun }); if (packageName !== null && all) { throw new ClearCacheUsageError("--package and --all are mutually exclusive."); } - if (packageName !== null && !yes) { + if (packageName !== null && !yes && !dryRun) { throw new ClearCacheUsageError(`--package requires --yes to confirm deletion. Re-run with: clear-cache --package ${packageName} --yes`); } - if (all && !yes) { + if (all && !yes && !dryRun) { throw new ClearCacheUsageError("--all requires --yes to confirm deletion. Re-run with: clear-cache --all --yes"); } if (packageName !== null && this.isUnsafePackageName(packageName)) { @@ -51,8 +54,13 @@ export class CacheCleaner { const warnings: string[] = []; if (all) { - await this.removeTarget(this.opencodeCacheRoot(), removed, warnings, true); - return { removed, warnings }; + const target = this.opencodeCacheRoot(); + if (dryRun) { + if (await exists(target)) removed.push(target); + return dryRunOutcome(removed, warnings); + } + await this.removeTarget(target, removed, warnings, true); + return { removed, warnings, dryRun }; } const name = packageName ?? PACKAGE_NAME; @@ -61,7 +69,7 @@ export class CacheCleaner { try { entries = await readdir(packagesRoot); } catch (error) { - if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return { removed, warnings }; + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return { removed, warnings, dryRun }; throw error; } @@ -71,10 +79,15 @@ export class CacheCleaner { .sort() .map((entry) => path.join(packagesRoot, entry)); + if (dryRun) { + removed.push(...targets); + return dryRunOutcome(removed, warnings); + } + for (const target of targets) { await this.removeTarget(target, removed, warnings, false); } - return { removed, warnings }; + return { removed, warnings, dryRun }; } private async removeTarget(target: string, removed: string[], warnings: string[], force: boolean): Promise { diff --git a/cli.ts b/cli.ts index aceb156..572d87b 100644 --- a/cli.ts +++ b/cli.ts @@ -15,6 +15,7 @@ async function main(): Promise { package: { type: "string" }, all: { type: "boolean", default: false }, yes: { type: "boolean", default: false }, + "dry-run": { type: "boolean", default: false }, help: { type: "boolean", short: "h", default: false }, version: { type: "boolean", short: "v", default: false }, }, @@ -96,7 +97,7 @@ async function main(): Promise { } let outcome; try { - outcome = await new CacheCleaner().clear({ packageName: values.package ?? null, all: values.all, yes: values.yes }); + outcome = await new CacheCleaner().clear({ packageName: values.package ?? null, all: values.all, yes: values.yes, dryRun: values["dry-run"] }); } catch (error) { if (error instanceof ClearCacheUsageError) { console.error(error.message); @@ -106,6 +107,9 @@ async function main(): Promise { } if (outcome.removed.length === 0) { console.log("No cached copies found; nothing to remove."); + } else if (outcome.dryRun) { + console.log("Dry run; would remove:"); + for (const target of outcome.removed) console.log(` Would remove: ${target}`); } else { console.log("Removed cached copies:"); for (const target of outcome.removed) console.log(` Removed: ${target}`); @@ -152,6 +156,7 @@ Options: --all clear-cache: remove the whole OpenCode cache directory; requires --yes --yes clear-cache: confirm a destructive broad mode + --dry-run clear-cache: list what would be removed without deleting -h, --help Show this help message -v, --version Show version @@ -163,6 +168,7 @@ Examples: opencode-architect clear-cache opencode-architect clear-cache --package some-pkg --yes opencode-architect clear-cache --all --yes + opencode-architect clear-cache --dry-run `); } diff --git a/tests/cache-cleaner.test.ts b/tests/cache-cleaner.test.ts index 2e7e892..2011957 100644 --- a/tests/cache-cleaner.test.ts +++ b/tests/cache-cleaner.test.ts @@ -34,7 +34,7 @@ describe("clearCache default mode", () => { const versioned = await seedCachedPackage(cacheRoot(), "opencode-architect@0.7.1"); const other = await seedCachedPackage(cacheRoot(), "other-package"); - const outcome = await cleaner.clear({ packageName: null, all: false, yes: false }); + const outcome = await cleaner.clear({ packageName: null, all: false, yes: false, dryRun: false }); expect(outcome.warnings).toEqual([]); expect(outcome.removed.sort()).toEqual([plain, latest, versioned].sort()); @@ -45,14 +45,14 @@ describe("clearCache default mode", () => { }); test("succeeds with nothing cached", async () => { - const outcome = await cleaner.clear({ packageName: null, all: false, yes: false }); + const outcome = await cleaner.clear({ packageName: null, all: false, yes: false, dryRun: false }); expect(outcome.removed).toEqual([]); expect(outcome.warnings).toEqual([]); }); test("succeeds when the packages directory does not exist at all", async () => { - const outcome = await cleaner.clear({ packageName: null, all: false, yes: false }); + const outcome = await cleaner.clear({ packageName: null, all: false, yes: false, dryRun: false }); expect(outcome.removed).toEqual([]); expect(outcome.warnings).toEqual([]); @@ -65,7 +65,7 @@ describe("clearCache --package mode", () => { await seedCachedPackage(cacheRoot(), "some-pkg@1.0.0"); const ours = await seedCachedPackage(cacheRoot(), "opencode-architect"); - const outcome = await cleaner.clear({ packageName: "some-pkg", all: false, yes: true }); + const outcome = await cleaner.clear({ packageName: "some-pkg", all: false, yes: true, dryRun: false }); expect(outcome.warnings).toEqual([]); expect(existsSync(target)).toBe(false); @@ -76,14 +76,14 @@ describe("clearCache --package mode", () => { test("requires --yes and deletes nothing without it", async () => { const dir = await seedCachedPackage(cacheRoot(), "some-pkg"); - await expectClearCacheUsageError(cleaner.clear({ packageName: "some-pkg", all: false, yes: false })); + await expectClearCacheUsageError(cleaner.clear({ packageName: "some-pkg", all: false, yes: false, dryRun: false })); expect(existsSync(dir)).toBe(true); }); test("rejects unsafe package names", async () => { for (const name of ["../escape", "foo/bar", "foo\\bar", "..", "foo..bar", "."]) { expect(cleaner.isUnsafePackageName(name)).toBe(true); - await expectClearCacheUsageError(cleaner.clear({ packageName: name, all: false, yes: true })); + await expectClearCacheUsageError(cleaner.clear({ packageName: name, all: false, yes: true, dryRun: false })); } }); }); @@ -94,7 +94,7 @@ describe("clearCache --all mode", () => { const unrelated = path.join(cleaner.opencodeCacheRoot(), "other-tool"); await seedCachedPackage(unrelated, "data"); - const outcome = await cleaner.clear({ packageName: null, all: true, yes: true }); + const outcome = await cleaner.clear({ packageName: null, all: true, yes: true, dryRun: false }); expect(outcome.warnings).toEqual([]); expect(existsSync(cleaner.opencodeCacheRoot())).toBe(false); @@ -103,14 +103,66 @@ describe("clearCache --all mode", () => { test("requires --yes and deletes nothing without it", async () => { await seedCachedPackage(cacheRoot(), "opencode-architect"); - await expectClearCacheUsageError(cleaner.clear({ packageName: null, all: true, yes: false })); + await expectClearCacheUsageError(cleaner.clear({ packageName: null, all: true, yes: false, dryRun: false })); expect(existsSync(path.join(cacheRoot(), "opencode-architect"))).toBe(true); }); }); describe("clearCache argument validation", () => { test("--package together with --all rejects with a usage error", async () => { - await expectClearCacheUsageError(cleaner.clear({ packageName: "some-pkg", all: true, yes: true })); + await expectClearCacheUsageError(cleaner.clear({ packageName: "some-pkg", all: true, yes: true, dryRun: false })); + }); +}); + +describe("clearCache dry-run", () => { + test("default mode lists this package's copies without deleting", async () => { + const plain = await seedCachedPackage(cacheRoot(), "opencode-architect"); + const latest = await seedCachedPackage(cacheRoot(), "opencode-architect@latest"); + const other = await seedCachedPackage(cacheRoot(), "other-package"); + + const outcome = await cleaner.clear({ packageName: null, all: false, yes: false, dryRun: true }); + + expect(outcome.dryRun).toBe(true); + expect(outcome.warnings).toEqual([]); + expect(outcome.removed.sort()).toEqual([plain, latest].sort()); + expect(existsSync(plain)).toBe(true); + expect(existsSync(latest)).toBe(true); + expect(existsSync(other)).toBe(true); + }); + + test("--package lists only that package's copies without deleting", async () => { + const target = await seedCachedPackage(cacheRoot(), "some-pkg"); + await seedCachedPackage(cacheRoot(), "some-pkg@1.0.0"); + const ours = await seedCachedPackage(cacheRoot(), "opencode-architect"); + + const outcome = await cleaner.clear({ packageName: "some-pkg", all: false, yes: false, dryRun: true }); + + expect(outcome.dryRun).toBe(true); + expect(existsSync(target)).toBe(true); + expect(existsSync(path.join(cacheRoot(), "some-pkg@1.0.0"))).toBe(true); + expect(existsSync(ours)).toBe(true); + }); + + test("--all lists the whole opencode cache dir without deleting", async () => { + await seedCachedPackage(cacheRoot(), "opencode-architect"); + + const outcome = await cleaner.clear({ packageName: null, all: true, yes: false, dryRun: true }); + + expect(outcome.dryRun).toBe(true); + expect(outcome.removed).toEqual([cleaner.opencodeCacheRoot()]); + expect(existsSync(cleaner.opencodeCacheRoot())).toBe(true); + }); + + test("with nothing cached lists nothing and succeeds", async () => { + const outcome = await cleaner.clear({ packageName: null, all: false, yes: false, dryRun: true }); + + expect(outcome.removed).toEqual([]); + expect(outcome.warnings).toEqual([]); + }); + + test("still rejects mutual exclusion and unsafe names", async () => { + await expectClearCacheUsageError(cleaner.clear({ packageName: "some-pkg", all: true, yes: false, dryRun: true })); + await expectClearCacheUsageError(cleaner.clear({ packageName: "../escape", all: false, yes: false, dryRun: true })); }); }); @@ -121,7 +173,7 @@ describe("clearCache failure tolerance", () => { await chmod(kept, 0o500); try { - const outcome = await cleaner.clear({ packageName: null, all: false, yes: false }); + const outcome = await cleaner.clear({ packageName: null, all: false, yes: false, dryRun: false }); expect(outcome.removed).toContain(removed); expect(existsSync(removed)).toBe(false); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 48ecccd..fd77c11 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -195,6 +195,44 @@ describe("cli clear-cache", () => { } }); + test("dry-run lists what would be removed without deleting, in every mode", async () => { + const cacheDir = await mkdtemp(path.join(tmpdir(), "oa-cli-cc-")); + try { + const ours = await seedCache(packagesDir(cacheDir), "opencode-architect"); + const other = await seedCache(packagesDir(cacheDir), "some-pkg"); + + const defaultRun = await runCli(["clear-cache", "--dry-run"], undefined, { XDG_CACHE_HOME: cacheDir }); + expect(defaultRun.exitCode).toBe(0); + expect(defaultRun.stdout).toContain("Would remove"); + expect(defaultRun.stdout).toContain(ours); + expect(defaultRun.stdout).not.toContain(other); + expect(existsSync(ours)).toBe(true); + expect(existsSync(other)).toBe(true); + + const packageRun = await runCli(["clear-cache", "--package", "some-pkg", "--dry-run"], undefined, { XDG_CACHE_HOME: cacheDir }); + expect(packageRun.exitCode).toBe(0); + expect(packageRun.stdout).toContain(other); + expect(packageRun.stdout).not.toContain(ours); + expect(existsSync(other)).toBe(true); + + const allRun = await runCli(["clear-cache", "--all", "--dry-run"], undefined, { XDG_CACHE_HOME: cacheDir }); + expect(allRun.exitCode).toBe(0); + expect(allRun.stdout).toContain(path.join(cacheDir, "opencode")); + expect(existsSync(path.join(cacheDir, "opencode"))).toBe(true); + + const emptyDir = await mkdtemp(path.join(tmpdir(), "oa-cli-cc-")); + try { + const emptyRun = await runCli(["clear-cache", "--dry-run"], undefined, { XDG_CACHE_HOME: emptyDir }); + expect(emptyRun.exitCode).toBe(0); + expect(emptyRun.stdout).toContain("nothing to remove"); + } finally { + await rm(emptyDir, { recursive: true, force: true }); + } + } finally { + await rm(cacheDir, { recursive: true, force: true }); + } + }); + test("running with nothing cached exits 0", async () => { const cacheDir = await mkdtemp(path.join(tmpdir(), "oa-cli-cc-")); try { From b50d4a439d57a00fd1b8a8d28de63c797f6eb038 Mon Sep 17 00:00:00 2001 From: "Diego B." Date: Wed, 23 Sep 2026 08:10:27 -0400 Subject: [PATCH 15/24] chore: add start script running bun test --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 4651db2..d06f4f0 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "email": "support@expertvision.software" }, "scripts": { + "start": "bun test", "check": "tsc --noEmit", "test": "bun test", "prepublishOnly": "npm run check && npm test", From 18c258c6357c2fb53075bb8b4997cf86e21bdcc9 Mon Sep 17 00:00:00 2001 From: "Diego B." Date: Thu, 1 Oct 2026 06:28:41 -0400 Subject: [PATCH 16/24] feat(templates): installer template prunes self cache copies on install --- assets/templates/installer.template.txt | 74 ++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 3 deletions(-) diff --git a/assets/templates/installer.template.txt b/assets/templates/installer.template.txt index c88473f..3803b89 100644 --- a/assets/templates/installer.template.txt +++ b/assets/templates/installer.template.txt @@ -7,7 +7,7 @@ | `COMMAND NAME` → "my-command.md" | Yes | Command file name in assets/commands/ | — | | `PACKAGE NAME` → "opencode-myextension" | Yes | npm package name; also the manifest file base | — | -**Load-bearing — do not simplify:** the deployment plan is content-based (ADR-0008): the content declaration (`"content"` in package.json, `"assets"` or `"code"`) decides the install modes — assets-only packages copy-install by default with `--mode plugin` as the opt-in, code-backed packages always plugin-register and `--mode copy` throws `CopyModeUnsupportedError`; copy install touches no config file at all — no permission blocks, no MCP entries, no plugin array edits; the `plugin` array is edited only by the surgical `PluginConfigEditor` (text splice, every other byte untouched, never a parse-then-reserialize, never rewrite config from `{}`, existing entry is a zero-write no-op); manifest-gated idempotency (never `.version` markers), the plugin-mode zero-write no-op includes a present entry (version match alone is not enough), and payload idempotency requires a recorded, hash-matching payload — a plugin-mode manifest with an empty file map means the load-time hook has not ensured assets yet, so the ensure path runs and records the payload hashes while preserving the recorded mode, entry, and target config file; config reading consults both `opencode.json` and `opencode.jsonc` per base, with `.jsonc` parsed leniently and `.json` strict, and every unparseable candidate is warned about; bundled assets resolve through `ASSET_LAYOUT_DIR` (packages shipping repo-root `skills/` instead of `assets/` set it to `"."`), and a missing or empty asset source is a hard error naming the path, package name+version, cache dir, and install command — never a silent skip, and a manifest is never written when zero files were written; the manifest hashes the whole payload relative to the config base (skills and commands), and `migrateRootConfig` deletes nothing without a consent callback and is never called from `install()`. Every function here is scope-parameterized: the exact same logic serves the local scope base (`/.opencode/`) and the global scope base (`~/.config/opencode/` or `$XDG_CONFIG_HOME/opencode/`), so a global install and a project install differ only in the base path — never in behavior. +**Load-bearing — do not simplify:** the deployment plan is content-based (ADR-0008): the content declaration (`"content"` in package.json, `"assets"` or `"code"`) decides the install modes — assets-only packages copy-install by default with `--mode plugin` as the opt-in, code-backed packages always plugin-register and `--mode copy` throws `CopyModeUnsupportedError`; copy install touches no config file at all — no permission blocks, no MCP entries, no plugin array edits; the `plugin` array is edited only by the surgical `PluginConfigEditor` (text splice, every other byte untouched, never a parse-then-reserialize, never rewrite config from `{}`, existing entry is a zero-write no-op); manifest-gated idempotency (never `.version` markers), the plugin-mode zero-write no-op includes a present entry (version match alone is not enough), and payload idempotency requires a recorded, hash-matching payload — a plugin-mode manifest with an empty file map means the load-time hook has not ensured assets yet, so the ensure path runs and records the payload hashes while preserving the recorded mode, entry, and target config file; config reading consults both `opencode.json` and `opencode.jsonc` per base, with `.jsonc` parsed leniently and `.json` strict, and every unparseable candidate is warned about; bundled assets resolve through `ASSET_LAYOUT_DIR` (packages shipping repo-root `skills/` instead of `assets/` set it to `"."`), and a missing or empty asset source is a hard error naming the path, package name+version, cache dir, and install command — never a silent skip, and a manifest is never written when zero files were written; the manifest hashes the whole payload relative to the config base (skills and commands), and `migrateRootConfig` deletes nothing without a consent callback and is never called from `install()`. Cache hygiene is self-scoped (ADR-0007): every install — including a no-op — prunes this package's own cache copies (``, `@latest`, `@`) from OpenCode's package cache (`$XDG_CACHE_HOME/opencode/packages`, falling back to `~/.cache/opencode/packages`), best-effort warn-and-continue, never touching other packages' cache dirs or pinned versions, and the load-time hook never prunes — only the CLI's install and `clear-cache` paths do. Every function here is scope-parameterized: the exact same logic serves the local scope base (`/.opencode/`) and the global scope base (`~/.config/opencode/` or `$XDG_CONFIG_HOME/opencode/`), so a global install and a project install differ only in the base path — never in behavior. --- import { exists, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; @@ -36,6 +36,7 @@ export interface InstallResult { entryConfigPath: string | null; skippedModified: string[]; upToDate: boolean; + clearedCache: string[]; } export interface UninstallResult { @@ -113,6 +114,69 @@ function getPackageDir(): string { return join(import.meta.dirname, ".."); } +function packageCacheRoot(): string { + const xdgCacheHome = process.env.XDG_CACHE_HOME; + if (xdgCacheHome) return join(xdgCacheHome, "opencode", "packages"); + return join(homedir(), ".cache", "opencode", "packages"); +} + +export async function prunePackageCache(): Promise<{ removed: string[]; warnings: string[] }> { + const removed: string[] = []; + const warnings: string[] = []; + const packagesRoot = packageCacheRoot(); + const version = await getPackageVersion(); + const wanted = [PLUGIN_NAME, `${PLUGIN_NAME}@latest`, `${PLUGIN_NAME}@${version}`]; + let entries: string[]; + try { + entries = await readdir(packagesRoot); + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return { removed, warnings }; + throw error; + } + const targets = entries + .filter((name) => wanted.includes(name)) + .sort() + .map((name) => join(packagesRoot, name)); + for (const target of targets) { + try { + await rm(target, { recursive: true }); + removed.push(target); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + warnings.push(`Could not clear cached package ${target}: ${message}`); + } + } + return { removed, warnings }; +} + +export async function clearPackageCache(): Promise<{ removed: string[]; warnings: string[] }> { + const removed: string[] = []; + const warnings: string[] = []; + const packagesRoot = packageCacheRoot(); + let entries: string[]; + try { + entries = await readdir(packagesRoot); + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return { removed, warnings }; + throw error; + } + const prefix = `${PLUGIN_NAME}@`; + const targets = entries + .filter((name) => name === PLUGIN_NAME || name.startsWith(prefix)) + .sort() + .map((name) => join(packagesRoot, name)); + for (const target of targets) { + try { + await rm(target, { recursive: true }); + removed.push(target); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + warnings.push(`Could not clear cached package ${target}: ${message}`); + } + } + return { removed, warnings }; +} + export function getGlobalConfigPath(): string { const xdgConfig = process.env.XDG_CONFIG_HOME; if (xdgConfig) { @@ -287,12 +351,16 @@ export async function install( const mode = await resolveMode(requested); const configBase = scopeBase(scope, projectDir); const { skillPath, commandPath } = await payloadPaths(configBase); + const cache = await prunePackageCache(); + for (const warning of cache.warnings) { + console.warn(`Warning: ${warning}`); + } if (mode === "copy") { const { upToDate, skippedModified } = await ensurePayload(scope, projectDir, force); - return { scope, mode, skillPath, commandPath, entry: null, entryConfigPath: null, skippedModified, upToDate }; + return { scope, mode, skillPath, commandPath, entry: null, entryConfigPath: null, skippedModified, upToDate, clearedCache: cache.removed }; } const { upToDate, entry, entryConfigPath } = await registerPlugin(scope, projectDir); - return { scope, mode, skillPath, commandPath, entry, entryConfigPath, skippedModified: [], upToDate }; + return { scope, mode, skillPath, commandPath, entry, entryConfigPath, skippedModified: [], upToDate, clearedCache: cache.removed }; } export async function uninstall(scope: Scope, projectDir: string = process.cwd()): Promise { From 71dfa99be192359f9cfb76b1ec0e50377b8fdaf2 Mon Sep 17 00:00:00 2001 From: "Diego B." Date: Thu, 1 Oct 2026 06:29:43 -0400 Subject: [PATCH 17/24] feat(templates): generated CLI gains a self-only clear-cache subcommand --- assets/templates/cli.template.txt | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/assets/templates/cli.template.txt b/assets/templates/cli.template.txt index 98b1772..14ddde8 100644 --- a/assets/templates/cli.template.txt +++ b/assets/templates/cli.template.txt @@ -4,15 +4,15 @@ |---|---|---|---| | `CLI NAME` → "opencode-myextension" | Yes | CLI command name; matches package.json bin field | — | | `VERSION` → "1.0.0" | Yes | Initial version | `1.0.0` | -| `COMMANDS` | Yes | install / uninstall / status / migrate subcommands | — | +| `COMMANDS` | Yes | install / uninstall / status / migrate / clear-cache subcommands | — | | `SCOPE` → "local" | Yes | Default scope when `--scope` is not specified | `local` | -**Load-bearing — do not simplify:** the deployment plan is content-based, so `install` resolves the mode through `resolveMode` and never forces one: assets-only packages default to copy and accept `--mode plugin`; code-backed packages always register and `--mode copy` surfaces the `CopyModeUnsupportedError` as a hard, explanatory exit-1 error; `install` forwards `force` so consumer-modified files are skipped without it; `status` reports mode, version, entry, entry config file, and registration presence per scope; `migrate` refuses to run without `--force` (root-config deletion is consent-gated and CLI-only); the module lives at `src/cli.ts` and imports `./installer.ts`. +**Load-bearing — do not simplify:** the deployment plan is content-based, so `install` resolves the mode through `resolveMode` and never forces one: assets-only packages default to copy and accept `--mode plugin`; code-backed packages always register and `--mode copy` surfaces the `CopyModeUnsupportedError` as a hard, explanatory exit-1 error; `install` forwards `force` so consumer-modified files are skipped without it; `status` reports mode, version, entry, entry config file, and registration presence per scope; `migrate` refuses to run without `--force` (root-config deletion is consent-gated and CLI-only); `clear-cache` is self-only — it removes this package's own cache copies (`` and every `@*`) from OpenCode's package cache, is idempotent (nothing cached is a success), warns and continues on per-target removal failures, and must never grow `--package` or `--all` modes (other packages' cache and the shared OpenCode cache dir are out of scope for a generated package); the module lives at `src/cli.ts` and imports `./installer.ts`. --- #!/usr/bin/env bun import { parseArgs } from "node:util"; -import { install, uninstall, status, migrateRootConfig, CopyModeUnsupportedError, type Scope, type InstallMode } from "./installer.ts"; +import { install, uninstall, status, migrateRootConfig, clearPackageCache, CopyModeUnsupportedError, type Scope, type InstallMode } from "./installer.ts"; const VERSION = JSON.parse( await Bun.file(`${import.meta.dirname}/../package.json`).text() @@ -27,6 +27,7 @@ Commands: uninstall Remove myextension (payload files and/or plugin registration, per manifest) status Check installation status per scope migrate Migrate root opencode.json into .opencode/ (asks for --force consent) + clear-cache Remove cached copies of this package from OpenCode's package cache Options: -s, --scope Installation scope: "local" or "global" (default: local) @@ -40,9 +41,10 @@ Examples: opencode-myextension install --scope global opencode-myextension install --mode plugin opencode-myextension uninstall --scope local - opencode-myextension status - opencode-myextension migrate --force -`); + opencode-myextension status + opencode-myextension migrate --force + opencode-myextension clear-cache + `); } function isInstallMode(value: string): value is InstallMode { @@ -128,6 +130,23 @@ async function main(): Promise { console.log(migrated ? "Migrated opencode.json into .opencode/opencode.json" : "Nothing to migrate"); break; } + case "clear-cache": { + if (positionals.length > 1) { + console.error(`Unexpected arguments for clear-cache: ${positionals.slice(1).join(" ")}`); + process.exit(1); + } + const outcome = await clearPackageCache(); + if (outcome.removed.length === 0) { + console.log("No cached copies found; nothing to remove."); + } else { + console.log("Removed cached copies:"); + for (const target of outcome.removed) console.log(` Removed: ${target}`); + } + for (const warning of outcome.warnings) { + console.warn(` Warning: ${warning}`); + } + break; + } default: console.error(`Unknown command: ${command}`); printHelp(); process.exit(1); } } catch (error) { From dcbdbc46e249bb73aeebe5b743081d78f013b1b9 Mon Sep 17 00:00:00 2001 From: "Diego B." Date: Thu, 1 Oct 2026 06:30:05 -0400 Subject: [PATCH 18/24] feat(templates): load-time advisory suggests bunx clear-cache --- assets/templates/plugin-local.template.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/assets/templates/plugin-local.template.txt b/assets/templates/plugin-local.template.txt index 56571c4..f6bbe30 100644 --- a/assets/templates/plugin-local.template.txt +++ b/assets/templates/plugin-local.template.txt @@ -7,7 +7,7 @@ | `COMMAND NAME` → "my-command.md" | Yes | Command file name in assets/commands/ | — | | `PACKAGE NAME` → "opencode-myextension" | Yes | npm package name; must match package.json | — | -**Load-bearing — do not simplify:** scope detection is read-only config inspection (both `opencode.json` and `opencode.jsonc`, global and project, plus global `config.json` via the detector), never launch-directory checks and never any directory-identity comparison (`import.meta.dirname`, `process.cwd()`, realpath) — a maintainer's own checkout is served by that repo's own config registration; the hook calls only `ensureAssets` — it never resolves an install mode, never edits `plugin` arrays, permission, or any config file, and writes stay inside the detected registration scope(s) only; assets-only and code-backed packages share this hook unchanged: assets are ensured in registered scopes either way, and code-backed assets resolve from the package at load; the **entire hook body** (detection, ensures, advisory) is wrapped in try/catch so a failure degrades to a warning and OpenCode still launches — hooks must never throw; the failure advisory names both the remediation command (`bunx install --scope global`) and the package-qualified cache dir (`~/.cache/opencode/packages/@`), is built inside its own try/catch with a static fallback, and each emitter swallows independently; the failure advisory and the not-installed advisory carry **separate** once-guards so neither suppresses the other; the zero-write no-op covers a fully matching manifest **and** a present plugin entry (an up-to-date manifest alone is not enough for plugin installs), so a healthy startup performs no writes at all; the hook never deletes the cache — it instructs only. +**Load-bearing — do not simplify:** scope detection is read-only config inspection (both `opencode.json` and `opencode.jsonc`, global and project, plus global `config.json` via the detector), never launch-directory checks and never any directory-identity comparison (`import.meta.dirname`, `process.cwd()`, realpath) — a maintainer's own checkout is served by that repo's own config registration; the hook calls only `ensureAssets` — it never resolves an install mode, never edits `plugin` arrays, permission, or any config file, and writes stay inside the detected registration scope(s) only; assets-only and code-backed packages share this hook unchanged: assets are ensured in registered scopes either way, and code-backed assets resolve from the package at load; the **entire hook body** (detection, ensures, advisory) is wrapped in try/catch so a failure degrades to a warning and OpenCode still launches — hooks must never throw; the failure advisory names both the remediation command (`bunx install --scope global`) and the package-qualified cache dir (`~/.cache/opencode/packages/@`), tells the consumer to run `bunx clear-cache` for a partial cache artifact instead of describing manual removal, is built inside its own try/catch with a static fallback, and each emitter swallows independently; the failure advisory and the not-installed advisory carry **separate** once-guards so neither suppresses the other; the zero-write no-op covers a fully matching manifest **and** a present plugin entry (an up-to-date manifest alone is not enough for plugin installs), so a healthy startup performs no writes at all; the hook never deletes the cache — it instructs only. --- import type { Plugin } from "@opencode-ai/plugin"; @@ -36,9 +36,9 @@ async function adviseFailureOnce(message: string): Promise { try { version = JSON.parse(await Bun.file(`${import.meta.dirname}/../package.json`).text()).version ?? version; } catch {} - text = `MyExtension load-time install failed. Clear ~/.cache/opencode/packages/${PACKAGE_NAME}@${version} and run: bunx ${PACKAGE_NAME} install --scope global. Cause: ${message}`; + text = `MyExtension load-time install failed. Run: bunx ${PACKAGE_NAME} clear-cache, then: bunx ${PACKAGE_NAME} install --scope global. The stale cache copy is ~/.cache/opencode/packages/${PACKAGE_NAME}@${version}. Cause: ${message}`; } catch { - text = `MyExtension load-time install failed. Clear the package cache under ~/.cache/opencode/packages/ and run: bunx ${PACKAGE_NAME} install --scope global`; + text = `MyExtension load-time install failed. Run: bunx ${PACKAGE_NAME} clear-cache, then: bunx ${PACKAGE_NAME} install --scope global (the stale copy lives under ~/.cache/opencode/packages/)`; } try { console.warn(`[${PACKAGE_NAME}] ${text}`); From 1f8f43a3ce8558443f6d0c86c98626ea5a77b213 Mon Sep 17 00:00:00 2001 From: "Diego B." Date: Thu, 1 Oct 2026 06:31:28 -0400 Subject: [PATCH 19/24] docs(templates): A6 cache-hygiene checklist item; packager/publisher carry it --- assets/agents/opencode-packager.md | 2 +- assets/agents/opencode-publisher.md | 4 ++-- assets/references/conformance-checklist.md | 17 +++++++++++++++++ 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/assets/agents/opencode-packager.md b/assets/agents/opencode-packager.md index d55a42c..fd98805 100644 --- a/assets/agents/opencode-packager.md +++ b/assets/agents/opencode-packager.md @@ -39,7 +39,7 @@ opencode-myextension/ └── tsconfig.json ``` -6. **Create plugin.ts** from `../templates/plugin-local.template.txt`, plus `src/plugin-name.ts` from `../templates/plugin-name.template.txt`, `src/manifest.ts` from `../templates/manifest.template.txt`, `src/registration.ts` from `../templates/registration.template.txt`, and `src/plugin-config.ts` from `../templates/plugin-config.template.txt`: the load hook performs read-only registration-scope detection, then ensures skills, commands, and agents only for the scopes where the plugin is registered, gated by the per-scope install manifest (version, mode, registration, per-file sha256). Never write outside the detected scopes, never edit `plugin` arrays or root configs at load, and never rewrite a config that fails to parse. The install logic is content-based (ADR-0008): the same installer serves both the global scope base and the project scope base with identical behavior — copy install touches no config, plugin registration goes through the surgical editor, and code-backed packages refuse `--mode copy`. +6. **Create plugin.ts** from `../templates/plugin-local.template.txt`, plus `src/plugin-name.ts` from `../templates/plugin-name.template.txt`, `src/manifest.ts` from `../templates/manifest.template.txt`, `src/registration.ts` from `../templates/registration.template.txt`, and `src/plugin-config.ts` from `../templates/plugin-config.template.txt`: the load hook performs read-only registration-scope detection, then ensures skills, commands, and agents only for the scopes where the plugin is registered, gated by the per-scope install manifest (version, mode, registration, per-file sha256). Never write outside the detected scopes, never edit `plugin` arrays or root configs at load, and never rewrite a config that fails to parse. The install logic is content-based (ADR-0008): the same installer serves both the global scope base and the project scope base with identical behavior — copy install touches no config, plugin registration goes through the surgical editor, and code-backed packages refuse `--mode copy`. Cache hygiene is self-scoped (checklist A6): the installer template prunes this package's own cache copies on every install (warn-and-continue, other packages untouched), and the hook's failure advisory tells consumers to run `bunx clear-cache` — the hook itself never deletes cache entries. 7. **Create package.json** from `../templates/package-basics.template.json` and tsconfig.json from `../templates/tsconfig.template.json`. The template ships with `"content": "assets"`; the asset inventory is the source of truth for the declaration (ADR-0008): keep `"assets"` only when the package ships skills and/or commands alone; set `"code"` when the source contained any agent, tool, plugin, hook, or other plugin integration — code-backed packages may also ship skills and commands. Report the decision: "Content declaration: code (1 agent, 2 tools found)" or "Content declaration: assets (skills and commands only)". diff --git a/assets/agents/opencode-publisher.md b/assets/agents/opencode-publisher.md index e7c1ecf..3552795 100644 --- a/assets/agents/opencode-publisher.md +++ b/assets/agents/opencode-publisher.md @@ -19,9 +19,9 @@ You are an OpenCode extension publisher: you transform locally-packaged extensio 1. **Verify the incoming package.** Confirm the packager's structure exists: a bundled asset directory (`assets/skills/` etc., or repo-root `skills//`), `plugin.ts` with inline install logic, minimal `package.json` with a `content` declaration (`assets` or `code`), `tsconfig.json`. Read the packager summary for extension name, description, included assets, dependencies, warnings, and the content declaration. Confirm every asset landed in the package and custom plugins or tools got their merge decisions. Cross-check the declaration against the bundled assets: `assets` requires skills/commands only — any agent, tool, or plugin file in the package contradicts it and returns to the orchestrator for repackaging; `code` is valid for any inventory. An invalid structure returns to the orchestrator for repackaging. -2. **Extract install logic to src/installer.ts.** Move install(), uninstall(), status(), scope detection, path resolution, and config management out of plugin.ts, keeping the manifest module (src/manifest.ts), plugin-name normalizer (src/plugin-name.ts), registration detector (src/registration.ts), and surgical config editor (src/plugin-config.ts) as separate files; update plugin.ts to call install() from src/installer.ts. Preserve the content-based deployment plan (ADR-0008): the installer reads the `content` declaration — assets-only packages copy-install by default (copy touches no config file) with `--mode plugin` as the opt-in; code-backed packages always register and `--mode copy` is a hard error. Preserve the invariants: manifest-gated idempotency (no `.version` markers; the plugin-mode no-op includes a present entry), semantic `@latest` plugin dedup written canonically as `name@latest` via the surgical editor only, abort-with-warning on unparseable config (never rewrite from `{}`), skip consumer-modified files unless `--force`, and root-config migration CLI-only behind explicit consent. +2. **Extract install logic to src/installer.ts.** Move install(), uninstall(), status(), scope detection, path resolution, and config management out of plugin.ts, keeping the manifest module (src/manifest.ts), plugin-name normalizer (src/plugin-name.ts), registration detector (src/registration.ts), and surgical config editor (src/plugin-config.ts) as separate files; update plugin.ts to call install() from src/installer.ts. Preserve the content-based deployment plan (ADR-0008): the installer reads the `content` declaration — assets-only packages copy-install by default (copy touches no config file) with `--mode plugin` as the opt-in; code-backed packages always register and `--mode copy` is a hard error. Preserve the invariants: manifest-gated idempotency (no `.version` markers; the plugin-mode no-op includes a present entry), semantic `@latest` plugin dedup written canonically as `name@latest` via the surgical editor only, abort-with-warning on unparseable config (never rewrite from `{}`), skip consumer-modified files unless `--force`, and root-config migration CLI-only behind explicit consent. Preserve the self-scoped cache hygiene (checklist A6): install() prunes the package's own cache copies (``, `@latest`, `@`) on every invocation — including no-ops — best-effort with warn-and-continue, and clearPackageCache() backs the CLI's `clear-cache` subcommand. -3. **Create the CLI entry point.** Build src/cli.ts from `../templates/cli.template.txt`: install command calls install(scope, projectDir, { mode, force }) — it resolves the mode from the content declaration and surfaces `CopyModeUnsupportedError` as an explanatory exit-1 error — uninstall calls uninstall(scope, projectDir), status calls status(projectDir) and reports mode, version, entry, and the target config file per scope, migrate calls migrateRootConfig only behind `--force` consent. +3. **Create the CLI entry point.** Build src/cli.ts from `../templates/cli.template.txt`: install command calls install(scope, projectDir, { mode, force }) — it resolves the mode from the content declaration and surfaces `CopyModeUnsupportedError` as an explanatory exit-1 error — uninstall calls uninstall(scope, projectDir), status calls status(projectDir) and reports mode, version, entry, and the target config file per scope, migrate calls migrateRootConfig only behind `--force` consent, and clear-cache calls clearPackageCache() — strictly self-only (this package's own cache copies, warn-and-continue, idempotent); it must not offer `--package` or `--all` modes, which exist only in the suite's own CLI. 4. **Expand package.json** from `../templates/package-full.template.json`: bin field for the CLI, scripts (check, test), expanded dependencies, npm fields (repository, bugs, license, author). Carry the packager's `content` declaration through unchanged — expansion adds npm fields, never alters the declaration. diff --git a/assets/references/conformance-checklist.md b/assets/references/conformance-checklist.md index c9ada1c..94aaa9d 100644 --- a/assets/references/conformance-checklist.md +++ b/assets/references/conformance-checklist.md @@ -31,6 +31,23 @@ finding. a manifest-only or zero-file scope as installed. A skipped asset that leaves the scope looking installed is non-conformant. (Hard error on the CLI; the B4 catch turns it into a warning at load.) +- **A6 Cache hygiene (self-scoped).** Every install — including a zero-write + no-op — prunes the package's own cache copies (``, + `@latest`, `@`) from OpenCode's package cache + (`$XDG_CACHE_HOME/opencode/packages`, falling back to + `~/.cache/opencode/packages`), best-effort: per-copy removal failures warn + and the install still succeeds. Other packages' cache dirs and pinned + `@x.y.z` copies are never touched. The CLI exposes a self-only + `clear-cache` subcommand that removes `` and every + `@*` idempotently (nothing cached is a success) with the same + warn-and-continue semantics; a `--package ` or `--all` mode on a + generated package's CLI is non-conformant — broad cache deletion belongs + to the suite's own CLI only. The load-time hook never deletes cache + entries (deletion races OpenCode's in-flight installs, ADR-0007): when + bundled assets are absent (partial cache artifact), its advisory + instructs running `bunx clear-cache` and reinstalling. A hook + that deletes cache entries, or an advisory that only describes manual + cache removal, is non-conformant. ## B. Config safety From 362db1de26bc503561b8b440dd84e2f29e122005 Mon Sep 17 00:00:00 2001 From: "Diego B." Date: Thu, 1 Oct 2026 06:32:49 -0400 Subject: [PATCH 20/24] test(templates): regression coverage for generated-package cache hygiene --- CHANGELOG.md | 1 + tests/deployment-plan.test.ts | 58 +++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4424f37..349acd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Generated packages inherit the suite's cache hygiene, self-scoped (checklist A6): the installer template prunes its own `@latest`, `@`, and untagged cache copies on every install (best-effort warn-and-continue, including no-ops); the generated CLI template gains a self-only `clear-cache` subcommand (no `--package`/`--all`); the generated load-time failure advisory suggests `bunx clear-cache` instead of manual removal; the conformance checklist, packager, and publisher instructions require generated output to carry it; regression coverage asserts each template behavior - `clear-cache` CLI subcommand (manual-only, never invoked at load): with no flags it removes `opencode-architect` and every `opencode-architect@*` copy from OpenCode's package cache (`$XDG_CACHE_HOME/opencode/packages`, falling back to `~/.cache/opencode/packages`). `--package ` removes `` and every `@*`; `--all` removes the whole OpenCode cache directory; both broad modes require `--yes`, are mutually exclusive, and unsafe package names (path separators, `..`) are rejected. `--dry-run` lists what any mode would remove without deleting. Idempotent — nothing cached is a success — and removal failures warn without failing the command - `install` prunes this package's stale copies from OpenCode's package cache (`$XDG_CACHE_HOME/opencode/packages`, falling back to `~/.cache/opencode/packages`): `opencode-architect`, `opencode-architect@latest`, and `opencode-architect@` are removed on every install invocation, including no-ops, so OpenCode re-fetches the just-installed version on next start. Pinned versions and other packages' cache dirs are preserved; removal failures warn without failing the install, and cleared paths are reported - Conformance checklist items for the content-based deployment plan (ADR-0008): surgical config writes (B5), zero-write registration no-op (B6), content declaration present and consistent (E1), and binary mode enforcement (E2); E1–E2 and B5–B6 join the hard non-conformance set and the auditor's conformance review covers section E diff --git a/tests/deployment-plan.test.ts b/tests/deployment-plan.test.ts index b9280ad..686bd72 100644 --- a/tests/deployment-plan.test.ts +++ b/tests/deployment-plan.test.ts @@ -86,6 +86,60 @@ describe("content-based deployment plan (issue #18)", () => { }); }); +describe("generated-package cache hygiene (issue #14)", () => { + test("installer template prunes self cache copies on install, warn-and-continue", async () => { + const source = await readTemplate("installer.template.txt"); + expect(source).toContain("prunePackageCache"); + expect(source).toContain("const cache = await prunePackageCache();"); + expect(source).toContain("clearPackageCache"); + expect(source).toMatch(/Could not clear cached package/); + expect(source).toContain('"opencode", "packages"'); + }); + + test("installer prunes every invocation including no-ops, before mode dispatch", async () => { + const source = await readTemplate("installer.template.txt"); + const installBody = source.slice(source.indexOf("export async function install(")); + const pruneIndex = installBody.indexOf("await prunePackageCache()"); + const copyBranch = installBody.indexOf('if (mode === "copy")'); + expect(pruneIndex).toBeGreaterThan(-1); + expect(copyBranch).toBeGreaterThan(pruneIndex); + }); + + test("generated CLI exposes a self-only clear-cache without --package/--all", async () => { + const source = await readTemplate("cli.template.txt"); + const code = source.slice(source.indexOf("#!/usr/bin/env bun")); + expect(code).toContain('case "clear-cache"'); + expect(code).toContain("clearPackageCache"); + expect(code).toContain("nothing to remove"); + expect(code).not.toMatch(/package:\s*\{/); + expect(code).not.toMatch(/all:\s*\{/); + }); + + test("load-time advisory names bunx clear-cache and the hook never deletes", async () => { + const source = await readTemplate("plugin-local.template.txt"); + expect(source).toContain("clear-cache"); + expect(source).toMatch(/bunx \$\{PACKAGE_NAME\} clear-cache/); + expect(source).not.toMatch(/await rm\(|rmSync/); + }); + + test("conformance checklist covers cache hygiene and the auditor consumes every item", async () => { + const checklist = await readReference("conformance-checklist.md"); + expect(checklist).toContain("**A6 Cache hygiene"); + expect(checklist).toContain("clear-cache"); + const auditor = await readAgent("opencode-extension-auditor.md"); + expect(auditor).toContain("every item in the checklist"); + }); + + test("packager and publisher instructions require generated packages to carry cache hygiene", async () => { + const packager = await readAgent("opencode-packager.md"); + const publisher = await readAgent("opencode-publisher.md"); + expect(packager).toContain("clear-cache"); + expect(packager).toContain("A6"); + expect(publisher).toContain("clear-cache"); + expect(publisher).toContain("A6"); + }); +}); + async function readTemplate(name: string): Promise { const source = await readFile(path.join(REPO_ROOT, "assets/templates", name), "utf-8"); return source.split("---").slice(1).join("---"); @@ -94,3 +148,7 @@ async function readTemplate(name: string): Promise { async function readAgent(name: string): Promise { return readFile(path.join(REPO_ROOT, "assets/agents", name), "utf-8"); } + +async function readReference(name: string): Promise { + return readFile(path.join(REPO_ROOT, "assets/references", name), "utf-8"); +} From 0332986dad8d2c99299b71254a4279dd80088086 Mon Sep 17 00:00:00 2001 From: "Diego B." Date: Thu, 1 Oct 2026 06:46:12 -0400 Subject: [PATCH 21/24] refactor(templates): shared cache-removal helper, prune before mode dispatch, print cleared cache --- assets/templates/cli.template.txt | 11 ++++-- assets/templates/installer.template.txt | 51 +++++++++---------------- 2 files changed, 25 insertions(+), 37 deletions(-) diff --git a/assets/templates/cli.template.txt b/assets/templates/cli.template.txt index 14ddde8..e8be058 100644 --- a/assets/templates/cli.template.txt +++ b/assets/templates/cli.template.txt @@ -41,10 +41,10 @@ Examples: opencode-myextension install --scope global opencode-myextension install --mode plugin opencode-myextension uninstall --scope local - opencode-myextension status - opencode-myextension migrate --force - opencode-myextension clear-cache - `); + opencode-myextension status + opencode-myextension migrate --force + opencode-myextension clear-cache +`); } function isInstallMode(value: string): value is InstallMode { @@ -97,6 +97,9 @@ async function main(): Promise { } else { console.log(` Plugin entry: ${result.entry} in ${result.entryConfigPath}`); } + for (const cleared of result.clearedCache) { + console.log(` Cleared cache: ${cleared}`); + } break; } case "uninstall": { diff --git a/assets/templates/installer.template.txt b/assets/templates/installer.template.txt index 3803b89..9b0ffb8 100644 --- a/assets/templates/installer.template.txt +++ b/assets/templates/installer.template.txt @@ -120,12 +120,14 @@ function packageCacheRoot(): string { return join(homedir(), ".cache", "opencode", "packages"); } -export async function prunePackageCache(): Promise<{ removed: string[]; warnings: string[] }> { +interface CacheOutcome { + removed: string[]; + warnings: string[]; +} + +async function removeCacheTargets(packagesRoot: string, wanted: (name: string) => boolean): Promise { const removed: string[] = []; const warnings: string[] = []; - const packagesRoot = packageCacheRoot(); - const version = await getPackageVersion(); - const wanted = [PLUGIN_NAME, `${PLUGIN_NAME}@latest`, `${PLUGIN_NAME}@${version}`]; let entries: string[]; try { entries = await readdir(packagesRoot); @@ -134,7 +136,7 @@ export async function prunePackageCache(): Promise<{ removed: string[]; warnings throw error; } const targets = entries - .filter((name) => wanted.includes(name)) + .filter(wanted) .sort() .map((name) => join(packagesRoot, name)); for (const target of targets) { @@ -149,32 +151,15 @@ export async function prunePackageCache(): Promise<{ removed: string[]; warnings return { removed, warnings }; } -export async function clearPackageCache(): Promise<{ removed: string[]; warnings: string[] }> { - const removed: string[] = []; - const warnings: string[] = []; - const packagesRoot = packageCacheRoot(); - let entries: string[]; - try { - entries = await readdir(packagesRoot); - } catch (error) { - if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return { removed, warnings }; - throw error; - } +export async function prunePackageCache(): Promise { + const version = await getPackageVersion(); + const wanted = [PLUGIN_NAME, `${PLUGIN_NAME}@latest`, `${PLUGIN_NAME}@${version}`]; + return removeCacheTargets(packageCacheRoot(), (name) => wanted.includes(name)); +} + +export async function clearPackageCache(): Promise { const prefix = `${PLUGIN_NAME}@`; - const targets = entries - .filter((name) => name === PLUGIN_NAME || name.startsWith(prefix)) - .sort() - .map((name) => join(packagesRoot, name)); - for (const target of targets) { - try { - await rm(target, { recursive: true }); - removed.push(target); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - warnings.push(`Could not clear cached package ${target}: ${message}`); - } - } - return { removed, warnings }; + return removeCacheTargets(packageCacheRoot(), (name) => name === PLUGIN_NAME || name.startsWith(prefix)); } export function getGlobalConfigPath(): string { @@ -348,13 +333,13 @@ export async function install( options: InstallOptions = {} ): Promise { const { mode: requested, force = false } = options; - const mode = await resolveMode(requested); - const configBase = scopeBase(scope, projectDir); - const { skillPath, commandPath } = await payloadPaths(configBase); const cache = await prunePackageCache(); for (const warning of cache.warnings) { console.warn(`Warning: ${warning}`); } + const mode = await resolveMode(requested); + const configBase = scopeBase(scope, projectDir); + const { skillPath, commandPath } = await payloadPaths(configBase); if (mode === "copy") { const { upToDate, skippedModified } = await ensurePayload(scope, projectDir, force); return { scope, mode, skillPath, commandPath, entry: null, entryConfigPath: null, skippedModified, upToDate, clearedCache: cache.removed }; From 25e3fd55f1797ac6738a0b6144e868de993d8fc0 Mon Sep 17 00:00:00 2001 From: "Diego B." Date: Thu, 1 Oct 2026 10:21:18 -0400 Subject: [PATCH 22/24] bump version --- CHANGELOG.md | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 349acd2..d65dccc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.8.0] - 2026-10-01 ### Added diff --git a/package.json b/package.json index d06f4f0..15016b7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-architect", - "version": "0.7.1", + "version": "0.8.0", "description": "OpenCode plugin and CLI with ten specialist agents for agent skills, slash commands, custom tools, plugins, and MCP server integration", "keywords": [ "opencode", From 5cd24fc0d082dbc031ccb91d14970170e43403d6 Mon Sep 17 00:00:00 2001 From: "Diego B." Date: Fri, 2 Oct 2026 04:15:22 -0400 Subject: [PATCH 23/24] feat(templates): package-root content layout, workspace-default deployment target --- AGENTS.md | 2 +- CHANGELOG.md | 3 + assets/agents/opencode-architect.md | 2 +- assets/agents/opencode-extension-auditor.md | 2 +- assets/agents/opencode-packager.md | 24 +++++--- assets/agents/opencode-plugin-engineer.md | 2 +- assets/agents/opencode-publisher.md | 2 +- assets/references/conformance-checklist.md | 5 +- assets/references/plugins.md | 2 +- assets/templates/index.template.txt | 4 +- assets/templates/installer.template.txt | 8 +-- assets/templates/package-basics.template.json | 12 +++- assets/templates/package-full.template.json | 12 +++- assets/templates/plugin-local.template.txt | 4 +- tests/deployment-plan.test.ts | 59 +++++++++++++++++++ 15 files changed, 114 insertions(+), 29 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8ed39a9..899aaba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,7 @@ When encountering file references (e.g., @references/workflow.md), use Read tool - Delegate to `opencode-packager` - - Extracts `.opencode/` assets, copies to `assets/`, creates `plugin.ts`, `package.json`, `tsconfig.json` + - Extracts `.opencode/` content to package-root `skills/`/`commands/`/`agents/` (no `assets/` wrapper), placed in this workspace (default) or a sibling `../opencode-/` directory; creates `plugin.ts`, `package.json`, `tsconfig.json` - Delegate to `opencode-publisher`, fed by the packager's output diff --git a/CHANGELOG.md b/CHANGELOG.md index d65dccc..cb1ede1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Packager deployment-target choice: the packager first establishes the repo root (`git rev-parse --show-toplevel`, never the `.opencode/` directory) and resolves every source and target path from it; a generated package lays down in the current workspace root by default (files merged with consent, structural conflicts routed to `opencode-plugin-engineer`, sibling `../opencode-/` recommended only on explicit request or extensive merges) or in a sibling directory on request - Generated packages inherit the suite's cache hygiene, self-scoped (checklist A6): the installer template prunes its own `@latest`, `@`, and untagged cache copies on every install (best-effort warn-and-continue, including no-ops); the generated CLI template gains a self-only `clear-cache` subcommand (no `--package`/`--all`); the generated load-time failure advisory suggests `bunx clear-cache` instead of manual removal; the conformance checklist, packager, and publisher instructions require generated output to carry it; regression coverage asserts each template behavior - `clear-cache` CLI subcommand (manual-only, never invoked at load): with no flags it removes `opencode-architect` and every `opencode-architect@*` copy from OpenCode's package cache (`$XDG_CACHE_HOME/opencode/packages`, falling back to `~/.cache/opencode/packages`). `--package ` removes `` and every `@*`; `--all` removes the whole OpenCode cache directory; both broad modes require `--yes`, are mutually exclusive, and unsafe package names (path separators, `..`) are rejected. `--dry-run` lists what any mode would remove without deleting. Idempotent — nothing cached is a success — and removal failures warn without failing the command - `install` prunes this package's stale copies from OpenCode's package cache (`$XDG_CACHE_HOME/opencode/packages`, falling back to `~/.cache/opencode/packages`): `opencode-architect`, `opencode-architect@latest`, and `opencode-architect@` are removed on every install invocation, including no-ops, so OpenCode re-fetches the just-installed version on next start. Pinned versions and other packages' cache dirs are preserved; removal failures warn without failing the install, and cleared paths are reported @@ -17,6 +18,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Breaking:** generated packages use package-root content directories — `skills//`, `commands/`, `agents/` sit at the package root with no `assets/` intermediary (`ASSET_LAYOUT_DIR = "."`), aligning generated repos with skills.sh-style root-`skills/` scanners; legacy `assets/`-wrapper packages remain recognized by the installer, checklist, auditor, and publisher +- Generated `package.json` `files` lists the shipped content directories (`skills`, `commands`, plus `agents`/`plugins`/`tools`/`src` as present) instead of `assets` - **Breaking:** `install` is now a registration manager (plugin install is the only mode for this code-backed package, per [ADR-0008](docs/adr/0008-content-based-deployment-plans.md), superseding ADR-0004): the CLI ensures the `plugin` entry in the target scope's config file via the surgical editor — comments and formatting preserved, unparseable configs abort untouched — and writes a generalized manifest (version, mode, plugin entry, target config file) at the scope base. A matching manifest with the entry present is a zero-write no-op. `--mode copy` is refused with an explanatory error; `--force` now re-registers and rewrites the manifest instead of removing the entry - Legacy copy installs migrate automatically on install: the old manifest's file list is removed exactly, with a printed notice, before the plugin entry is added - `uninstall` surgically removes the plugin entry (config formatting preserved) plus the manifest and any residual copy payload — manifest-gated, or a known-filenames sweep of `agents/` and `opencode-architect/` when no manifest exists; `status` reports mode, version, and the config file holding the registration diff --git a/assets/agents/opencode-architect.md b/assets/agents/opencode-architect.md index 68afcd5..1dcb12c 100644 --- a/assets/agents/opencode-architect.md +++ b/assets/agents/opencode-architect.md @@ -61,7 +61,7 @@ After the user creates or updates an extension, or finishes an extraction, sugge Run one stage at a time, returning to the user between stages so they review and decide each step: 1. Optionally first, delegate to opencode-extension-auditor for an inventory of `.opencode/` - informed packaging guidance. -2. Delegate to opencode-packager: "Package extensions from [source path or .opencode/] for local sharing. Target directory: ./opencode-[extension-name]/. Return: summary of created files, included assets, dependencies, and any issues." When the source includes skills, commands, or static assets, list each in the prompt (skill asset files, command files, XML templates or docs) plus the intended package name opencode-{extension-name}. +2. Delegate to opencode-packager: "Package extensions from [source path or .opencode/] for local sharing. Deployment target: this workspace root by default — lay the package tree (skills/, commands/, agents/, plugin.ts, package.json) at the repo root (git rev-parse --show-toplevel; never relative to .opencode/) — or a sibling directory ../opencode-[extension-name]/ when the user asks or the root already holds a package.json/plugin.ts with extensive merge conflicts. Return: summary of created files, included assets, dependencies, and any issues." When the source includes skills, commands, or static assets, list each in the prompt (skill asset files, command files, XML templates or docs) plus the intended package name opencode-{extension-name}. 3. Check the packager summary against the package checklist below. 4. Ask the user about publishing, showing local use: add "file:///path/to/opencode-[name]" to the plugins array in opencode.json. 5. On yes, delegate to opencode-publisher: "Transform the locally-packaged extension at ./opencode-[name]/ for npm publishing" plus the packager summary and the publisher tasks: extract install logic to src/installer.ts, create src/cli.ts for bunx, expand package.json for npm, verify npm authentication, publish, generate consumer installation instructions. diff --git a/assets/agents/opencode-extension-auditor.md b/assets/agents/opencode-extension-auditor.md index 8df1d5f..6a9ba50 100644 --- a/assets/agents/opencode-extension-auditor.md +++ b/assets/agents/opencode-extension-auditor.md @@ -58,7 +58,7 @@ Suggest packaging when criteria are met (3+ skills OR 2+ commands OR 1+ agent), ## Conformance review mode -Run this mode when asked whether a package is aligned with this suite's guidance or best practice, whether it accounts for the manifest implementation, or to assess/report conformance generally. The subject is a built package (a repo or directory with `plugin.ts`, install logic, and a bundled asset directory — `assets/` or repo-root `skills/`), not a project's `.opencode/`. +Run this mode when asked whether a package is aligned with this suite's guidance or best practice, whether it accounts for the manifest implementation, or to assess/report conformance generally. The subject is a built package (a repo or directory with `plugin.ts`, install logic, and bundled content directories at the package root — `skills/`, `commands/`; a legacy `assets/` wrapper is recognized), not a project's `.opencode/`. 1. Read `../references/conformance-checklist.md` and treat **every item in the checklist** (all A, B, C, D, E items) as the review rubric — never a hardcoded range. State the **absolute path and version of the checklist copy you used** in the report header (version comes from that package tree's own `package.json` / CHANGELOG). When a newer criteria copy exists than the one your relative path resolved (e.g. an installed cache copy older than the suite repo), say so explicitly and **refuse to return a Conformant verdict against the stale criteria** — report at most "Partially conformant, pending review against current criteria". 2. The review is executed, not just read. Run the package's own test suite and typecheck (`bun test`, `bun run check`) and report their results as evidence. For any parse, hash, manifest, or detection logic, construct at least one adversarial input and **execute** it (e.g. `bun -e` / `node -e` with a `.jsonc` containing a comment between a trailing comma and its closer) before declaring the item Conformant; cite the command and observed output. Stay within the bash allowlist above; never write to the subject repo. diff --git a/assets/agents/opencode-packager.md b/assets/agents/opencode-packager.md index fd98805..f28fc43 100644 --- a/assets/agents/opencode-packager.md +++ b/assets/agents/opencode-packager.md @@ -16,23 +16,29 @@ You package OpenCode extensions for local sharing across projects as standalone ## Workflow -1. **Detect the source.** Locate the source structure: a project-local `.opencode/` (skills/, commands/, agents/, optional plugins/ and tools/, optional package.json) or an existing package (assets/ with skills, commands, agents; plugin.ts; package.json; tsconfig.json). Use the path the user named; otherwise scan the current directory for `.opencode/`, falling back to an existing package structure. Report findings and confirm before proceeding. +1. **Establish the workspace root, then detect the source.** First establish the repo root: run `git rev-parse --show-toplevel` (falling back to the directory holding the project's `opencode.json`). Every path in every later step — source scan and deployment target alike — resolves from that root. The root is never the `.opencode/` directory, and no target path is ever resolved relative to the source's location. Then locate the source structure: a project-local `.opencode/` (skills/, commands/, agents/, optional plugins/ and tools/, optional package.json) or an existing package (package-root `skills/`/`commands/`/`agents/`, or a legacy `assets/` wrapper; plugin.ts; package.json; tsconfig.json). Use the path the user named; otherwise scan the repo root for `.opencode/`, falling back to an existing package structure. Report findings (including the established root) and confirm before proceeding. -1b. **Discovery study (only when shaping a new package from example repos).** When the user points at existing repos or packages as structural exemplars, delegate a read-only comparative study to a general subagent via the task tool: evolutionary order, per-repo handling of every required structural element (`.opencode/opencode.json`, bundled asset dir, `src/`, `package.json`, `plugin.ts`, install logic, tests), and an inferred blueprint. Use DeepWiki MCP as the primary mechanism; fall back to raw file fetches when a repo is not indexed; never scaffold during the study. **Treat the suite's bundled template files (listed under Templates below) as the structural source of truth** — the blueprint from exemplar repos informs content and naming only. Example repos may embed outdated install patterns (version markers, unconditional overwrites, the plural `plugins` config key) *and* may use a repo-root `skills//` layout instead of `assets/`; the templates encode the corrected scope-aware, manifest-gated pattern and win any conflict. +1b. **Discovery study (only when shaping a new package from example repos).** When the user points at existing repos or packages as structural exemplars, delegate a read-only comparative study to a general subagent via the task tool: evolutionary order, per-repo handling of every required structural element (`.opencode/opencode.json`, bundled asset dir, `src/`, `package.json`, `plugin.ts`, install logic, tests), and an inferred blueprint. Use DeepWiki MCP as the primary mechanism; fall back to raw file fetches when a repo is not indexed; never scaffold during the study. **Treat the suite's bundled template files (listed under Templates below) as the structural source of truth** — the blueprint from exemplar repos informs content and naming only. Example repos may embed outdated install patterns (version markers, unconditional overwrites, the plural `plugins` config key) or the legacy `assets/` wrapper layout; the templates encode the corrected scope-aware, manifest-gated pattern with package-root content directories and win any conflict. -2. **Copy assets to the bundled asset directory.** Skills, commands, and agents are copied, because consumers must read and edit them in their own `.opencode/`; commands in particular have no config registration, so copying is the only mechanism. The suite accepts two layouts: `assets/` (skills/commands/agents inside it — the templates' default) or repo-root `skills//` for skills-cli-oriented packages. Whichever layout you choose, the installer resolves the asset dir through a single layout constant and fails loudly when it is absent. Result: assets/skills//SKILL.md, assets/commands/.md, assets/agents/.md (or the skills/ equivalent), mirroring the source. +1c. **Choose the deployment target.** Offer two placements for the generated package and confirm before writing anything. All target paths resolve from the root established in step 1 — in this-workspace mode the target is the repo root itself; in sibling mode `../opencode-/` means a sibling of the repo root, never a sibling of `.opencode/`: + + - **This workspace (default).** Lay the package files directly into the repo root — `skills/`, `commands/`, `agents/`, `plugin.ts`, `package.json`, `tsconfig.json` at the root, no `opencode-/` wrapper directory. This is the choice whenever the user is silent or unsure, and always when the root is empty or has no `package.json`/`plugin.ts` — a mostly-empty repo never triggers sibling mode. + - **Sibling.** Create a new `../opencode-/` directory holding the same tree. Only pick this when the user explicitly asks for it, or when the root already holds a `package.json`/`plugin.ts` and the merge below turns out extensive. + + In this-workspace mode, when the root already holds a `package.json` or `plugin.ts`, merge rather than overwrite: obtain user consent for every merge decision and route structural conflicts to opencode-plugin-engineer (same delegation as step 4). When the merge is extensive (both files exist with substantive content), recommend sibling mode instead and proceed there if the user agrees. + +2. **Copy assets to the package root.** Skills, commands, and agents are copied, because consumers must read and edit them in their own `.opencode/`; commands in particular have no config registration, so copying is the only mechanism. The layout is package-root content directories — `skills//`, `commands/`, `agents/` — with no `assets/` intermediary (skills.sh-style scanners discover repo-root `skills/`). The installer resolves the package root through `ASSET_LAYOUT_DIR = "."` and fails loudly when a content directory is absent; only when adapting a legacy `assets/`-layout package is the constant overridden to `"assets"`. Result: skills//SKILL.md, commands/.md, agents/.md, mirroring the source. 3. **Merge dependencies.** Read `.opencode/package.json` when present; carry its dependencies and peerDependencies into the generated package.json. Report them: "Including 1 dependency from .opencode/package.json: zod". 4. **Pause on custom code.** When the source contains `.opencode/plugins/*.ts` or `.opencode/tools/*.ts`, stop before merging: list the plugins and tools found, tell the user these require merge decisions, and delegate to opencode-plugin-engineer - "The user is packaging their .opencode/ extensions. Custom code detected: Plugins: [list], Tools: [list]. Guide the user through merging into target package structure." Resume packaging with the engineer's merge summary. -5. **Create the package structure.** +5. **Create the package structure.** In sibling mode the tree lives under `opencode-myextension/`; in this-workspace mode (step 1c) the same tree is laid at the current workspace root, merging with existing files per step 1c. ``` opencode-myextension/ -├── assets/ -│ ├── skills//SKILL.md -│ ├── commands/.md -│ └── agents/.md +├── skills//SKILL.md +├── commands/.md +├── agents/.md ├── index.ts # re-exports plugin.ts ├── plugin.ts # main plugin with inline install logic ├── package.json @@ -41,7 +47,7 @@ opencode-myextension/ 6. **Create plugin.ts** from `../templates/plugin-local.template.txt`, plus `src/plugin-name.ts` from `../templates/plugin-name.template.txt`, `src/manifest.ts` from `../templates/manifest.template.txt`, `src/registration.ts` from `../templates/registration.template.txt`, and `src/plugin-config.ts` from `../templates/plugin-config.template.txt`: the load hook performs read-only registration-scope detection, then ensures skills, commands, and agents only for the scopes where the plugin is registered, gated by the per-scope install manifest (version, mode, registration, per-file sha256). Never write outside the detected scopes, never edit `plugin` arrays or root configs at load, and never rewrite a config that fails to parse. The install logic is content-based (ADR-0008): the same installer serves both the global scope base and the project scope base with identical behavior — copy install touches no config, plugin registration goes through the surgical editor, and code-backed packages refuse `--mode copy`. Cache hygiene is self-scoped (checklist A6): the installer template prunes this package's own cache copies on every install (warn-and-continue, other packages untouched), and the hook's failure advisory tells consumers to run `bunx clear-cache` — the hook itself never deletes cache entries. -7. **Create package.json** from `../templates/package-basics.template.json` and tsconfig.json from `../templates/tsconfig.template.json`. The template ships with `"content": "assets"`; the asset inventory is the source of truth for the declaration (ADR-0008): keep `"assets"` only when the package ships skills and/or commands alone; set `"code"` when the source contained any agent, tool, plugin, hook, or other plugin integration — code-backed packages may also ship skills and commands. Report the decision: "Content declaration: code (1 agent, 2 tools found)" or "Content declaration: assets (skills and commands only)". +7. **Create package.json** from `../templates/package-basics.template.json` and tsconfig.json from `../templates/tsconfig.template.json`. The template ships with `"content": "assets"`; the asset inventory is the source of truth for the declaration (ADR-0008): keep `"assets"` only when the package ships skills and/or commands alone; set `"code"` when the source contained any agent, tool, plugin, hook, or other plugin integration — code-backed packages may also ship skills and commands. The `files` list names the content directories actually shipped at the package root (`skills`, `commands`; add `agents`, `plugins`, `tools`, `src` as present) — never an `assets` entry. Report the decision: "Content declaration: code (1 agent, 2 tools found)" or "Content declaration: assets (skills and commands only)". 8. **Create the README badge row.** Build the package's `README.md` with the badge row directly below the first heading (a tagline between heading and badges is non-conformant). Emit or verify the row exactly as specified in opencode-publisher step 4b; include the DeepWiki badge only when the repo is indexed (confirm via a `deepwiki.com//` fetch or a DeepWiki MCP query — never assume). The publisher re-verifies this row; emitting it here keeps locally-used packages conformant too. diff --git a/assets/agents/opencode-plugin-engineer.md b/assets/agents/opencode-plugin-engineer.md index 0f8b8e0..f7b8b54 100644 --- a/assets/agents/opencode-plugin-engineer.md +++ b/assets/agents/opencode-plugin-engineer.md @@ -38,7 +38,7 @@ OpenCode must always launch, with or without the plugin. A hook rejection during - Wrap the **entire hook body** (detection, installs, advisory) in try/catch. On failure: structured warn + one failure advisory built inside its own try/catch with a static fallback, naming both the remediation command (`bunx install --scope global`) and the package-qualified cache dir (`~/.cache/opencode/packages/@`); each emitter (log, toast) swallows independently; then return. In-memory config work (permissions, agent injection) still applies. The failure advisory and the not-installed advisory carry separate once-guards — sharing one flag lets either suppress the other. The hook never deletes the cache (deletion races OpenCode's in-flight installs); it instructs only. - Hard errors belong to the CLI (`install`/`status`), never to hooks. Reserve throw-worthy conditions for explicit user-invoked commands. - Detect registration read-only in every supported config format — `opencode.json` and `opencode.jsonc`, global and project. A plugin entry in `opencode.jsonc` is invisible if you only read `opencode.json`. -- OpenCode imports npm plugins from their real cached directory (`~/.cache/opencode/packages//node_modules/`), so `import.meta.dirname` resolves the package's bundled asset directory (`assets/`, or repo-root `skills/` for skills-layout packages). But OpenCode reuses a cached `node_modules/` forever — including a partial extraction missing assets. A missing or empty asset source at install time is a hard error naming the path, package name+version, cache dir to clear, and install command — never a silent skip that leaves the scope looking installed. If assets are unexpectedly absent at load, never assume a broken consumer setup: advise removing the specific cache dir (exact path) so the next start re-installs, and verify the published tarball ships assets via `npm pack --dry-run` before release (it belongs in the publish checklist). +- OpenCode imports npm plugins from their real cached directory (`~/.cache/opencode/packages//node_modules/`), so `import.meta.dirname` resolves the package root, whose bundled content directories (`skills/`, `commands/`, `agents/` — via `ASSET_LAYOUT_DIR = "."`; a legacy `assets/` wrapper is recognized) sit at the package root. But OpenCode reuses a cached `node_modules/` forever — including a partial extraction missing assets. A missing or empty asset source at install time is a hard error naming the path, package name+version, cache dir to clear, and install command — never a silent skip that leaves the scope looking installed. If assets are unexpectedly absent at load, never assume a broken consumer setup: advise removing the specific cache dir (exact path) so the next start re-installs, and verify the published tarball ships assets via `npm pack --dry-run` before release (it belongs in the publish checklist). ## References usage diff --git a/assets/agents/opencode-publisher.md b/assets/agents/opencode-publisher.md index 3552795..8e390e2 100644 --- a/assets/agents/opencode-publisher.md +++ b/assets/agents/opencode-publisher.md @@ -17,7 +17,7 @@ You are an OpenCode extension publisher: you transform locally-packaged extensio ## Workflow -1. **Verify the incoming package.** Confirm the packager's structure exists: a bundled asset directory (`assets/skills/` etc., or repo-root `skills//`), `plugin.ts` with inline install logic, minimal `package.json` with a `content` declaration (`assets` or `code`), `tsconfig.json`. Read the packager summary for extension name, description, included assets, dependencies, warnings, and the content declaration. Confirm every asset landed in the package and custom plugins or tools got their merge decisions. Cross-check the declaration against the bundled assets: `assets` requires skills/commands only — any agent, tool, or plugin file in the package contradicts it and returns to the orchestrator for repackaging; `code` is valid for any inventory. An invalid structure returns to the orchestrator for repackaging. +1. **Verify the incoming package.** Confirm the packager's structure exists: bundled content directories at the package root (`skills/`, `commands/`; a legacy `assets/` wrapper is recognized but non-default), `plugin.ts` with inline install logic, minimal `package.json` with a `content` declaration (`assets` or `code`), `tsconfig.json`. Read the packager summary for extension name, description, included assets, dependencies, warnings, and the content declaration. Confirm every asset landed in the package and custom plugins or tools got their merge decisions. Cross-check the declaration against the bundled assets: `assets` requires skills/commands only — any agent, tool, or plugin file in the package contradicts it and returns to the orchestrator for repackaging; `code` is valid for any inventory. An invalid structure returns to the orchestrator for repackaging. 2. **Extract install logic to src/installer.ts.** Move install(), uninstall(), status(), scope detection, path resolution, and config management out of plugin.ts, keeping the manifest module (src/manifest.ts), plugin-name normalizer (src/plugin-name.ts), registration detector (src/registration.ts), and surgical config editor (src/plugin-config.ts) as separate files; update plugin.ts to call install() from src/installer.ts. Preserve the content-based deployment plan (ADR-0008): the installer reads the `content` declaration — assets-only packages copy-install by default (copy touches no config file) with `--mode plugin` as the opt-in; code-backed packages always register and `--mode copy` is a hard error. Preserve the invariants: manifest-gated idempotency (no `.version` markers; the plugin-mode no-op includes a present entry), semantic `@latest` plugin dedup written canonically as `name@latest` via the surgical editor only, abort-with-warning on unparseable config (never rewrite from `{}`), skip consumer-modified files unless `--force`, and root-config migration CLI-only behind explicit consent. Preserve the self-scoped cache hygiene (checklist A6): install() prunes the package's own cache copies (``, `@latest`, `@`) on every invocation — including no-ops — best-effort with warn-and-continue, and clearPackageCache() backs the CLI's `clear-cache` subcommand. diff --git a/assets/references/conformance-checklist.md b/assets/references/conformance-checklist.md index 94aaa9d..90792c5 100644 --- a/assets/references/conformance-checklist.md +++ b/assets/references/conformance-checklist.md @@ -3,8 +3,9 @@ Canonical review criteria for assessing whether an existing plugin package conforms to this suite's design (ADR 0006: scope-aware, manifest-gated installation). Review a built package — a repo with `plugin.ts`, install -logic, a bundled asset directory (`assets/` or repo-root `skills/`), and -`package.json` — against every item. Cite +logic, bundled content directories at the package root (`skills/`, +`commands/`; the legacy `assets/` wrapper is recognized but non-default), +and `package.json` — against every item. Cite file and line evidence per item; an item with no evidence found is a finding. diff --git a/assets/references/plugins.md b/assets/references/plugins.md index adc4c38..7bc5258 100644 --- a/assets/references/plugins.md +++ b/assets/references/plugins.md @@ -80,7 +80,7 @@ Compaction hook `experimental.session.compacting` can append via `output.context Verified against the OpenCode source; rely on these when writing plugins that self-install assets. - Resolution: a `plugin` entry starting with `file://`, `.`, or an absolute path loads from disk as-is; anything else is treated as an npm spec and installed with arborist into `~/.cache/opencode/packages//node_modules/`. An existing cached `node_modules/` is reused verbatim — including a partial or corrupt install; nothing re-validates or repairs it. -- Import: the entrypoint is picked from `exports["./server"]`, then `main`, then a root `index.{ts,tsx,js,mjs,cjs}`; it must resolve inside the package directory. The module is imported from the real directory (no bundling), so `import.meta.dirname` is the package dir and bundled `assets/` resolve normally. +- Import: the entrypoint is picked from `exports["./server"]`, then `main`, then a root `index.{ts,tsx,js,mjs,cjs}`; it must resolve inside the package directory. The module is imported from the real directory (no bundling), so `import.meta.dirname` is the package dir and bundled content directories (`skills/`, `commands/`, `agents/`) resolve normally. - Error handling: install/entry/import failures are caught and logged — startup continues. But the wait that joins background npm-install fibers has no timeout, and a hook that rejects during config assembly propagates into config loading: either can stall startup with no visible escape. A plugin must therefore never throw from hooks. - Config files: global config may be `opencode.json`, `opencode.jsonc`, or `config.json`; project config likewise `.json`/`.jsonc` at either base (`.opencode/opencode.json(c)` and repo root). Anything reading consumer registration must check both extensions, and `config.json` at the global base. - `.jsonc` parse semantics: string-aware stripping of `//` and `/* */` comments plus trailing commas, for `.jsonc` only; `.json` stays strict. A `$schema` URL containing `//` must survive; escaped quotes must not break string tracking; the trailing-comma lookahead must skip comments (strip comments first, then trailing commas). Any `.jsonc` reader must pass this fixture matrix: line comment; block comment; trailing comma at array end; trailing comma at object end; comment between a trailing comma and its closer; `$schema` URL containing `//`; a string containing `/*`; an escaped quote inside a string; and a genuinely malformed file, which must stay unparseable and be preserved byte-for-byte. diff --git a/assets/templates/index.template.txt b/assets/templates/index.template.txt index 47b9ff1..1a10d06 100644 --- a/assets/templates/index.template.txt +++ b/assets/templates/index.template.txt @@ -6,10 +6,10 @@ EXTENSION NAME "myextension" → your extension identifier (e.g., "mytool") SKILL NAME - "myextension" → must match the folder name in assets/skills/ + "myextension" → must match the folder name in skills/ COMMAND NAME - "my-command.md" → your command file name in assets/commands/ + "my-command.md" → your command file name in commands/ --- export { default } from "./plugin.ts"; \ No newline at end of file diff --git a/assets/templates/installer.template.txt b/assets/templates/installer.template.txt index 9b0ffb8..257c053 100644 --- a/assets/templates/installer.template.txt +++ b/assets/templates/installer.template.txt @@ -3,11 +3,11 @@ | Placeholder | Required | Description | Default | |---|---|---|---| | `EXTENSION NAME` → "myextension" | Yes | Extension identifier (e.g., "mytool") | — | -| `SKILL NAME` → "myextension" | Yes | Must match the folder name in assets/skills/ | — | -| `COMMAND NAME` → "my-command.md" | Yes | Command file name in assets/commands/ | — | +| `SKILL NAME` → "myextension" | Yes | Must match the folder name in skills/ at the package root | — | +| `COMMAND NAME` → "my-command.md" | Yes | Command file name in commands/ at the package root | — | | `PACKAGE NAME` → "opencode-myextension" | Yes | npm package name; also the manifest file base | — | -**Load-bearing — do not simplify:** the deployment plan is content-based (ADR-0008): the content declaration (`"content"` in package.json, `"assets"` or `"code"`) decides the install modes — assets-only packages copy-install by default with `--mode plugin` as the opt-in, code-backed packages always plugin-register and `--mode copy` throws `CopyModeUnsupportedError`; copy install touches no config file at all — no permission blocks, no MCP entries, no plugin array edits; the `plugin` array is edited only by the surgical `PluginConfigEditor` (text splice, every other byte untouched, never a parse-then-reserialize, never rewrite config from `{}`, existing entry is a zero-write no-op); manifest-gated idempotency (never `.version` markers), the plugin-mode zero-write no-op includes a present entry (version match alone is not enough), and payload idempotency requires a recorded, hash-matching payload — a plugin-mode manifest with an empty file map means the load-time hook has not ensured assets yet, so the ensure path runs and records the payload hashes while preserving the recorded mode, entry, and target config file; config reading consults both `opencode.json` and `opencode.jsonc` per base, with `.jsonc` parsed leniently and `.json` strict, and every unparseable candidate is warned about; bundled assets resolve through `ASSET_LAYOUT_DIR` (packages shipping repo-root `skills/` instead of `assets/` set it to `"."`), and a missing or empty asset source is a hard error naming the path, package name+version, cache dir, and install command — never a silent skip, and a manifest is never written when zero files were written; the manifest hashes the whole payload relative to the config base (skills and commands), and `migrateRootConfig` deletes nothing without a consent callback and is never called from `install()`. Cache hygiene is self-scoped (ADR-0007): every install — including a no-op — prunes this package's own cache copies (``, `@latest`, `@`) from OpenCode's package cache (`$XDG_CACHE_HOME/opencode/packages`, falling back to `~/.cache/opencode/packages`), best-effort warn-and-continue, never touching other packages' cache dirs or pinned versions, and the load-time hook never prunes — only the CLI's install and `clear-cache` paths do. Every function here is scope-parameterized: the exact same logic serves the local scope base (`/.opencode/`) and the global scope base (`~/.config/opencode/` or `$XDG_CONFIG_HOME/opencode/`), so a global install and a project install differ only in the base path — never in behavior. +**Load-bearing — do not simplify:** the deployment plan is content-based (ADR-0008): the content declaration (`"content"` in package.json, `"assets"` or `"code"`) decides the install modes — assets-only packages copy-install by default with `--mode plugin` as the opt-in, code-backed packages always plugin-register and `--mode copy` throws `CopyModeUnsupportedError`; copy install touches no config file at all — no permission blocks, no MCP entries, no plugin array edits; the `plugin` array is edited only by the surgical `PluginConfigEditor` (text splice, every other byte untouched, never a parse-then-reserialize, never rewrite config from `{}`, existing entry is a zero-write no-op); manifest-gated idempotency (never `.version` markers), the plugin-mode zero-write no-op includes a present entry (version match alone is not enough), and payload idempotency requires a recorded, hash-matching payload — a plugin-mode manifest with an empty file map means the load-time hook has not ensured assets yet, so the ensure path runs and records the payload hashes while preserving the recorded mode, entry, and target config file; config reading consults both `opencode.json` and `opencode.jsonc` per base, with `.jsonc` parsed leniently and `.json` strict, and every unparseable candidate is warned about; bundled content directories sit at the package root (`skills/`, `commands/`, `agents/` — no `assets/` intermediary) and resolve through `ASSET_LAYOUT_DIR`, which is `"."` by default; only when adapting a legacy package that wraps them in `assets/` is the constant overridden to `"assets"`, and a missing or empty asset source is a hard error naming the path, package name+version, cache dir, and install command — never a silent skip, and a manifest is never written when zero files were written; the manifest hashes the whole payload relative to the config base (skills and commands), and `migrateRootConfig` deletes nothing without a consent callback and is never called from `install()`. Cache hygiene is self-scoped (ADR-0007): every install — including a no-op — prunes this package's own cache copies (``, `@latest`, `@`) from OpenCode's package cache (`$XDG_CACHE_HOME/opencode/packages`, falling back to `~/.cache/opencode/packages`), best-effort warn-and-continue, never touching other packages' cache dirs or pinned versions, and the load-time hook never prunes — only the CLI's install and `clear-cache` paths do. Every function here is scope-parameterized: the exact same logic serves the local scope base (`/.opencode/`) and the global scope base (`~/.config/opencode/` or `$XDG_CONFIG_HOME/opencode/`), so a global install and a project install differ only in the base path — never in behavior. --- import { exists, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; @@ -63,7 +63,7 @@ export interface StatusResult { const SKILL_NAME = "myextension"; const COMMAND_NAME = "my-command.md"; const PLUGIN_NAME = "opencode-myextension"; -const ASSET_LAYOUT_DIR = "assets"; +const ASSET_LAYOUT_DIR = "."; const normalizer = new PluginNameNormalizer(); const editor = new PluginConfigEditor(); diff --git a/assets/templates/package-basics.template.json b/assets/templates/package-basics.template.json index 87e68be..09e4c9d 100644 --- a/assets/templates/package-basics.template.json +++ b/assets/templates/package-basics.template.json @@ -10,9 +10,17 @@ VERSION "1.0.0" → initial version PATHS - "skills/myextension" → path to your skill assets + "skills/myextension" → path to your skill assets (folder at the + package root — skills/, commands/, agents/; + there is no assets/ wrapper) "commands/my-command.md" → path to your command file +FILES + ["index.ts", "plugin.ts", "skills", "commands"] + → list the content directories actually shipped; + add "agents", "plugins", "tools", or "src" + when the package ships them + CONTENT DECLARATION "assets" → keep when the package ships only skills and/or commands "code" → replace when the package ships any agent, tool, hook, or @@ -31,5 +39,5 @@ METADATA "content": "assets", "type": "module", "module": "index.ts", - "files": ["index.ts", "plugin.ts", "assets"] + "files": ["index.ts", "plugin.ts", "skills", "commands"] } \ No newline at end of file diff --git a/assets/templates/package-full.template.json b/assets/templates/package-full.template.json index cac276a..c7c531e 100644 --- a/assets/templates/package-full.template.json +++ b/assets/templates/package-full.template.json @@ -10,9 +10,17 @@ VERSION "1.0.0" → initial version PATHS - "skills/myextension" → path to your skill assets + "skills/myextension" → path to your skill assets (folder at the + package root — skills/, commands/, agents/; + there is no assets/ wrapper) "commands/my-command.md" → path to your command file +FILES + ["index.ts", "plugin.ts", "src", "skills", "commands"] + → list the content directories actually shipped; + add "agents", "plugins", or "tools" when the + package ships them + CONTENT DECLARATION "assets" → keep when the package ships only skills and/or commands "code" → replace when the package ships any agent, tool, hook, or @@ -46,7 +54,7 @@ METADATA "email": "support@example.com" }, "license": "MIT", - "files": ["index.ts", "plugin.ts", "src", "assets"], + "files": ["index.ts", "plugin.ts", "src", "skills", "commands"], "scripts": { "check": "tsc --noEmit", "test": "bun test" diff --git a/assets/templates/plugin-local.template.txt b/assets/templates/plugin-local.template.txt index f6bbe30..f7684be 100644 --- a/assets/templates/plugin-local.template.txt +++ b/assets/templates/plugin-local.template.txt @@ -3,8 +3,8 @@ | Placeholder | Required | Description | Default | |---|---|---|---| | `EXTENSION NAME` → "myextension" | Yes | Extension identifier used in the advisory text | — | -| `SKILL NAME` → "myextension" | Yes | Must match the folder name in assets/skills/ | — | -| `COMMAND NAME` → "my-command.md" | Yes | Command file name in assets/commands/ | — | +| `SKILL NAME` → "myextension" | Yes | Must match the folder name in skills/ at the package root | — | +| `COMMAND NAME` → "my-command.md" | Yes | Command file name in commands/ at the package root | — | | `PACKAGE NAME` → "opencode-myextension" | Yes | npm package name; must match package.json | — | **Load-bearing — do not simplify:** scope detection is read-only config inspection (both `opencode.json` and `opencode.jsonc`, global and project, plus global `config.json` via the detector), never launch-directory checks and never any directory-identity comparison (`import.meta.dirname`, `process.cwd()`, realpath) — a maintainer's own checkout is served by that repo's own config registration; the hook calls only `ensureAssets` — it never resolves an install mode, never edits `plugin` arrays, permission, or any config file, and writes stay inside the detected registration scope(s) only; assets-only and code-backed packages share this hook unchanged: assets are ensured in registered scopes either way, and code-backed assets resolve from the package at load; the **entire hook body** (detection, ensures, advisory) is wrapped in try/catch so a failure degrades to a warning and OpenCode still launches — hooks must never throw; the failure advisory names both the remediation command (`bunx install --scope global`) and the package-qualified cache dir (`~/.cache/opencode/packages/@`), tells the consumer to run `bunx clear-cache` for a partial cache artifact instead of describing manual removal, is built inside its own try/catch with a static fallback, and each emitter swallows independently; the failure advisory and the not-installed advisory carry **separate** once-guards so neither suppresses the other; the zero-write no-op covers a fully matching manifest **and** a present plugin entry (an up-to-date manifest alone is not enough for plugin installs), so a healthy startup performs no writes at all; the hook never deletes the cache — it instructs only. diff --git a/tests/deployment-plan.test.ts b/tests/deployment-plan.test.ts index 686bd72..27bbcc2 100644 --- a/tests/deployment-plan.test.ts +++ b/tests/deployment-plan.test.ts @@ -140,6 +140,65 @@ describe("generated-package cache hygiene (issue #14)", () => { }); }); +describe("package-root content layout (no assets/ wrapper)", () => { + test("installer resolves content directories at the package root by default", async () => { + const source = await readTemplate("installer.template.txt"); + expect(source).toContain('const ASSET_LAYOUT_DIR = ".";'); + expect(source).not.toContain('const ASSET_LAYOUT_DIR = "assets";'); + }); + + test("package.json templates list content directories, never assets", async () => { + for (const template of ["package-basics.template.json", "package-full.template.json"]) { + const source = await readTemplate(template); + const json = JSON.parse(source.slice(source.indexOf("{"))); + const files = json.files as string[]; + expect(files).toContain("skills"); + expect(files).toContain("commands"); + expect(files).not.toContain("assets"); + expect(json.content).toBe("assets"); + } + }); + + test("templates address skills and commands at the package root", async () => { + for (const template of ["plugin-local.template.txt", "index.template.txt", "installer.template.txt"]) { + const source = await readTemplate(template); + expect(source).not.toContain("assets/skills"); + expect(source).not.toContain("assets/commands"); + } + }); + + test("packager establishes the repo root before any path resolution", async () => { + const packager = await readAgent("opencode-packager.md"); + expect(packager).toContain("Establish the workspace root"); + expect(packager).toContain("git rev-parse --show-toplevel"); + expect(packager).toContain("never the `.opencode/` directory"); + expect(packager).toContain("never a sibling of `.opencode/`"); + }); + + test("packager chooses the deployment target: this workspace by default, sibling on request or heavy merges", async () => { + const packager = await readAgent("opencode-packager.md"); + expect(packager).toContain("Choose the deployment target"); + expect(packager).toContain("This workspace (default)"); + expect(packager).toContain("a mostly-empty repo never triggers sibling mode"); + expect(packager).toContain("Sibling"); + expect(packager).toContain("opencode-plugin-engineer"); + expect(packager).toContain("recommend sibling mode"); + expect(packager).toContain("no `assets/` intermediary"); + expect(packager).toContain("skills//SKILL.md"); + }); + + test("publisher verifies package-root content directories", async () => { + const publisher = await readAgent("opencode-publisher.md"); + expect(publisher).toContain("package root"); + expect(publisher).not.toContain("assets/skills/"); + }); + + test("architect routes packaging to this workspace by default", async () => { + const architect = await readAgent("opencode-architect.md"); + expect(architect).toContain("this workspace root by default"); + }); +}); + async function readTemplate(name: string): Promise { const source = await readFile(path.join(REPO_ROOT, "assets/templates", name), "utf-8"); return source.split("---").slice(1).join("---"); From 1ccfd7b09de58b0b84fd9eb9cc64e8b8dd241f48 Mon Sep 17 00:00:00 2001 From: "Diego B." Date: Fri, 2 Oct 2026 09:53:32 -0400 Subject: [PATCH 24/24] feat(agents): packager bin surface, mandatory frontmatter quoting, promoted-source retirement --- CHANGELOG.md | 9 ++ assets/agents/opencode-agent-designer.md | 2 +- assets/agents/opencode-command-crafter.md | 2 +- assets/agents/opencode-packager.md | 26 ++++- assets/agents/opencode-skill-creator.md | 2 +- assets/references/agents.md | 2 + assets/references/commands.md | 8 +- assets/references/conformance-checklist.md | 47 ++++++--- assets/references/skills.md | 2 + assets/templates/cli.template.txt | 28 +++--- assets/templates/index.template.txt | 11 ++- assets/templates/package-basics.template.json | 19 +++- assets/templates/package-full.template.json | 2 +- assets/templates/plugin-local.template.txt | 2 +- assets/templates/skill-structure.template.md | 12 +-- tests/deployment-plan.test.ts | 98 +++++++++++++++++++ 16 files changed, 222 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb1ede1..b4fde7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,14 +10,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Packager deployment-target choice: the packager first establishes the repo root (`git rev-parse --show-toplevel`, never the `.opencode/` directory) and resolves every source and target path from it; a generated package lays down in the current workspace root by default (files merged with consent, structural conflicts routed to `opencode-plugin-engineer`, sibling `../opencode-/` recommended only on explicit request or extensive merges) or in a sibling directory on request +- Mandatory frontmatter double-quoting (checklist D6): every frontmatter property value in every generated asset — `SKILL.md`, commands, agent definitions — is enclosed in double quotation marks (bare values non-conformant; native booleans/numbers excepted); rule carried in the skills/commands/agents references, the skill-structure template, and the three creator agents +- Generated packages ship a working CLI surface at packager time: `bin` entry (` → src/cli.ts`), `check`/`test` scripts, `src/installer.ts` + `src/cli.ts` created from templates, `runCli(argv)` export, and an `index.ts` dispatch shim — so every `bunx ...` advisory resolves before any publishing step +- Promoted-source retirement (checklist D9): after packaging `.opencode/` extensions, the packager establishes a live config reference to the package (surgical `plugin`/`skills.paths` write with consent), triggers and verifies the scope payload, then — with a printed list and explicit user confirmation — removes the promoted originals per item; the end state leaves `.opencode/` holding only the config file plus hook-managed payload and manifests (source `package.json`, lockfile, and `node_modules/` always removed, unrelated extensions untouched), and it never deletes with no reference in place or the whole `.opencode/` directory +- Packager self-audit gate: before reporting done, the packager verifies the actual tree — exact file inventory (`src/` module split), `plugin.ts` as the thin hook only (monolith pattern is a structural failure), package.json `bin`/`files`/`content`/scripts, single-line byte-for-byte badge row, D6 frontmatter quoting, `bun test` + `bunx tsc --noEmit` green, and the retirement end state — and fixes misses by rebuilding from templates; checklist D7 now fails multi-line or hand-rolled badge rows - Generated packages inherit the suite's cache hygiene, self-scoped (checklist A6): the installer template prunes its own `@latest`, `@`, and untagged cache copies on every install (best-effort warn-and-continue, including no-ops); the generated CLI template gains a self-only `clear-cache` subcommand (no `--package`/`--all`); the generated load-time failure advisory suggests `bunx clear-cache` instead of manual removal; the conformance checklist, packager, and publisher instructions require generated output to carry it; regression coverage asserts each template behavior - `clear-cache` CLI subcommand (manual-only, never invoked at load): with no flags it removes `opencode-architect` and every `opencode-architect@*` copy from OpenCode's package cache (`$XDG_CACHE_HOME/opencode/packages`, falling back to `~/.cache/opencode/packages`). `--package ` removes `` and every `@*`; `--all` removes the whole OpenCode cache directory; both broad modes require `--yes`, are mutually exclusive, and unsafe package names (path separators, `..`) are rejected. `--dry-run` lists what any mode would remove without deleting. Idempotent — nothing cached is a success — and removal failures warn without failing the command - `install` prunes this package's stale copies from OpenCode's package cache (`$XDG_CACHE_HOME/opencode/packages`, falling back to `~/.cache/opencode/packages`): `opencode-architect`, `opencode-architect@latest`, and `opencode-architect@` are removed on every install invocation, including no-ops, so OpenCode re-fetches the just-installed version on next start. Pinned versions and other packages' cache dirs are preserved; removal failures warn without failing the install, and cleared paths are reported - Conformance checklist items for the content-based deployment plan (ADR-0008): surgical config writes (B5), zero-write registration no-op (B6), content declaration present and consistent (E1), and binary mode enforcement (E2); E1–E2 and B5–B6 join the hard non-conformance set and the auditor's conformance review covers section E - Plugins and config references document the allowed config patterns (including global `config.json`), the repo-root `opencode.jsonc` create-default, and the surgical-writer rule +### Fixed + +- `plugin-local.template.txt` read the package version from the parent of the package dir (`${import.meta.dirname}/../package.json`); corrected to the package root, matching plugin.ts's actual location + ### Changed +- Packager README badge row is now byte-for-byte the publisher 4b markup: substituting custom badges ("Bun tested", "TypeScript", hand-rolled variants) is explicitly D7 non-conformant - **Breaking:** generated packages use package-root content directories — `skills//`, `commands/`, `agents/` sit at the package root with no `assets/` intermediary (`ASSET_LAYOUT_DIR = "."`), aligning generated repos with skills.sh-style root-`skills/` scanners; legacy `assets/`-wrapper packages remain recognized by the installer, checklist, auditor, and publisher - Generated `package.json` `files` lists the shipped content directories (`skills`, `commands`, plus `agents`/`plugins`/`tools`/`src` as present) instead of `assets` - **Breaking:** `install` is now a registration manager (plugin install is the only mode for this code-backed package, per [ADR-0008](docs/adr/0008-content-based-deployment-plans.md), superseding ADR-0004): the CLI ensures the `plugin` entry in the target scope's config file via the surgical editor — comments and formatting preserved, unparseable configs abort untouched — and writes a generalized manifest (version, mode, plugin entry, target config file) at the scope base. A matching manifest with the entry present is a zero-write no-op. `--mode copy` is refused with an explanatory error; `--force` now re-registers and rewrites the manifest instead of removing the entry diff --git a/assets/agents/opencode-agent-designer.md b/assets/agents/opencode-agent-designer.md index f77caa7..48e9d89 100644 --- a/assets/agents/opencode-agent-designer.md +++ b/assets/agents/opencode-agent-designer.md @@ -18,7 +18,7 @@ You create or refine OpenCode agents in `.opencode/agents/` as Markdown with YAM 1. Read `../references/prompt-engineering.md` for prompt-engineering techniques before drafting anything. 2. Consult `../references/agents.md` for agent fields, modes, tools, and permissions; `../references/tools.md` for tool IDs and behavior; `../references/config.md` for config precedence and defaults. -3. Write the frontmatter: description (required), mode (primary or subagent - set it explicitly), model (only when the user names one), temperature, maxSteps, tools, permission, hidden, as needed. +3. Write the frontmatter: description (required), mode (primary or subagent - set it explicitly), model (only when the user names one), temperature, maxSteps, tools, permission, hidden, as needed. Every frontmatter property value is enclosed in double quotation marks (checklist D6) — `mode: "subagent"`, never `mode: subagent` — except values the schema requires as native booleans or numbers. 4. Write the prompt in this order: role and scope boundaries first, then expected inputs and output format, then direct, specific instructions. 5. Reinforce the instructions where they fit: structure with headings and lists, critical instructions at the end, examples for ambiguous tasks and output formats, explicit constraints, structured outputs (JSON, XML) where precise parsing is needed, reasoning prompts for multi-step tasks, persistent context and persona for primary agents. 6. Scope capability to the job: tools block enables or disables specific tools, permission gates edit, bash, or webfetch, permission.task limits which subagents run, and the prompt scans no wider than the job requires. diff --git a/assets/agents/opencode-command-crafter.md b/assets/agents/opencode-command-crafter.md index 092b816..bc9f369 100644 --- a/assets/agents/opencode-command-crafter.md +++ b/assets/agents/opencode-command-crafter.md @@ -18,7 +18,7 @@ You create custom commands in `.opencode/commands/` as Markdown files with YAML 1. Read `../references/prompt-engineering.md` for prompt-engineering techniques before drafting anything. 2. Consult `../references/commands.md` for frontmatter keys and templating while you write. -3. Create or update the command file: frontmatter carries description, agent, model, subtask as needed; the filename becomes the command name; the body is the prompt template. +3. Create or update the command file: frontmatter carries description, agent, model, subtask as needed; the filename becomes the command name; the body is the prompt template. Every frontmatter property value is enclosed in double quotation marks (checklist D6) — `agent: "build"`, never `agent: build` — except values the schema requires as native booleans or numbers. 4. Verify the template features are used where they resolve at run time: '$ARGUMENTS' for full args, '$1', '$2', '$3' for positional args, '!command' to inject shell output into the prompt, '@path/to/file' to include file content. Done when the command file exists and every placeholder in its template is valid. diff --git a/assets/agents/opencode-packager.md b/assets/agents/opencode-packager.md index f28fc43..76fc2a2 100644 --- a/assets/agents/opencode-packager.md +++ b/assets/agents/opencode-packager.md @@ -45,13 +45,29 @@ opencode-myextension/ └── tsconfig.json ``` -6. **Create plugin.ts** from `../templates/plugin-local.template.txt`, plus `src/plugin-name.ts` from `../templates/plugin-name.template.txt`, `src/manifest.ts` from `../templates/manifest.template.txt`, `src/registration.ts` from `../templates/registration.template.txt`, and `src/plugin-config.ts` from `../templates/plugin-config.template.txt`: the load hook performs read-only registration-scope detection, then ensures skills, commands, and agents only for the scopes where the plugin is registered, gated by the per-scope install manifest (version, mode, registration, per-file sha256). Never write outside the detected scopes, never edit `plugin` arrays or root configs at load, and never rewrite a config that fails to parse. The install logic is content-based (ADR-0008): the same installer serves both the global scope base and the project scope base with identical behavior — copy install touches no config, plugin registration goes through the surgical editor, and code-backed packages refuse `--mode copy`. Cache hygiene is self-scoped (checklist A6): the installer template prunes this package's own cache copies on every install (warn-and-continue, other packages untouched), and the hook's failure advisory tells consumers to run `bunx clear-cache` — the hook itself never deletes cache entries. +6. **Create plugin.ts** from `../templates/plugin-local.template.txt`, plus `src/plugin-name.ts` from `../templates/plugin-name.template.txt`, `src/manifest.ts` from `../templates/manifest.template.txt`, `src/registration.ts` from `../templates/registration.template.txt`, `src/plugin-config.ts` from `../templates/plugin-config.template.txt`, `src/installer.ts` from `../templates/installer.template.txt` (plugin.ts imports it), and `src/cli.ts` from `../templates/cli.template.txt`: the load hook performs read-only registration-scope detection, then ensures skills, commands, and agents only for the scopes where the plugin is registered, gated by the per-scope install manifest (version, mode, registration, per-file sha256). Never write outside the detected scopes, never edit `plugin` arrays or root configs at load, and never rewrite a config that fails to parse. The install logic is content-based (ADR-0008): the same installer serves both the global scope base and the project scope base with identical behavior — copy install touches no config, plugin registration goes through the surgical editor, and code-backed packages refuse `--mode copy`. Cache hygiene is self-scoped (checklist A6): the installer template prunes this package's own cache copies on every install (warn-and-continue, other packages untouched), and the hook's failure advisory tells consumers to run `bunx clear-cache` — the hook itself never deletes cache entries. -7. **Create package.json** from `../templates/package-basics.template.json` and tsconfig.json from `../templates/tsconfig.template.json`. The template ships with `"content": "assets"`; the asset inventory is the source of truth for the declaration (ADR-0008): keep `"assets"` only when the package ships skills and/or commands alone; set `"code"` when the source contained any agent, tool, plugin, hook, or other plugin integration — code-backed packages may also ship skills and commands. The `files` list names the content directories actually shipped at the package root (`skills`, `commands`; add `agents`, `plugins`, `tools`, `src` as present) — never an `assets` entry. Report the decision: "Content declaration: code (1 agent, 2 tools found)" or "Content declaration: assets (skills and commands only)". +7. **Create package.json** from `../templates/package-basics.template.json` and tsconfig.json from `../templates/tsconfig.template.json`. The template ships with `"content": "assets"`; the asset inventory is the source of truth for the declaration (ADR-0008): keep `"assets"` only when the package ships skills and/or commands alone; set `"code"` when the source contained any agent, tool, plugin, hook, or other plugin integration — code-backed packages may also ship skills and commands. The `files` list names the content directories actually shipped at the package root (`skills`, `commands`, plus `agents`, `plugins`, `tools`, `src` as present) — never an `assets` entry. The `bin` entry (`"": "src/cli.ts"`) and the `check`/`test` scripts ship at packager time: every load-hook advisory and README instruction says `bunx ...`, so the entry point must resolve before any publishing step. Report the decision: "Content declaration: code (1 agent, 2 tools found)" or "Content declaration: assets (skills and commands only)". -8. **Create the README badge row.** Build the package's `README.md` with the badge row directly below the first heading (a tagline between heading and badges is non-conformant). Emit or verify the row exactly as specified in opencode-publisher step 4b; include the DeepWiki badge only when the repo is indexed (confirm via a `deepwiki.com//` fetch or a DeepWiki MCP query — never assume). The publisher re-verifies this row; emitting it here keeps locally-used packages conformant too. +8. **Create the README badge row.** Build the package's `README.md` with the badge row directly below the first heading (a tagline between heading and badges is non-conformant). The row is exactly the publisher step 4b markup — npm version, Bun runtime, License, Platforms (URL-encoded, matching the repo's actual platforms), the fixed OpenCode plugin badge, and DeepWiki when indexed (confirm via a `deepwiki.com//` fetch or a DeepWiki MCP query — never assume). Never substitute custom badges: no "Bun tested", "TypeScript", or other hand-rolled variants, no extra badges, none missing — any deviation from the 4b markup is D7 non-conformant. Include the License badge only when the repo is MIT-licensed, adjusting label and color otherwise; omit DeepWiki only when the repo is not indexed. The publisher re-verifies this row; emitting it here keeps locally-used packages conformant too. -Done when the target tree matches step 5 and the plugin performs a zero-write no-op on a start where every registered scope's manifest matches the running version. +9. **Retire the promoted source.** The original `.opencode/` extensions this package was built from are removed only after the package provably serves the content, in this order: + + a. **Ensure the reference.** Inspect the scope configs (project `.opencode/opencode.json(c)`, repo-root `opencode.json(c)`, both extensions) for a live reference to the new package — a `plugin` entry (`file:///` URL or package name) or, for skills-only packages, a `skills.paths` entry pointing into the package. When none exists, offer to add one through the surgical `PluginConfigEditor` (user consents to scope and form; entries validated against the config schema; canonical `file:///` form). If the user declines or the write is blocked, stop — report that the source was left in place and why. Never delete with nothing pointing at the package: the extension would silently vanish from the next start. + b. **Ensure and verify the payload.** Trigger the install path (`bun run index.ts install --scope local`, or the load hook on a start) and verify the scope now holds the content — the install manifest present and the promoted files (e.g. `skills//SKILL.md`) on disk matching the packaged copies. Deletion never precedes a verified payload. + c. **List, get consent, delete.** Print every item slated for removal — the promoted skill folders, command files, agent files, merged plugin/tool sources, plus the source `.opencode/package.json`, its lockfile, and `node_modules/` when the dependencies were merged into the package — and delete only after the user explicitly confirms. Remove per item, never the whole `.opencode/` directory, and never touch unrelated extensions — anything the user did not promote stays. Outside a git repo, warn that removal is unrecoverable before asking. End state: apart from unrelated extensions, `.opencode/` holds only the config file holding the reference (`opencode.json(c)` — there or at the repo root) plus what the load hook manages from here on (ensured payload copies and `*.manifest.json`); every source artifact — promoted originals, `package.json`, lockfile, `node_modules/` — is gone. + +10. **Self-audit gate — verify before reporting done.** Do not trust the plan; read the tree. Verify every item below by inspecting the actual files, fix any miss (rebuilding from the templates, not hand-patching), and only then report done. A gate item you cannot verify is reported as a failure, never waved through. + + - **File inventory.** `index.ts` (re-export + `import.meta.main` CLI shim), `plugin.ts`, `src/plugin-name.ts`, `src/manifest.ts`, `src/registration.ts`, `src/plugin-config.ts`, `src/installer.ts`, `src/cli.ts`, the content directories actually shipped (`skills//SKILL.md`, plus `commands/`, `agents/`, `plugins/`, `tools/` as present), `package.json`, `tsconfig.json`, `README.md`, `AGENTS.md`, `tests/`. + - **plugin.ts is the thin hook.** It imports the installer and registration detector from the `src/` modules and contains advisory + detection logic only. A `plugin.ts` that inlines the manifest, installer, registration detector, or config-editor code (the monolith pattern) is a structural failure — restructure into the `src/` modules from the templates. + - **package.json.** `bin` maps `` to `src/cli.ts`; `files` lists the shipped root content directories plus `src` (never `assets`); `content` declaration matches the inventory; `check`/`test` scripts present. + - **Badge row.** One single line, directly below the first heading, byte-for-byte the publisher 4b badges (npm version, Bun runtime, License, URL-encoded Platforms, fixed OpenCode plugin badge, DeepWiki when indexed). Multi-line rows and custom badges are failures. + - **Frontmatter.** Every frontmatter property value in every shipped markdown file is enclosed in double quotation marks (D6). + - **Gates.** `bun test` and `bunx tsc --noEmit` run green in the package. + - **Retirement end state.** The config file holding the reference exists; no promoted source items, no source `package.json`/lockfile/`node_modules` remain; `.opencode/` contains only the config plus hook-managed payload and manifests. + +Done when every self-audit gate item verifies against the tree and the plugin performs a zero-write no-op on a start where every registered scope's manifest matches the running version. ## Templates @@ -62,6 +78,8 @@ Done when the target tree matches step 5 and the plugin performs a zero-write no - `../templates/manifest.template.txt` - `../templates/registration.template.txt` - `../templates/plugin-config.template.txt` +- `../templates/installer.template.txt` +- `../templates/cli.template.txt` - `../templates/tsconfig.template.json` - `../templates/skill-structure.template.md` diff --git a/assets/agents/opencode-skill-creator.md b/assets/agents/opencode-skill-creator.md index dcf8fee..c6db37c 100644 --- a/assets/agents/opencode-skill-creator.md +++ b/assets/agents/opencode-skill-creator.md @@ -19,7 +19,7 @@ You create skills in `.opencode/skills//SKILL.md`. 1. Read `../references/prompt-engineering.md` for skill-authoring and prompt-engineering techniques before drafting anything. 2. Consult `../references/skills.md` for frontmatter fields and naming rules while you write. 3. Create the skill folder and SKILL.md. -4. Verify the contract: frontmatter carries name and description; name is lowercase alphanumeric with single hyphens and matches the folder name; description is 1-1024 characters, written in third person, and states what the skill does and when to use it. +4. Verify the contract: frontmatter carries name and description; name is lowercase alphanumeric with single hyphens and matches the folder name; description is 1-1024 characters, written in third person, and states what the skill does and when to use it; every frontmatter property value is enclosed in double quotation marks (checklist D6) — `name: "world-greeter"`, never `name: world-greeter`. ## Writing rules diff --git a/assets/references/agents.md b/assets/references/agents.md index 4566e05..b2cb05d 100644 --- a/assets/references/agents.md +++ b/assets/references/agents.md @@ -15,6 +15,8 @@ The filename becomes the agent name (`review.md` → `review` agent). The markdo ## Frontmatter fields +Every frontmatter property value is enclosed in double quotation marks — `description: "..."`, `mode: "subagent"` — never bare values; the only exception is a value the schema requires as a native boolean or number (checklist D6). + | Field | Required | Notes | | --- | --- | --- | | `description` | yes | What the agent does and when to use it. Drives subagent selection. | diff --git a/assets/references/commands.md b/assets/references/commands.md index 1f37060..467eb10 100644 --- a/assets/references/commands.md +++ b/assets/references/commands.md @@ -15,15 +15,17 @@ The frontmatter defines properties; the body is the prompt template. ```markdown --- -description: Run tests with coverage -agent: build -model: anthropic/claude-haiku-4-5 +description: "Run tests with coverage" +agent: "build" +model: "anthropic/claude-haiku-4-5" --- Run the full test suite with coverage report and show any failures. Focus on the failing tests and suggest fixes. ``` +Every frontmatter property value is enclosed in double quotation marks — never bare values (checklist D6). + ## Frontmatter keys | Key | Required | Notes | diff --git a/assets/references/conformance-checklist.md b/assets/references/conformance-checklist.md index 90792c5..3148e74 100644 --- a/assets/references/conformance-checklist.md +++ b/assets/references/conformance-checklist.md @@ -151,20 +151,24 @@ finding. - **D5 One-shot advisory.** Any "not installed, run bunx … install" notice fires at most once per session and is suppressed when any scope holds an install. -- **D6 Frontmatter hygiene.** Frontmatter values in every shipped markdown - file (agent definitions, `SKILL.md`, command files) contain no colons: - a value that needs a colon (URLs, `provider/model-id`, sentences with - colons) is rewritten or the value is enclosed in double quotes. Where - possible, all frontmatter string values are double-quoted. Unquoted - values containing `:` are non-conformant — YAML parses them as mappings - or fails validation. -- **D7 README badge row.** The package README carries, directly below the - first heading, the badge row: npm version, Bun runtime, license, - platforms (URL-encoded, matching the repo's actual platforms), the fixed - OpenCode plugin badge, and DeepWiki when indexed. Badge URLs use the - exact package name and repo casing (`My-Org/pkg` ≠ `my-org/pkg`). A - missing badge row, extra runtime badges, or mismatched casing is - non-conformant. +- **D6 Frontmatter hygiene.** In every shipped markdown file (agent + definitions, `SKILL.md`, command files) **every frontmatter property + value is enclosed in double quotation marks** — bare values are + non-conformant: `mode: subagent` fails, `mode: "subagent"` conforms. + Quoting is mandatory, not best-effort, and applies to names, + descriptions, enum values, and everything else. The only exception is a + value the consuming schema requires as a native YAML boolean or number + (e.g. `subtask: true`, `temperature: 0.2`). A value that needs a colon + (URLs, `provider/model-id`, sentences with colons) stays inside its + double quotes; an unquoted value containing `:` is doubly + non-conformant — YAML parses it as a mapping or fails validation. +- **D7 README badge row.** The package README carries, on **one single + line** directly below the first heading, the badge row: npm version, Bun + runtime, license, platforms (URL-encoded, matching the repo's actual + platforms), the fixed OpenCode plugin badge, and DeepWiki when indexed. + Badge URLs use the exact package name and repo casing (`My-Org/pkg` ≠ + `my-org/pkg`). A missing badge row, a multi-line row, extra runtime + badges, hand-rolled variants, or mismatched casing is non-conformant. - **D8 Consumer snippet key validity.** Every `opencode.json` snippet the package ships (README, AGENTS.md, CONTRIBUTING, CLI help) uses the @@ -172,7 +176,20 @@ finding. `name@latest` or a `file:///` URL. Verify emitted keys against `https://opencode.ai/config.json` (the `Config` definition sets `additionalProperties: false`, so an invalid key is rejected at load). A - shipped snippet using an invalid key is non-conformant. + shipped snippet using an invalid key is non-conformant. + +- **D9 Promoted-source retirement.** When a package is created from + existing `.opencode/` extensions, the originals are removed only after + (1) a live config reference to the package exists — a `plugin` entry or + `skills.paths`, surgically written with user consent — and (2) the + scope's payload is verified on disk (install manifest present, files + match the packaged copies). Deletion is per-item with a printed list and + explicit user consent — never the whole `.opencode/` directory, never + unrelated extensions, and never with no reference in place (the + extension would silently vanish from the next start). The end state + leaves `.opencode/` holding only the config file plus hook-managed + payload and manifests: the source `package.json`, lockfile, + `node_modules/`, and every promoted original are gone. ## E. Deployment plan (ADR-0008) diff --git a/assets/references/skills.md b/assets/references/skills.md index 1e97015..e6e063a 100644 --- a/assets/references/skills.md +++ b/assets/references/skills.md @@ -22,6 +22,8 @@ Only these fields are recognized; unknown fields are ignored: If a skill does not show up: verify `SKILL.md` capitalization, required frontmatter, unique names across locations, and that permissions don't `deny` it. +Every frontmatter property value is enclosed in double quotation marks — `name: "world-greeter"`, `description: "Greets in five languages."` — never bare values (checklist D6). + ## Progressive disclosure - Metadata (name + description) is pre-loaded at startup; the body is read on demand; bundled files are read only as needed — no context penalty until accessed. diff --git a/assets/templates/cli.template.txt b/assets/templates/cli.template.txt index e8be058..855c319 100644 --- a/assets/templates/cli.template.txt +++ b/assets/templates/cli.template.txt @@ -7,6 +7,8 @@ | `COMMANDS` | Yes | install / uninstall / status / migrate / clear-cache subcommands | — | | `SCOPE` → "local" | Yes | Default scope when `--scope` is not specified | `local` | +The module exports `runCli(argv)` and runs standalone under its shebang via the `import.meta.main` guard — both `bunx ` (through the package.json `bin` entry) and the `index.ts` dispatch shim resolve to the same `runCli`. + **Load-bearing — do not simplify:** the deployment plan is content-based, so `install` resolves the mode through `resolveMode` and never forces one: assets-only packages default to copy and accept `--mode plugin`; code-backed packages always register and `--mode copy` surfaces the `CopyModeUnsupportedError` as a hard, explanatory exit-1 error; `install` forwards `force` so consumer-modified files are skipped without it; `status` reports mode, version, entry, entry config file, and registration presence per scope; `migrate` refuses to run without `--force` (root-config deletion is consent-gated and CLI-only); `clear-cache` is self-only — it removes this package's own cache copies (`` and every `@*`) from OpenCode's package cache, is idempotent (nothing cached is a success), warns and continues on per-target removal failures, and must never grow `--package` or `--all` modes (other packages' cache and the shared OpenCode cache dir are out of scope for a generated package); the module lives at `src/cli.ts` and imports `./installer.ts`. --- @@ -51,8 +53,9 @@ function isInstallMode(value: string): value is InstallMode { return value === "copy" || value === "plugin"; } -async function main(): Promise { +export async function runCli(argv: string[]): Promise { const { positionals, values } = parseArgs({ + args: argv, options: { scope: { type: "string", short: "s" }, mode: { type: "string", short: "m" }, @@ -64,8 +67,8 @@ async function main(): Promise { strict: true, }); - if (values.version) { console.log(`opencode-myextension v${VERSION}`); process.exit(0); } - if (values.help || positionals.length === 0) { printHelp(); process.exit(0); } + if (values.version) { console.log(`opencode-myextension v${VERSION}`); return 0; } + if (values.help || positionals.length === 0) { printHelp(); return 0; } const command = positionals[0]; const scope: Scope | undefined = values.scope as Scope | undefined; @@ -75,11 +78,11 @@ async function main(): Promise { if (scope && scope !== "local" && scope !== "global") { console.error(`Invalid scope: ${scope}. Must be "local" or "global".`); - process.exit(1); + return 1; } if (mode === ("invalid" as InstallMode)) { console.error(`Invalid mode: ${values.mode}. Must be "copy" or "plugin".`); - process.exit(1); + return 1; } try { @@ -127,7 +130,7 @@ async function main(): Promise { case "migrate": { if (!force) { console.error("Migration deletes the root opencode.json. Re-run with --force to consent."); - process.exit(1); + return 1; } const migrated = await migrateRootConfig(process.cwd(), async () => true); console.log(migrated ? "Migrated opencode.json into .opencode/opencode.json" : "Nothing to migrate"); @@ -136,7 +139,7 @@ async function main(): Promise { case "clear-cache": { if (positionals.length > 1) { console.error(`Unexpected arguments for clear-cache: ${positionals.slice(1).join(" ")}`); - process.exit(1); + return 1; } const outcome = await clearPackageCache(); if (outcome.removed.length === 0) { @@ -150,17 +153,20 @@ async function main(): Promise { } break; } - default: console.error(`Unknown command: ${command}`); printHelp(); process.exit(1); + default: console.error(`Unknown command: ${command}`); printHelp(); return 1; } + return 0; } catch (error) { if (error instanceof CopyModeUnsupportedError) { console.error(`Error: ${error.message}`); - process.exit(1); + return 1; } const message = error instanceof Error ? error.message : String(error); console.error(`Error: ${message}`); - process.exit(1); + return 1; } } -main(); +if (import.meta.main) { + process.exitCode = await runCli(process.argv.slice(2)); +} diff --git a/assets/templates/index.template.txt b/assets/templates/index.template.txt index 1a10d06..79d25d2 100644 --- a/assets/templates/index.template.txt +++ b/assets/templates/index.template.txt @@ -11,5 +11,14 @@ SKILL NAME COMMAND NAME "my-command.md" → your command file name in commands/ +PACKAGE NAME + "opencode-myextension" → must match package.json; the bin entry points + here so `bunx ` works + --- -export { default } from "./plugin.ts"; \ No newline at end of file +export { default } from "./plugin.ts"; + +if (import.meta.main) { + const { runCli } = await import("./src/cli.ts"); + process.exitCode = await runCli(process.argv.slice(2)); +} diff --git a/assets/templates/package-basics.template.json b/assets/templates/package-basics.template.json index 09e4c9d..9450c17 100644 --- a/assets/templates/package-basics.template.json +++ b/assets/templates/package-basics.template.json @@ -16,10 +16,16 @@ PATHS "commands/my-command.md" → path to your command file FILES - ["index.ts", "plugin.ts", "skills", "commands"] - → list the content directories actually shipped; - add "agents", "plugins", "tools", or "src" - when the package ships them + ["index.ts", "plugin.ts", "skills", "commands", "src"] + → list the content directories actually shipped + (src/ always ships the CLI); add "agents", + "plugins", or "tools" when the package + ships them + +BIN + "src/cli.ts" → the bunx entry point; keeps every + `bunx ...` advisory working at + packager time, not only after publishing CONTENT DECLARATION "assets" → keep when the package ships only skills and/or commands @@ -39,5 +45,8 @@ METADATA "content": "assets", "type": "module", "module": "index.ts", - "files": ["index.ts", "plugin.ts", "skills", "commands"] + "bin": { + "opencode-myextension": "src/cli.ts" + }, + "files": ["index.ts", "plugin.ts", "skills", "commands", "src"] } \ No newline at end of file diff --git a/assets/templates/package-full.template.json b/assets/templates/package-full.template.json index c7c531e..3250763 100644 --- a/assets/templates/package-full.template.json +++ b/assets/templates/package-full.template.json @@ -41,7 +41,7 @@ METADATA "type": "module", "module": "index.ts", "bin": { - "opencode-myextension": "./src/cli.ts" + "opencode-myextension": "src/cli.ts" }, "repository": { "type": "git", diff --git a/assets/templates/plugin-local.template.txt b/assets/templates/plugin-local.template.txt index f7684be..945b424 100644 --- a/assets/templates/plugin-local.template.txt +++ b/assets/templates/plugin-local.template.txt @@ -34,7 +34,7 @@ async function adviseFailureOnce(message: string): Promise { try { let version = ""; try { - version = JSON.parse(await Bun.file(`${import.meta.dirname}/../package.json`).text()).version ?? version; + version = JSON.parse(await Bun.file(`${import.meta.dirname}/package.json`).text()).version ?? version; } catch {} text = `MyExtension load-time install failed. Run: bunx ${PACKAGE_NAME} clear-cache, then: bunx ${PACKAGE_NAME} install --scope global. The stale cache copy is ~/.cache/opencode/packages/${PACKAGE_NAME}@${version}. Cause: ${message}`; } catch { diff --git a/assets/templates/skill-structure.template.md b/assets/templates/skill-structure.template.md index 62fc467..0685d7b 100644 --- a/assets/templates/skill-structure.template.md +++ b/assets/templates/skill-structure.template.md @@ -17,13 +17,13 @@ DESCRIPTION --- --- -name: myextension -description: Use this skill when the user asks about... -license: MIT -compatibility: opencode +name: "myextension" +description: "Use this skill when the user asks about..." +license: "MIT" +compatibility: "opencode" metadata: - version: 1.0.0 - audience: agents + version: "1.0.0" + audience: "agents" topic: "topic1, topic2" --- diff --git a/tests/deployment-plan.test.ts b/tests/deployment-plan.test.ts index 27bbcc2..b9b2d2d 100644 --- a/tests/deployment-plan.test.ts +++ b/tests/deployment-plan.test.ts @@ -199,6 +199,104 @@ describe("package-root content layout (no assets/ wrapper)", () => { }); }); +describe("generated CLI surface", () => { + test("plugin-local reads version from the package root, not its parent", async () => { + const source = await readTemplate("plugin-local.template.txt"); + expect(source).toContain("${import.meta.dirname}/package.json"); + expect(source).not.toContain("${import.meta.dirname}/../package.json"); + }); + + test("index template dispatches to the CLI; cli template exports runCli", async () => { + const index = await readTemplate("index.template.txt"); + expect(index).toContain("import.meta.main"); + expect(index).toContain("runCli"); + const cli = await readTemplate("cli.template.txt"); + expect(cli).toContain("export async function runCli"); + expect(cli).toContain("import.meta.main"); + }); + + test("package templates declare the bunx entry at packager time", async () => { + for (const template of ["package-basics.template.json", "package-full.template.json"]) { + const source = await readTemplate(template); + const json = JSON.parse(source.slice(source.indexOf("{"))); + expect(json.bin["opencode-myextension"]).toBe("src/cli.ts"); + } + }); + + test("packager requires the exact publisher badge row, the bin entry, and the full src set", async () => { + const packager = await readAgent("opencode-packager.md"); + expect(packager).toContain("Never substitute custom badges"); + expect(packager).toContain("src/cli.ts"); + expect(packager).toContain("installer.template.txt"); + expect(packager).toContain("cli.template.txt"); + }); +}); + +describe("frontmatter hygiene (mandatory double-quoting)", () => { + test("checklist D6 requires every frontmatter value to be double-quoted", async () => { + const checklist = await readReference("conformance-checklist.md"); + expect(checklist).toContain("enclosed in double quotation marks"); + expect(checklist).toContain("Quoting is mandatory"); + }); + + test("references and templates model quoted frontmatter", async () => { + const structure = await readTemplate("skill-structure.template.md"); + expect(structure).toContain('name: "myextension"'); + expect(structure).toContain('audience: "agents"'); + const commands = await readReference("commands.md"); + expect(commands).toContain('description: "Run tests with coverage"'); + const skills = await readReference("skills.md"); + expect(skills).toContain("double quotation marks"); + const agents = await readReference("agents.md"); + expect(agents).toContain("double quotation marks"); + }); + + test("creator agents enforce the quoting rule", async () => { + for (const agent of ["opencode-skill-creator.md", "opencode-command-crafter.md", "opencode-agent-designer.md"]) { + const source = await readAgent(agent); + expect(source).toContain("double quotation marks"); + } + }); +}); + +describe("promoted-source retirement", () => { + test("packager retires the source only after a verified payload and consent", async () => { + const packager = await readAgent("opencode-packager.md"); + expect(packager).toContain("Retire the promoted source"); + expect(packager).toContain("Never delete with nothing pointing at the package"); + expect(packager).toContain("Deletion never precedes a verified payload"); + expect(packager).toContain("never the whole `.opencode/` directory"); + expect(packager).toContain("PluginConfigEditor"); + expect(packager).toContain("every source artifact"); + expect(packager).toContain("plus hook-managed payload and manifests"); + }); + + test("checklist covers promoted-source retirement (D9)", async () => { + const checklist = await readReference("conformance-checklist.md"); + expect(checklist).toContain("**D9 Promoted-source retirement.**"); + expect(checklist).toContain("never with no reference in place"); + expect(checklist).toContain("plus hook-managed\n payload and manifests"); + }); +}); + +describe("packager self-audit gate", () => { + test("done requires tree-verified inventory, thin plugin.ts, single-line badge row, and green gates", async () => { + const packager = await readAgent("opencode-packager.md"); + expect(packager).toContain("Self-audit gate"); + expect(packager).toContain("plugin.ts is the thin hook"); + expect(packager).toContain("monolith pattern"); + expect(packager).toContain("One single line, directly below the first heading"); + expect(packager).toContain("`bun test` and `bunx tsc --noEmit` run green"); + expect(packager).toContain("Done when every self-audit gate item verifies against the tree"); + }); + + test("checklist D7 requires the badge row on one single line", async () => { + const checklist = await readReference("conformance-checklist.md"); + expect(checklist).toContain("**one single\n line**"); + expect(checklist).toContain("a multi-line row"); + }); +}); + async function readTemplate(name: string): Promise { const source = await readFile(path.join(REPO_ROOT, "assets/templates", name), "utf-8"); return source.split("---").slice(1).join("---");