diff --git a/.opencode/.gitignore b/.opencode/.gitignore index 20507c0..37a4d41 100644 --- a/.opencode/.gitignore +++ b/.opencode/.gitignore @@ -1,5 +1,6 @@ node_modules package.json +package-lock.json bun.lock docs diff --git a/.opencode/opencode.json b/.opencode/opencode.json index e913a75..935b625 100644 --- a/.opencode/opencode.json +++ b/.opencode/opencode.json @@ -1,6 +1,6 @@ { "$schema": "https://opencode.ai/config.json", "plugin": [ - "../index.ts", + "../index.ts" ] } \ No newline at end of file diff --git a/.opencode/package-lock.json b/.opencode/package-lock.json deleted file mode 100644 index 7215230..0000000 --- a/.opencode/package-lock.json +++ /dev/null @@ -1,399 +0,0 @@ -{ - "name": ".opencode", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "dependencies": { - "@opencode-ai/plugin": "1.18.31" - } - }, - "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.31", - "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.18.31.tgz", - "integrity": "sha512-Rdc1bPK06PByaGyGd0kf7JUZ4pTkexz2OOUNlqZWLpHOiMEZ+/rFGjt46ypZ3QwA697gNdWwwwQbHbKG5NMwGA==", - "license": "MIT", - "dependencies": { - "@ai-sdk/provider": "3.0.8", - "@opencode-ai/sdk": "1.18.31", - "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.31", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.18.31.tgz", - "integrity": "sha512-Raouthf8Lhe9edjvYeeSK7SgvdoU6bBjH9qV3f70dHoa6h+z0X2TMz/e22/wKp/StlFUZ4kIRpYYxFnY8/k01w==", - "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/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/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", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/CONTEXT.md b/CONTEXT.md index e4871e0..3bb4e37 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -203,11 +203,11 @@ taking ownership of them is CLI-only with an explicit force flag. ### Bundled files **References**: -The static markdown guides bundled with the package at `assets/references/`, +The static markdown guides bundled with the package at `references/`, covering stable OpenCode fundamentals. **Templates**: -The static scaffolding files bundled with the package at `assets/templates/`, +The static scaffolding files bundled with the package at `templates/`, from which the packager and publisher render a generated package's code files (plugin entry, manifest, name normalizer, registration detector, CLI, installer). The structural source of truth for generated packages: example diff --git a/README.md b/README.md index 1a795b9..9ac21ad 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,5 @@ # opencode-architect - -[![npm version](https://img.shields.io/npm/v/opencode-architect)](https://www.npmjs.com/package/opencode-architect) -[![License: MIT](https://img.shields.io/npm/l/opencode-architect)](./LICENSE.md) -[![OpenCode plugin](https://img.shields.io/badge/opencode-plugin-blueviolet)](https://opencode.ai/docs/plugins) +[![npm version](https://img.shields.io/npm/v/opencode-architect?color=cb3837&label=npm)](https://www.npmjs.com/package/opencode-architect) [![Bun](https://img.shields.io/badge/Runtime-Bun-f9f1e1?logo=bun&logoColor=black)](https://bun.sh) [![License: MIT](https://img.shields.io/badge/License-MIT-22c55e)](LICENSE.md) [![Platforms](https://img.shields.io/badge/Platforms-Linux-6366f1)](#quick-start-install-the-opencode-plugin-suite) [![OpenCode plugin](https://img.shields.io/badge/opencode-plugin-blueviolet)](https://opencode.ai/docs/plugins) **Ten specialist agents that design, build, and package OpenCode extensions — agent skills, slash commands, custom tools, plugins, and MCP server integrations — right inside your AI coding assistant.** @@ -16,7 +13,7 @@ Add the package to the `plugin` array in your OpenCode config — `.opencode/ope ```json { - "plugin": ["opencode-architect"] + "plugin": ["opencode-architect@latest"] } ``` @@ -24,7 +21,7 @@ The plugin registers the full agent suite at startup, with self-contained bundle ### Option 2 — Install with the CLI (bunx or npx) -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. +The CLI registers the package as a plugin: it adds `opencode-architect@latest` 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.manifest.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): edits the ./.opencode/ or repo-root config diff --git a/assets/agents/opencode-agent-designer.md b/agents/opencode-agent-designer.md similarity index 99% rename from assets/agents/opencode-agent-designer.md rename to agents/opencode-agent-designer.md index 48e9d89..8522fc7 100644 --- a/assets/agents/opencode-agent-designer.md +++ b/agents/opencode-agent-designer.md @@ -1,6 +1,6 @@ --- description: "Designs OpenCode agents and orchestrator subagents - roles, constraints, tools, permissions" -mode: subagent +mode: "subagent" tools: read: true write: true diff --git a/assets/agents/opencode-architect.md b/agents/opencode-architect.md similarity index 99% rename from assets/agents/opencode-architect.md rename to agents/opencode-architect.md index 1dcb12c..8b5d45a 100644 --- a/assets/agents/opencode-architect.md +++ b/agents/opencode-architect.md @@ -1,6 +1,6 @@ --- description: "Routes OpenCode meta tasks to specialist subagents - agents, skills, commands, tools, plugins, MCP setup, packaging, publishing" -mode: primary +mode: "primary" tools: read: true write: true diff --git a/assets/agents/opencode-command-crafter.md b/agents/opencode-command-crafter.md similarity index 98% rename from assets/agents/opencode-command-crafter.md rename to agents/opencode-command-crafter.md index bc9f369..731f55c 100644 --- a/assets/agents/opencode-command-crafter.md +++ b/agents/opencode-command-crafter.md @@ -1,6 +1,6 @@ --- description: "Creates OpenCode slash commands in .opencode/commands - prompt templates, $ARGUMENTS, frontmatter" -mode: subagent +mode: "subagent" tools: read: true write: true diff --git a/assets/agents/opencode-extension-auditor.md b/agents/opencode-extension-auditor.md similarity index 95% rename from assets/agents/opencode-extension-auditor.md rename to agents/opencode-extension-auditor.md index 6a9ba50..a231b6f 100644 --- a/assets/agents/opencode-extension-auditor.md +++ b/agents/opencode-extension-auditor.md @@ -1,20 +1,20 @@ --- description: "Analyzes .opencode/ contents for packaging readiness, and reviews existing plugin packages for conformance to this suite's design - inventory, dependencies, complications, conformance verdict" -mode: subagent +mode: "subagent" tools: read: true glob: true grep: true permission: bash: - "*": deny - "bun test*": allow - "bun run check*": allow - "bun -e *": allow - "node -e *": allow - "git diff*": allow - "git log*": allow - "git status": allow + "*": "deny" + "bun test*": "allow" + "bun run check*": "allow" + "bun -e *": "allow" + "node -e *": "allow" + "git diff*": "allow" + "git log*": "allow" + "git status": "allow" --- Prefer Exa MCP over default websearch tools and grepai MCP over default codebase search tools, when available. diff --git a/assets/agents/opencode-mcp-integrator.md b/agents/opencode-mcp-integrator.md similarity index 98% rename from assets/agents/opencode-mcp-integrator.md rename to agents/opencode-mcp-integrator.md index 54bb112..acb3d28 100644 --- a/assets/agents/opencode-mcp-integrator.md +++ b/agents/opencode-mcp-integrator.md @@ -1,6 +1,6 @@ --- description: "Configures MCP servers and tool scoping in opencode.json - local/remote servers, permissions" -mode: subagent +mode: "subagent" tools: read: true write: false diff --git a/assets/agents/opencode-packager.md b/agents/opencode-packager.md similarity index 99% rename from assets/agents/opencode-packager.md rename to agents/opencode-packager.md index 76fc2a2..11ec472 100644 --- a/assets/agents/opencode-packager.md +++ b/agents/opencode-packager.md @@ -1,6 +1,6 @@ --- description: "Packages OpenCode extensions for local sharing across projects - file:/// plugin packages" -mode: subagent +mode: "subagent" tools: read: true write: true diff --git a/assets/agents/opencode-plugin-engineer.md b/agents/opencode-plugin-engineer.md similarity index 99% rename from assets/agents/opencode-plugin-engineer.md rename to agents/opencode-plugin-engineer.md index f7b8b54..e3c020f 100644 --- a/assets/agents/opencode-plugin-engineer.md +++ b/agents/opencode-plugin-engineer.md @@ -1,6 +1,6 @@ --- description: "Builds OpenCode plugins in .opencode/plugins - event hooks, custom tools, TypeScript" -mode: subagent +mode: "subagent" tools: read: true write: true diff --git a/assets/agents/opencode-publisher.md b/agents/opencode-publisher.md similarity index 99% rename from assets/agents/opencode-publisher.md rename to agents/opencode-publisher.md index 8e390e2..581928a 100644 --- a/assets/agents/opencode-publisher.md +++ b/agents/opencode-publisher.md @@ -1,6 +1,6 @@ --- description: "Publishes OpenCode extensions to npm - transform local packages, share with others, make distributable" -mode: primary +mode: "primary" tools: read: true write: true diff --git a/assets/agents/opencode-skill-creator.md b/agents/opencode-skill-creator.md similarity index 99% rename from assets/agents/opencode-skill-creator.md rename to agents/opencode-skill-creator.md index c6db37c..6fbd011 100644 --- a/assets/agents/opencode-skill-creator.md +++ b/agents/opencode-skill-creator.md @@ -1,6 +1,6 @@ --- description: "Creates OpenCode skills in .opencode/skills - SKILL.md, frontmatter, progressive disclosure" -mode: subagent +mode: "subagent" tools: read: true write: true diff --git a/assets/agents/opencode-tool-builder.md b/agents/opencode-tool-builder.md similarity index 98% rename from assets/agents/opencode-tool-builder.md rename to agents/opencode-tool-builder.md index 008d151..52cac3f 100644 --- a/assets/agents/opencode-tool-builder.md +++ b/agents/opencode-tool-builder.md @@ -1,6 +1,6 @@ --- description: "Creates OpenCode custom tools in .opencode/tools - Zod schemas and execute logic" -mode: subagent +mode: "subagent" tools: read: true write: true diff --git a/index.ts b/index.ts index 261d0fa..eaec43a 100644 --- a/index.ts +++ b/index.ts @@ -1,26 +1,6 @@ -import type { Plugin } from "@opencode-ai/plugin"; -import path from "node:path"; -import { AgentLoader } from "./agent-loader"; -import { AssetPermissionRegistrar } from "./permission-registrar"; +export { OpencodeArchitectPlugin, default } from "./plugin.ts"; -const AGENTS_DIR = path.join(import.meta.dirname, "assets", "agents"); -const ASSETS_DIR = path.join(import.meta.dirname, "assets"); - -const OpencodeArchitect: Plugin = async () => { - const agents = await new AgentLoader(AGENTS_DIR).loadAgents(); - const permissionRegistrar = new AssetPermissionRegistrar(ASSETS_DIR); - - return { - config: async (config) => { - config.agent = config.agent || {}; - - for (const [name, agentConfig] of Object.entries(agents)) { - config.agent[name] = agentConfig; - } - - permissionRegistrar.register(config); - }, - }; -}; - -export default OpencodeArchitect; +if (import.meta.main) { + const { runCli } = await import("./src/cli.ts"); + process.exitCode = await runCli(process.argv.slice(2)); +} diff --git a/package.json b/package.json index 15016b7..186f6c2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "opencode-architect", "version": "0.8.0", + "content": "code", "description": "OpenCode plugin and CLI with ten specialist agents for agent skills, slash commands, custom tools, plugins, and MCP server integration", "keywords": [ "opencode", @@ -19,7 +20,7 @@ "type": "module", "module": "index.ts", "bin": { - "opencode-architect": "cli.ts" + "opencode-architect": "src/cli.ts" }, "repository": { "type": "git", @@ -44,14 +45,11 @@ }, "files": [ "index.ts", - "agent-loader.ts", - "cache-cleaner.ts", - "clear-cache-usage-error.ts", - "cli.ts", - "installer.ts", - "plugin-config.ts", - "permission-registrar.ts", - "assets" + "plugin.ts", + "src", + "agents", + "references", + "templates" ], "dependencies": { "@opencode-ai/plugin": "*", diff --git a/plugin.ts b/plugin.ts new file mode 100644 index 0000000..3b40632 --- /dev/null +++ b/plugin.ts @@ -0,0 +1,97 @@ +import type { Plugin } from "@opencode-ai/plugin"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { AgentLoader } from "./src/agent-loader"; +import { AssetPermissionRegistrar } from "./src/permission-registrar"; +import { Installer } from "./src/installer"; + +const PACKAGE_NAME = "opencode-architect"; + +export class OpencodeArchitectPlugin { + private readonly agentsDir: string; + private readonly assetsDir: string; + private readonly readVersion: () => Promise; + private readonly hasInstall: (projectDir: string) => Promise; + private failureAdvised = false; + private notInstalledAdvised = false; + + constructor( + agentsDir: string | null = null, + assetsDir: string | null = null, + readVersion: (() => Promise) | null = null, + hasInstall: ((projectDir: string) => Promise) | null = null, + ) { + this.agentsDir = agentsDir ?? path.join(import.meta.dirname, "agents"); + this.assetsDir = assetsDir ?? import.meta.dirname; + this.readVersion = readVersion ?? (() => this.readPackageMetadata()); + this.hasInstall = hasInstall ?? ((projectDir) => new Installer().hasManifestAnywhere(projectDir)); + } + + public toPlugin(): Plugin { + return async (input) => { + const directory = typeof input?.directory === "string" ? input.directory : process.cwd(); + return { + config: async (config) => { + try { + const agents = await new AgentLoader(this.agentsDir).loadAgents(); + config.agent = config.agent || {}; + for (const [name, agentConfig] of Object.entries(agents)) { + config.agent[name] = agentConfig; + } + new AssetPermissionRegistrar(this.assetsDir).register(config); + await this.adviseNotInstalledOnce(directory); + } catch (error) { + await this.adviseFailureOnce(error instanceof Error ? error.message : String(error)); + } + }, + }; + }; + } + + private async adviseNotInstalledOnce(directory: string): Promise { + if (this.notInstalledAdvised) return; + if (await this.hasInstall(directory)) return; + this.notInstalledAdvised = true; + try { + console.log( + `[${PACKAGE_NAME}] Not installed in any scope. Run: bunx ${PACKAGE_NAME} install --scope global ` + + `(or: bunx ${PACKAGE_NAME} install).`, + ); + } catch {} + } + + private async adviseFailureOnce(message: string): Promise { + if (this.failureAdvised) return; + this.failureAdvised = true; + await this.emitAdvisory(message); + } + + private async emitAdvisory(message: string): Promise { + const prefix = + `Failed to load the bundled agent suite. Run: bunx ${PACKAGE_NAME} clear-cache, then ` + + `reinstall and restart OpenCode. The stale cache copy is `; + let text: string; + try { + text = `${prefix}~/.cache/opencode/packages/${PACKAGE_NAME}@${await this.readVersion()}. Cause: ${message}`; + } catch { + text = `${prefix}~/.cache/opencode/packages/${PACKAGE_NAME}@. Cause: ${message}`; + } + try { + console.warn(`[${PACKAGE_NAME}] ${text}`); + } catch {} + } + + private async readPackageMetadata(): Promise { + const manifest = JSON.parse( + await readFile(path.join(import.meta.dirname, "package.json"), "utf-8"), + ) as { version: string }; + if (typeof manifest.version !== "string" || manifest.version.length === 0) { + throw new Error("unreadable package metadata"); + } + return manifest.version; + } +} + +const opencodeArchitect = new OpencodeArchitectPlugin(); + +export default opencodeArchitect.toPlugin(); diff --git a/assets/references/agents.md b/references/agents.md similarity index 100% rename from assets/references/agents.md rename to references/agents.md diff --git a/assets/references/commands.md b/references/commands.md similarity index 100% rename from assets/references/commands.md rename to references/commands.md diff --git a/assets/references/config.md b/references/config.md similarity index 100% rename from assets/references/config.md rename to references/config.md diff --git a/assets/references/conformance-checklist.md b/references/conformance-checklist.md similarity index 100% rename from assets/references/conformance-checklist.md rename to references/conformance-checklist.md diff --git a/assets/references/live-knowledge-fallback.md b/references/live-knowledge-fallback.md similarity index 100% rename from assets/references/live-knowledge-fallback.md rename to references/live-knowledge-fallback.md diff --git a/assets/references/mcp-servers.md b/references/mcp-servers.md similarity index 100% rename from assets/references/mcp-servers.md rename to references/mcp-servers.md diff --git a/assets/references/opencode-architect-oneshots.md b/references/opencode-architect-oneshots.md similarity index 100% rename from assets/references/opencode-architect-oneshots.md rename to references/opencode-architect-oneshots.md diff --git a/assets/references/plugins.md b/references/plugins.md similarity index 100% rename from assets/references/plugins.md rename to references/plugins.md diff --git a/assets/references/prompt-engineering.md b/references/prompt-engineering.md similarity index 100% rename from assets/references/prompt-engineering.md rename to references/prompt-engineering.md diff --git a/assets/references/skills.md b/references/skills.md similarity index 100% rename from assets/references/skills.md rename to references/skills.md diff --git a/assets/references/tools.md b/references/tools.md similarity index 100% rename from assets/references/tools.md rename to references/tools.md diff --git a/agent-loader.ts b/src/agent-loader.ts similarity index 100% rename from agent-loader.ts rename to src/agent-loader.ts diff --git a/src/bundled-assets-missing-error.ts b/src/bundled-assets-missing-error.ts new file mode 100644 index 0000000..28a77cd --- /dev/null +++ b/src/bundled-assets-missing-error.ts @@ -0,0 +1,11 @@ +export class BundledAssetsMissingError extends Error { + constructor(missingPath: string, cacheRoot: string, version: string) { + super( + `Bundled asset directory missing or empty: ${missingPath}. ` + + `The opencode-architect package cache at ${cacheRoot} is partial ` + + `(expected opencode-architect@${version}). ` + + `Clear it with: bunx opencode-architect clear-cache, then reinstall with: bunx opencode-architect install`, + ); + this.name = "BundledAssetsMissingError"; + } +} diff --git a/cache-cleaner.ts b/src/cache-cleaner.ts similarity index 100% rename from cache-cleaner.ts rename to src/cache-cleaner.ts diff --git a/clear-cache-usage-error.ts b/src/clear-cache-usage-error.ts similarity index 100% rename from clear-cache-usage-error.ts rename to src/clear-cache-usage-error.ts diff --git a/cli.ts b/src/cli.ts similarity index 93% rename from cli.ts rename to src/cli.ts index 572d87b..e86b9ab 100644 --- a/cli.ts +++ b/src/cli.ts @@ -4,10 +4,11 @@ import { Installer, type Scope } from "./installer"; 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; +const VERSION = (JSON.parse(await Bun.file(`${import.meta.dirname}/../package.json`).text()) as { version: string }).version; -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" }, @@ -25,23 +26,23 @@ async function main(): Promise { if (values.version) { console.log(`opencode-architect v${VERSION}`); - return; + return 0; } if (values.help || positionals.length === 0) { printHelp(); - return; + return 0; } const command = positionals[0]; const scopeInput = values.scope; if (scopeInput !== undefined && scopeInput !== "local" && scopeInput !== "global") { console.error(`Invalid scope: ${scopeInput}. Must be "local" or "global".`); - process.exit(1); + return 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); + return 1; } const mode: "plugin" | "copy" = values.mode === "copy" ? "copy" : "plugin"; const installer = new Installer(); @@ -93,7 +94,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; } let outcome; try { @@ -101,7 +102,7 @@ async function main(): Promise { } catch (error) { if (error instanceof ClearCacheUsageError) { console.error(error.message); - process.exit(1); + return 1; } throw error; } @@ -122,15 +123,20 @@ async function main(): Promise { default: console.error(`Unknown command: ${command}`); printHelp(); - process.exit(1); + return 1; } + return 0; } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error(`Error: ${message}`); - process.exit(1); + return 1; } } +if (import.meta.main) { + process.exitCode = await runCli(process.argv.slice(2)); +} + function printHelp(): void { console.log(` opencode-architect v${VERSION} @@ -150,7 +156,8 @@ 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 + -f, --force re-register and rewrite the manifest even when it is up to date; + consent to migrating a legacy copy install (removes its copied payload) --package clear-cache: remove and every @* instead; requires --yes --all clear-cache: remove the whole OpenCode cache directory; @@ -172,4 +179,3 @@ Examples: `); } -main(); diff --git a/src/copy-mode-unsupported-error.ts b/src/copy-mode-unsupported-error.ts new file mode 100644 index 0000000..cab65ba --- /dev/null +++ b/src/copy-mode-unsupported-error.ts @@ -0,0 +1,9 @@ +export class CopyModeUnsupportedError extends Error { + constructor(packageName: string) { + super( + `${packageName} is a code-backed package: it ships agents, which only work through ` + + `plugin registration. Copy install cannot express that. Run without --mode copy.`, + ); + this.name = "CopyModeUnsupportedError"; + } +} diff --git a/installer.ts b/src/installer.ts similarity index 73% rename from installer.ts rename to src/installer.ts index af36b3d..c69aa3e 100644 --- a/installer.ts +++ b/src/installer.ts @@ -3,6 +3,8 @@ import { homedir } from "node:os"; import path from "node:path"; import { hashElement } from "folder-hash"; import { AGENT_FILENAMES } from "./agent-loader"; +import { BundledAssetsMissingError } from "./bundled-assets-missing-error"; +import { CopyModeUnsupportedError } from "./copy-mode-unsupported-error"; import { PluginConfigEditor } from "./plugin-config"; export type Scope = "local" | "global"; @@ -57,27 +59,43 @@ export interface StatusOutcome { } const PACKAGE_NAME = "opencode-architect"; -const MANIFEST_NAME = "opencode-architect.json"; +const BUNDLED_ASSET_DIRS = ["agents", "references", "templates"]; +const PLUGIN_ENTRY = "opencode-architect@latest"; +const MANIFEST_NAME = "opencode-architect.manifest.json"; +const LEGACY_MANIFEST_NAME = "opencode-architect.json"; export class Installer { private readonly editor = new PluginConfigEditor(); + private readonly assetsDir: string; + + constructor(assetsDir: string | null = null) { + this.assetsDir = assetsDir ?? path.join(import.meta.dirname, ".."); + } 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.`, - ); + throw new CopyModeUnsupportedError(PACKAGE_NAME); } const base = this.scopeBase(scope, options.projectDir); const manifestPath = path.join(base, MANIFEST_NAME); + const legacyManifestPath = path.join(base, LEGACY_MANIFEST_NAME); + const record = await this.readManifestRecord(base); + const existing = record.manifest; const version = await this.getPackageVersion(); - const existing = await this.readManifest(manifestPath); + await this.requireBundledAssets(version); let removedPayload: string[] = []; let action: InstallAction; if (existing !== null && existing.mode === "copy") { + if (!options.force) { + throw new Error( + `A legacy copy install of ${PACKAGE_NAME} was found at ${base}. ` + + `Migrating it to plugin registration removes the copied payload it recorded, ` + + `including any files you edited after installing. ` + + `Re-run with --force to consent.`, + ); + } 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 ?? []); @@ -98,17 +116,20 @@ export class Installer { action = existing.version === version && registration.action === "noop" ? "noop" : "upgraded"; } - if (action !== "noop" || options.force) { + if (action !== "noop" || options.force || record.foundPath === legacyManifestPath) { const manifest: Manifest = { version, mode: "plugin", - entry: PACKAGE_NAME, + entry: PLUGIN_ENTRY, configPath: registration.configPath, "content-hash": null, hashes: null, }; await mkdir(base, { recursive: true }); await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n"); + if (await exists(legacyManifestPath)) { + await rm(legacyManifestPath); + } } const cache = await this.prunePackageCache(version); @@ -125,6 +146,15 @@ export class Installer { }; } + private async requireBundledAssets(version: string): Promise { + for (const name of BUNDLED_ASSET_DIRS) { + const dir = path.join(this.assetsDir, name); + if (!(await exists(dir))) throw new BundledAssetsMissingError(dir, this.packageCacheRoot(), version); + const contents = await readdir(dir); + if (contents.length === 0) throw new BundledAssetsMissingError(dir, this.packageCacheRoot(), version); + } + } + private async prunePackageCache(version: string): Promise<{ removed: string[]; warnings: string[] }> { const removed: string[] = []; const warnings: string[] = []; @@ -154,8 +184,8 @@ export class Installer { 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 record = await this.readManifestRecord(base); + const manifest = record.manifest; const removed: string[] = []; const removal = await this.editor.removePluginEntry(PACKAGE_NAME, { scope, projectDir }); @@ -166,13 +196,11 @@ export class Installer { if (manifest !== null && manifest.mode === "copy") { removed.push(...(await this.removePayloadPerManifest(base, manifest.hashes ?? []))); - await rm(manifestPath); - removed.push(manifestPath); + removed.push(...(await this.removeManifestFiles(base))); } if (manifest !== null && manifest.mode === "plugin") { - await rm(manifestPath); - removed.push(manifestPath); + removed.push(...(await this.removeManifestFiles(base))); } if (manifest === null) { @@ -208,7 +236,7 @@ export class Installer { public async status(scope: Scope, projectDir: string): Promise { const base = this.scopeBase(scope, projectDir); - const manifest = await this.readManifest(path.join(base, MANIFEST_NAME)); + const { manifest } = await this.readManifestRecord(base); if (manifest !== null) { return { scope, mode: manifest.mode, version: manifest.version, configPath: manifest.configPath }; } @@ -219,6 +247,16 @@ export class Installer { return { scope, mode: "none", version: null, configPath: null }; } + public async hasManifestAnywhere(projectDir: string): Promise { + for (const scope of ["global", "local"] as const) { + const base = this.scopeBase(scope, projectDir); + for (const candidate of this.manifestCandidates(base)) { + if (await exists(candidate)) return true; + } + } + return false; + } + private async removePayloadPerManifest( base: string, hashes: ManifestHashEntry[], @@ -248,6 +286,28 @@ export class Installer { if (contents.length === 0) await rmdir(directory); } + private manifestCandidates(base: string): string[] { + return [path.join(base, MANIFEST_NAME), path.join(base, LEGACY_MANIFEST_NAME)]; + } + + private async readManifestRecord(base: string): Promise<{ manifest: Manifest | null; foundPath: string | null }> { + for (const candidate of this.manifestCandidates(base)) { + const manifest = await this.readManifest(candidate); + if (manifest !== null) return { manifest, foundPath: candidate }; + } + return { manifest: null, foundPath: null }; + } + + private async removeManifestFiles(base: string): Promise { + const removed: string[] = []; + for (const candidate of this.manifestCandidates(base)) { + if (!(await exists(candidate))) continue; + await rm(candidate); + removed.push(candidate); + } + return removed; + } + private async readManifest(manifestPath: string): Promise { try { const parsed = JSON.parse(await readFile(manifestPath, "utf-8")) as Partial; @@ -277,7 +337,7 @@ export class Installer { } private async getPackageVersion(): Promise { - const content = await readFile(path.join(import.meta.dirname, "package.json"), "utf-8"); + const content = await readFile(path.join(import.meta.dirname, "..", "package.json"), "utf-8"); return (JSON.parse(content) as { version: string }).version; } diff --git a/permission-registrar.ts b/src/permission-registrar.ts similarity index 100% rename from permission-registrar.ts rename to src/permission-registrar.ts diff --git a/plugin-config.ts b/src/plugin-config.ts similarity index 91% rename from plugin-config.ts rename to src/plugin-config.ts index 032a447..79c8b54 100644 --- a/plugin-config.ts +++ b/src/plugin-config.ts @@ -21,6 +21,12 @@ interface CandidateConfig { writable: boolean; } +interface CandidateRead { + candidate: CandidateConfig; + text: string; + plugins: string[] | null; +} + export interface RemovePluginEntryOutcome { action: "noop" | "removed" | "blocked"; configPath: string | null; @@ -46,10 +52,8 @@ export class PluginConfigEditor { 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); + const canonical = this.canonicalEntry(packageName); + for (const { candidate, text, plugins } of await this.readCandidates(options)) { if (plugins === null) { return { action: "blocked", @@ -63,7 +67,7 @@ export class PluginConfigEditor { return { action: "noop", configPath: candidate.path, warning: null }; } if (!candidate.writable) continue; - const spliced = this.spliceEntry(text, packageName, candidate.lenient); + const spliced = this.spliceEntry(text, canonical, packageName, candidate.lenient); if (spliced === null) { return { action: "blocked", @@ -77,7 +81,7 @@ export class PluginConfigEditor { } const target = this.defaultConfigPath(options); - const content = DEFAULT_CONFIG_TEMPLATE.replace("__PACKAGE_NAME__", packageName); + const content = DEFAULT_CONFIG_TEMPLATE.replace("__PACKAGE_NAME__", canonical); await mkdir(path.dirname(target), { recursive: true }); await writeFile(target, content); return { action: "created", configPath: target, warning: null }; @@ -103,10 +107,7 @@ export class PluginConfigEditor { 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); + for (const { candidate, plugins } of await this.readCandidates(options)) { if (plugins === null) continue; if (this.hasMatchingEntry(plugins, packageName)) return candidate.path; } @@ -117,10 +118,8 @@ export class PluginConfigEditor { 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); + for (const { candidate, text, plugins } of await this.readCandidates(options)) { + if (!candidate.writable) continue; if (plugins === null) { return { action: "blocked", @@ -155,6 +154,12 @@ export class PluginConfigEditor { return name === packageName; } + private canonicalEntry(packageName: string): string { + const specIndex = packageName.lastIndexOf("@"); + if (specIndex > 0) return packageName; + return `${packageName}@latest`; + } + private candidateConfigs(options: EnsurePluginEntryOptions): CandidateConfig[] { const scopeBase = this.scopeBase(options.scope, options.projectDir); const repoRoot = options.projectDir; @@ -173,6 +178,22 @@ export class PluginConfigEditor { return configs; } + private async readCandidates(options: EnsurePluginEntryOptions): Promise { + const reads: CandidateRead[] = []; + 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) { + console.warn( + `Warning: ${candidate.path} could not be parsed; refusing to treat it as a registration candidate.`, + ); + } + reads.push({ candidate, text, plugins }); + } + return reads; + } + private defaultConfigPath(options: EnsurePluginEntryOptions): string { if (options.scope === "global") { return path.join(this.scopeBase("global", options.projectDir), "opencode.jsonc"); @@ -258,13 +279,18 @@ export class PluginConfigEditor { return chars.join(""); } - private spliceEntry(text: string, packageName: string, lenient: boolean): string | null { + private spliceEntry( + text: string, + entryToWrite: 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); + ? this.splicePluginKey(text, navigable, entryToWrite) + : this.spliceArrayEntry(text, navigable, range, entryToWrite); if (spliced === null) return null; const plugins = this.parsePluginArray(spliced, lenient); if (plugins === null || !this.hasMatchingEntry(plugins, packageName)) return null; diff --git a/assets/templates/cli.template.txt b/templates/cli.template.txt similarity index 100% rename from assets/templates/cli.template.txt rename to templates/cli.template.txt diff --git a/assets/templates/index.template.txt b/templates/index.template.txt similarity index 100% rename from assets/templates/index.template.txt rename to templates/index.template.txt diff --git a/assets/templates/installer.template.txt b/templates/installer.template.txt similarity index 100% rename from assets/templates/installer.template.txt rename to templates/installer.template.txt diff --git a/assets/templates/manifest.template.txt b/templates/manifest.template.txt similarity index 100% rename from assets/templates/manifest.template.txt rename to templates/manifest.template.txt diff --git a/assets/templates/package-basics.template.json b/templates/package-basics.template.json similarity index 100% rename from assets/templates/package-basics.template.json rename to templates/package-basics.template.json diff --git a/assets/templates/package-full.template.json b/templates/package-full.template.json similarity index 100% rename from assets/templates/package-full.template.json rename to templates/package-full.template.json diff --git a/assets/templates/plugin-config.template.txt b/templates/plugin-config.template.txt similarity index 88% rename from assets/templates/plugin-config.template.txt rename to templates/plugin-config.template.txt index af005e1..990dbac 100644 --- a/assets/templates/plugin-config.template.txt +++ b/templates/plugin-config.template.txt @@ -2,13 +2,12 @@ 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. +**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); every candidate is read up front and every unparseable one is warned about **before** any short-circuit on a successful match; 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 (`name`, `name@latest`, `name@x.y.z` are the same package) and new entries are written canonically as `name@latest`; 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"; @@ -35,6 +34,12 @@ interface CandidateConfig { writable: boolean; } +interface CandidateRead { + candidate: CandidateConfig; + text: string; + plugins: string[] | null; +} + export type ConfigCheck = { ok: true } | { ok: false; warning: string }; interface PluginArrayRange { @@ -54,10 +59,8 @@ export class PluginConfigEditor { 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); + const canonical = this.canonicalEntry(packageName); + for (const { candidate, text, plugins } of await this.readCandidates(options)) { if (plugins === null) { return { action: "blocked", @@ -71,7 +74,7 @@ export class PluginConfigEditor { return { action: "noop", configPath: candidate.path, warning: null }; } if (!candidate.writable) continue; - const spliced = this.spliceEntry(text, packageName, candidate.lenient); + const spliced = this.spliceEntry(text, canonical, packageName, candidate.lenient); if (spliced === null) { return { action: "blocked", @@ -85,7 +88,7 @@ export class PluginConfigEditor { } const target = this.defaultConfigPath(options); - const content = DEFAULT_CONFIG_TEMPLATE.replace("__PACKAGE_NAME__", packageName); + const content = DEFAULT_CONFIG_TEMPLATE.replace("__PACKAGE_NAME__", canonical); await mkdir(path.dirname(target), { recursive: true }); await writeFile(target, content); return { action: "created", configPath: target, warning: null }; @@ -111,10 +114,7 @@ export class PluginConfigEditor { 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); + for (const { candidate, plugins } of await this.readCandidates(options)) { if (plugins === null) continue; if (this.hasMatchingEntry(plugins, packageName)) return candidate.path; } @@ -125,10 +125,8 @@ export class PluginConfigEditor { 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); + for (const { candidate, text, plugins } of await this.readCandidates(options)) { + if (!candidate.writable) continue; if (plugins === null) { return { action: "blocked", @@ -163,6 +161,12 @@ export class PluginConfigEditor { return name === packageName; } + private canonicalEntry(packageName: string): string { + const specIndex = packageName.lastIndexOf("@"); + if (specIndex > 0) return packageName; + return `${packageName}@latest`; + } + private candidateConfigs(options: EnsurePluginEntryOptions): CandidateConfig[] { const scopeBase = this.scopeBase(options.scope, options.projectDir); const repoRoot = options.projectDir; @@ -181,6 +185,22 @@ export class PluginConfigEditor { return configs; } + private async readCandidates(options: EnsurePluginEntryOptions): Promise { + const reads: CandidateRead[] = []; + 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) { + console.warn( + `Warning: ${candidate.path} could not be parsed; refusing to treat it as a registration candidate.`, + ); + } + reads.push({ candidate, text, plugins }); + } + return reads; + } + private defaultConfigPath(options: EnsurePluginEntryOptions): string { if (options.scope === "global") { return path.join(this.scopeBase("global", options.projectDir), "opencode.jsonc"); @@ -264,13 +284,18 @@ export class PluginConfigEditor { return chars.join(""); } - private spliceEntry(text: string, packageName: string, lenient: boolean): string | null { + private spliceEntry( + text: string, + entryToWrite: 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); + ? this.splicePluginKey(text, navigable, entryToWrite) + : this.spliceArrayEntry(text, navigable, range, entryToWrite); if (spliced === null) return null; const plugins = this.parsePluginArray(spliced, lenient); if (plugins === null || !this.hasMatchingEntry(plugins, packageName)) return null; diff --git a/assets/templates/plugin-local.template.txt b/templates/plugin-local.template.txt similarity index 100% rename from assets/templates/plugin-local.template.txt rename to templates/plugin-local.template.txt diff --git a/assets/templates/plugin-name.template.txt b/templates/plugin-name.template.txt similarity index 100% rename from assets/templates/plugin-name.template.txt rename to templates/plugin-name.template.txt diff --git a/assets/templates/prompts.template.txt b/templates/prompts.template.txt similarity index 100% rename from assets/templates/prompts.template.txt rename to templates/prompts.template.txt diff --git a/assets/templates/registration.template.txt b/templates/registration.template.txt similarity index 100% rename from assets/templates/registration.template.txt rename to templates/registration.template.txt diff --git a/assets/templates/skill-structure.template.md b/templates/skill-structure.template.md similarity index 100% rename from assets/templates/skill-structure.template.md rename to templates/skill-structure.template.md diff --git a/assets/templates/tsconfig.template.json b/templates/tsconfig.template.json similarity index 100% rename from assets/templates/tsconfig.template.json rename to templates/tsconfig.template.json diff --git a/tests/agents.test.ts b/tests/agents.test.ts index 641738a..5252e0b 100644 --- a/tests/agents.test.ts +++ b/tests/agents.test.ts @@ -1,10 +1,10 @@ import { describe, expect, test } from "bun:test"; -import { existsSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; import { readFile } from "node:fs/promises"; import path from "node:path"; -import { AGENT_FILENAMES, AgentLoader } from "../agent-loader"; +import { AGENT_FILENAMES, AgentLoader } from "../src/agent-loader"; -const AGENTS_DIR = path.resolve(import.meta.dirname, "..", "assets", "agents"); +const AGENTS_DIR = path.resolve(import.meta.dirname, "..", "agents"); const RELATIVE_REFERENCE_REGEX = /`((?:\.{1,2})(?:[\\/][^`\\/]+)+)`/g; const ABSOLUTE_PATH_REGEX = /`([A-Za-z]:[\\/][^`]+|\/[^`]+)`/g; @@ -23,6 +23,44 @@ function isReferenceFilePath(candidate: string): boolean { return looksAbsolute && /\.[A-Za-z0-9]+$/.test(lastSegment); } +function frontmatterLines(content: string): string[] { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!match) return []; + return (match[1] ?? "").split(/\r?\n/); +} + +function listShippedMarkdown(): string[] { + const assetsRoot = path.resolve(import.meta.dirname, ".."); + return readdirSync(assetsRoot, { recursive: true }) + .map(String) + .filter((relative) => relative.endsWith(".md")) + .map((relative) => path.join(assetsRoot, relative)); +} + +function frontmatterViolations(filePath: string): string[] { + const violations: string[] = []; + for (const line of frontmatterLines(readFileSync(filePath, "utf-8"))) { + if (line.trim().length === 0) continue; + const indented = /^[ \t]/.test(line); + const property = line.match(/^([A-Za-z_-]+):\s*(.*)$/); + const entry = indented ? line.match(/^\s+(?:"[^"]+"|[A-Za-z_-]+):\s*(.*)$/) : null; + const value = (property && !indented ? (property[2] ?? "") : (entry?.[1] ?? "")).trim(); + if (value.length === 0) continue; + if (/^(true|false|-?\d+(\.\d+)?)$/.test(value)) continue; + if (!/^".*"$/.test(value)) { + violations.push(`${path.basename(filePath)}: unquoted value ${value}`); + } + } + return violations; +} + +describe("frontmatter hygiene (D6)", () => { + test("every frontmatter value in every shipped markdown file is double-quoted", () => { + const violations = listShippedMarkdown().flatMap((filePath) => frontmatterViolations(filePath)); + expect(violations).toEqual([]); + }); +}); + describe("AgentLoader", () => { test("loadAgents returns all ten agents with non-empty prompts", async () => { const agents = await new AgentLoader(AGENTS_DIR).loadAgents(); diff --git a/tests/cache-cleaner.test.ts b/tests/cache-cleaner.test.ts index 2011957..5dcdfc5 100644 --- a/tests/cache-cleaner.test.ts +++ b/tests/cache-cleaner.test.ts @@ -3,7 +3,7 @@ import { existsSync } from "node:fs"; import { chmod, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import { CacheCleaner } from "../cache-cleaner"; +import { CacheCleaner } from "../src/cache-cleaner"; import { seedCachedPackage, expectClearCacheUsageError } from "./test-helpers"; let cacheDir = ""; diff --git a/tests/cli.test.ts b/tests/cli.test.ts index fd77c11..eb62631 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -6,7 +6,7 @@ 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"); +const CLI_PATH = path.join(PACKAGE_ROOT, "src", "cli.ts"); interface CliRun { exitCode: number; diff --git a/tests/content-declaration.test.ts b/tests/content-declaration.test.ts index b55fee8..df1501c 100644 --- a/tests/content-declaration.test.ts +++ b/tests/content-declaration.test.ts @@ -10,8 +10,8 @@ const readRepoFile = (relativePath: string): Promise => 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", + "templates/package-basics.template.json", + "templates/package-full.template.json", ]) { const source = await readRepoFile(template); const body = source.split("---")[1] ?? ""; @@ -21,13 +21,13 @@ describe("content declaration", () => { }); test("packager derives the declaration from its inventory", async () => { - const source = await readRepoFile("assets/agents/opencode-packager.md"); + const source = await readRepoFile("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"); + const source = await readRepoFile("agents/opencode-publisher.md"); expect(source).toContain("content` declaration"); expect(source).toContain('declares `"content"`'); }); diff --git a/tests/deployment-plan.test.ts b/tests/deployment-plan.test.ts index b9b2d2d..727cf74 100644 --- a/tests/deployment-plan.test.ts +++ b/tests/deployment-plan.test.ts @@ -298,14 +298,14 @@ describe("packager self-audit gate", () => { }); async function readTemplate(name: string): Promise { - const source = await readFile(path.join(REPO_ROOT, "assets/templates", name), "utf-8"); + const source = await readFile(path.join(REPO_ROOT, "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"); + return readFile(path.join(REPO_ROOT, "agents", name), "utf-8"); } async function readReference(name: string): Promise { - return readFile(path.join(REPO_ROOT, "assets/references", name), "utf-8"); + return readFile(path.join(REPO_ROOT, "references", name), "utf-8"); } diff --git a/tests/installer.test.ts b/tests/installer.test.ts index 5624afa..0a0d8a3 100644 --- a/tests/installer.test.ts +++ b/tests/installer.test.ts @@ -1,9 +1,10 @@ import { describe, expect, test, beforeEach, afterEach } from "bun:test"; import { existsSync } from "node:fs"; -import { chmod, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, readFile, readdir, rename, 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"; +import { Installer, contentHash, type Manifest, type Scope } from "../src/installer"; +import { CopyModeUnsupportedError } from "../src/copy-mode-unsupported-error"; const PACKAGE_ROOT = path.resolve(import.meta.dirname, ".."); @@ -45,6 +46,10 @@ function scopeBase(scope: Scope): string { } function manifestPath(scope: Scope): string { + return path.join(scopeBase(scope), "opencode-architect.manifest.json"); +} + +function legacyManifestPath(scope: Scope): string { return path.join(scopeBase(scope), "opencode-architect.json"); } @@ -87,13 +92,13 @@ describe("Installer.install", () => { expect(outcome.manifestPath).toBe(manifestPath("local")); const config = await readJson(configPath); - expect(config.plugin).toEqual(["opencode-architect"]); + expect(config.plugin).toEqual(["opencode-architect@latest"]); expect(config.theme).toBe("dark"); const manifest = (await readJson(manifestPath("local"))) as unknown as Manifest; expect(manifest.version).toBe(await readPackageVersion()); expect(manifest.mode).toBe("plugin"); - expect(manifest.entry).toBe("opencode-architect"); + expect(manifest.entry).toBe("opencode-architect@latest"); expect(manifest.configPath).toBe(configPath); expect(manifest["content-hash"]).toBeNull(); }); @@ -159,8 +164,8 @@ describe("Installer.install", () => { 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(after.indexOf("opencode-architect@latest")).toBeLessThan(after.indexOf("other-extension")); + const withoutEntry = after.replace(`\n "opencode-architect@latest",`, ""); expect(withoutEntry).toBe(original); }); @@ -181,14 +186,43 @@ describe("Installer.install", () => { 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"]'); + expect(text).toContain('"plugin": ["opencode-architect@latest"]'); }); test("refuses copy mode with an explanatory error", async () => { - await expect(install("local", { mode: "copy" })).rejects.toThrow(/code-backed/); + await expect(install("local", { mode: "copy" })).rejects.toThrow(CopyModeUnsupportedError); expect(existsSync(manifestPath("local"))).toBe(false); }); + test("fails loudly when bundled assets are absent (partial cache artifact)", async () => { + const partialCache = await mkdtemp(path.join(tmpdir(), "oa-partial-cache-")); + try { + const broken = new Installer(partialCache); + + await expect( + broken.install("local", { force: false, mode: "plugin", projectDir }), + ).rejects.toThrow(/Bundled asset directory missing or empty.*clear-cache/s); + + expect(existsSync(manifestPath("local"))).toBe(false); + } finally { + await rm(partialCache, { recursive: true, force: true }); + } + }); + + test("fails loudly when a bundled asset directory is empty", async () => { + const partialCache = await mkdtemp(path.join(tmpdir(), "oa-partial-cache-")); + await mkdir(path.join(partialCache, "agents"), { recursive: true }); + try { + const broken = new Installer(partialCache); + + await expect( + broken.install("local", { force: false, mode: "plugin", projectDir }), + ).rejects.toThrow(/missing or empty.*agents/s); + } finally { + await rm(partialCache, { recursive: true, force: true }); + } + }); + test("migrates a legacy copy install: payload removed per manifest, entry added", async () => { const configPath = path.join(scopeBase("local"), "opencode.json"); await writeJson(configPath, {}); @@ -213,7 +247,7 @@ describe("Installer.install", () => { const consumerAgent = path.join(scopeBase("local"), "agents", "consumer-own.md"); await writeText(consumerAgent, "# consumer's own"); - const outcome = await install("local"); + const outcome = await install("local", { force: true }); expect(outcome.action).toBe("migrated"); expect(outcome.removedPayload).toContain(path.join(scopeBase("local"), "agents", "opencode-architect.md")); @@ -225,11 +259,31 @@ describe("Installer.install", () => { expect(existsSync(path.join(scopeBase("local"), "opencode-architect"))).toBe(false); const config = await readJson(path.join(scopeBase("local"), "opencode.json")); - expect(config.plugin).toEqual(["opencode-architect"]); + expect(config.plugin).toEqual(["opencode-architect@latest"]); const manifest = (await readJson(manifestPath("local"))) as unknown as Manifest; expect(manifest.mode).toBe("plugin"); }); + test("migration requires --force consent and leaves everything intact without it", async () => { + const configPath = path.join(scopeBase("local"), "opencode.json"); + await writeJson(configPath, {}); + 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"); + + await expect(install("local")).rejects.toThrow(/--force/); + + expect(existsSync(path.join(scopeBase("local"), "agents", "opencode-architect.md"))).toBe(true); + expect(existsSync(manifestPath("local"))).toBe(true); + expect(existsSync(configPath)).toBe(true); + const config = await readJson(configPath); + expect(config.plugin).toBeUndefined(); + }); + test("migration aborts with the payload intact when a config is unparseable", async () => { const legacy = { version: "0.0.1", @@ -241,7 +295,7 @@ describe("Installer.install", () => { const configPath = path.join(scopeBase("local"), "opencode.json"); await writeText(configPath, "{ broken ]"); - await expect(install("local")).rejects.toThrow(/could not be parsed/); + await expect(install("local", { force: true })).rejects.toThrow(/could not be parsed/); expect(existsSync(path.join(scopeBase("local"), "agents", "opencode-architect.md"))).toBe(true); expect(existsSync(manifestPath("local"))).toBe(true); @@ -260,6 +314,56 @@ describe("Installer.install", () => { await readPackageVersion(), ); }); + + test("a removed plugin entry is not up to date: install re-registers", async () => { + await install("local"); + const configPath = path.join(scopeBase("local"), "opencode.json"); + await writeJson(configPath, { theme: "dark" }); + + const outcome = await install("local"); + + expect(outcome.action).toBe("upgraded"); + const config = await readJson(configPath); + expect(config.plugin).toEqual(["opencode-architect@latest"]); + const manifest = (await readJson(manifestPath("local"))) as unknown as Manifest; + expect(manifest.version).toBe(await readPackageVersion()); + }); + + test("re-registering one scope leaves the other scope untouched", async () => { + await install("local"); + await install("global"); + const globalConfigPath = path.join(scopeBase("global"), "opencode.jsonc"); + const globalConfigBefore = await readFile(globalConfigPath, "utf-8"); + const globalManifestBefore = await readFile(manifestPath("global"), "utf-8"); + + await install("local", { force: true }); + + expect(await readFile(globalConfigPath, "utf-8")).toBe(globalConfigBefore); + expect(await readFile(manifestPath("global"), "utf-8")).toBe(globalManifestBefore); + }); + + test("relocates a manifest left at the legacy name on the next install", async () => { + await install("local"); + await rename(manifestPath("local"), legacyManifestPath("local")); + + const outcome = await install("local"); + + expect(outcome.action).toBe("noop"); + expect(existsSync(manifestPath("local"))).toBe(true); + expect(existsSync(legacyManifestPath("local"))).toBe(false); + const status = await installer.status("local", projectDir); + expect(status.version).toBe(await readPackageVersion()); + }); + + test("uninstall removes manifests left at either name", async () => { + await install("local"); + await rename(manifestPath("local"), legacyManifestPath("local")); + + await installer.uninstall("local", projectDir); + + expect(existsSync(manifestPath("local"))).toBe(false); + expect(existsSync(legacyManifestPath("local"))).toBe(false); + }); }); describe("Installer.install cache pruning", () => { diff --git a/tests/load-hook.test.ts b/tests/load-hook.test.ts new file mode 100644 index 0000000..5595fde --- /dev/null +++ b/tests/load-hook.test.ts @@ -0,0 +1,169 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import type { Config } from "@opencode-ai/plugin"; +import type { Plugin } from "@opencode-ai/plugin"; +import { OpencodeArchitectPlugin } from "../index"; +import { captureConsole } from "./test-helpers"; + +const REPO_ROOT = path.resolve(import.meta.dirname, ".."); +const REAL_AGENTS_DIR = path.join(REPO_ROOT, "agents"); +const REAL_ASSETS_DIR = REPO_ROOT; + +type PluginHooks = Awaited>; + +async function hooksFrom(plugin: Plugin): Promise { + const build = plugin as unknown as () => Promise; + return build(); +} + +async function configFrom(plugin: Plugin): Promise { + const hooks = await hooksFrom(plugin); + const register = hooks.config; + if (!register) throw new Error("missing config hook"); + const config = {} as Config; + await register(config); + return config; +} + +function pluginWith(agentsDir: string, assetsDir: string): Plugin { + return new OpencodeArchitectPlugin(agentsDir, assetsDir).toPlugin(); +} + +async function makeScratchDirs(): Promise<{ agentsDir: string; assetsDir: string }> { + const root = await mkdtemp(path.join(tmpdir(), "opencode-architect-hook-")); + const agentsDir = path.join(root, "agents"); + const assetsDir = path.join(root, "assets"); + await mkdir(agentsDir, { recursive: true }); + await mkdir(assetsDir, { recursive: true }); + return { agentsDir, assetsDir }; +} + +describe("plugin load hook (startup non-interference)", () => { + let restoreWarnings: (() => void) | null = null; + + afterEach(() => { + restoreWarnings?.(); + restoreWarnings = null; + }); + + function captureWarnings(): string[] { + const captured = captureConsole("warn"); + restoreWarnings = captured.restore; + return captured.lines; + } + + test("missing bundled agents degrade to exactly one advisory instead of throwing", async () => { + const { agentsDir, assetsDir } = await makeScratchDirs(); + const warnings = captureWarnings(); + const plugin = pluginWith(agentsDir, assetsDir); + + const config = await configFrom(plugin); + + expect(Object.keys(config.agent ?? {})).toEqual([]); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("bunx opencode-architect clear-cache"); + expect(warnings[0]).toContain(`~/.cache/opencode/packages/opencode-architect@`); + expect(warnings[0]).toContain("ENOENT"); + }); + + test("the advisory fallback keeps a package-qualified cache path with a literal version placeholder", async () => { + const { agentsDir, assetsDir } = await makeScratchDirs(); + const warnings = captureWarnings(); + const plugin = new OpencodeArchitectPlugin(agentsDir, assetsDir, async () => { + throw new Error("unreadable package metadata"); + }); + + await configFrom(plugin.toPlugin()); + + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("opencode-architect@"); + expect(warnings[0]).toContain("Cause: ENOENT"); + }); + + test("repeated failing invocations emit exactly one advisory", async () => { + const { agentsDir, assetsDir } = await makeScratchDirs(); + const warnings = captureWarnings(); + const plugin = pluginWith(agentsDir, assetsDir); + + await configFrom(plugin); + await configFrom(plugin); + await configFrom(plugin); + + expect(warnings).toHaveLength(1); + }); + + test("healthy assets register the agent suite in-memory with no warning", async () => { + const warnings = captureWarnings(); + const plugin = new OpencodeArchitectPlugin(REAL_AGENTS_DIR, REAL_ASSETS_DIR, null, async () => true); + const config = await configFrom(plugin.toPlugin()); + + expect(Object.keys(config.agent ?? {}).length).toBe(10); + expect(Object.keys(config.permission?.external_directory ?? {}).length).toBeGreaterThan(0); + expect(warnings).toEqual([]); + }); +}); + +describe("not-installed advisory (D5)", () => { + let restoreLogs: (() => void) | null = null; + let restoreWarnings: (() => void) | null = null; + + afterEach(() => { + restoreLogs?.(); + restoreWarnings?.(); + restoreLogs = null; + restoreWarnings = null; + }); + + function captureOutput(): { logs: string[]; warnings: string[] } { + const logs = captureConsole("log"); + const warnings = captureConsole("warn"); + restoreLogs = logs.restore; + restoreWarnings = warnings.restore; + return { logs: logs.lines, warnings: warnings.lines }; + } + + test("fires once when no scope holds an install, then stays suppressed", async () => { + const { logs } = captureOutput(); + let installed = false; + const plugin = new OpencodeArchitectPlugin(REAL_AGENTS_DIR, REAL_ASSETS_DIR, null, async () => installed); + + await configFrom(plugin.toPlugin()); + await configFrom(plugin.toPlugin()); + + expect(logs).toHaveLength(1); + expect(logs[0]).toContain("Not installed in any scope"); + expect(logs[0]).toContain("bunx opencode-architect install --scope global"); + + installed = true; + await configFrom(plugin.toPlugin()); + expect(logs).toHaveLength(1); + }); + + test("fires independently of the failure advisory (separate once-guards)", async () => { + const { logs, warnings } = captureOutput(); + const failing = new OpencodeArchitectPlugin( + path.join(tmpdir(), "missing-agents-"), + path.join(tmpdir(), "missing-assets-"), + ); + await configFrom(failing.toPlugin()); + expect(warnings).toHaveLength(1); + + const healthy = new OpencodeArchitectPlugin(REAL_AGENTS_DIR, REAL_ASSETS_DIR, null, async () => false); + await configFrom(healthy.toPlugin()); + + expect(logs).toHaveLength(1); + expect(logs[0]).toContain("Not installed in any scope"); + }); + + test("never fires when a scope already holds an install", async () => { + const { logs } = captureOutput(); + const plugin = new OpencodeArchitectPlugin(REAL_AGENTS_DIR, REAL_ASSETS_DIR, null, async () => true); + + await configFrom(plugin.toPlugin()); + await configFrom(plugin.toPlugin()); + + expect(logs).toEqual([]); + }); +}); diff --git a/tests/permission-registrar.test.ts b/tests/permission-registrar.test.ts index 3e8855f..49792bb 100644 --- a/tests/permission-registrar.test.ts +++ b/tests/permission-registrar.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from "bun:test"; import path from "node:path"; import type { Config } from "@opencode-ai/plugin"; -import { AssetPermissionRegistrar } from "../permission-registrar"; +import { AssetPermissionRegistrar } from "../src/permission-registrar"; -const ASSETS_DIR = path.resolve(import.meta.dirname, "..", "assets"); +const ASSETS_DIR = path.resolve(import.meta.dirname, ".."); const ASSETS_PATTERN = path.join(ASSETS_DIR, "*").replaceAll("\\", "/"); function configWithPermission(value: unknown): Config { @@ -68,6 +68,6 @@ describe("AssetPermissionRegistrar", () => { const pattern = Object.keys(externalDirectory(config) as Record)[0] ?? ""; expect(pattern.includes("\\")).toBe(false); - expect(pattern.endsWith("/assets/*")).toBe(true); + expect(pattern.endsWith("/*")).toBe(true); }); }); diff --git a/tests/plugin-config.test.ts b/tests/plugin-config.test.ts index 5bc8640..c7c5cff 100644 --- a/tests/plugin-config.test.ts +++ b/tests/plugin-config.test.ts @@ -1,7 +1,8 @@ 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"; +import { PluginConfigEditor } from "../src/plugin-config"; +import { captureConsole } from "./test-helpers"; const ROOT = path.join(import.meta.dirname, "..", ".tmp-plugin-config-test"); @@ -103,15 +104,26 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { 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(after).toContain('"my-pkg@latest"'); + const without = after.replace('"my-pkg@latest",', "").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("my-pkg@latest"); expect(parsed.plugin).toContain("some-other-plugin"); expect(parsed.model).toBe("x/y"); }); + test("writes a new entry in the canonical name@latest form", async () => { + const projectDir = await makeDir("project"); + await write("project/opencode.json", '{ "plugin": ["other"] }\n'); + + await editor().ensurePluginEntry("my-pkg", { scope: "local", projectDir }); + + const parsed = parseJsonc(await readFile(path.join(projectDir, "opencode.json"), "utf-8")); + expect(parsed.plugin).toContain("my-pkg@latest"); + expect(parsed.plugin).not.toContain("my-pkg"); + }); + 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"]) { @@ -133,7 +145,7 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { 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.plugin).toEqual(["my-pkg@latest"]); expect(parsed.$schema).toContain("config.json"); }); @@ -149,6 +161,57 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { expect(await readFile(configPath, "utf-8")).toBe(before); }); + test("warns about an unparseable candidate even when an earlier candidate matches", async () => { + const projectDir = await makeDir("project"); + await write("project/.opencode/opencode.json", '{ "plugin": ["my-pkg"] }\n'); + const brokenRoot = await write("project/opencode.json", "{ broken ]"); + const captured = captureConsole("warn"); + + try { + const outcome = await editor().ensurePluginEntry("my-pkg", { scope: "local", projectDir }); + + expect(outcome.action).toBe("noop"); + expect(captured.lines.some((message) => message.includes(brokenRoot))).toBe(true); + } finally { + captured.restore(); + } + }); + + test("findRegistration warns about unparseable candidates and still finds later registrations", async () => { + const projectDir = await makeDir("project"); + await write("project/.opencode/opencode.json", "{ broken ]"); + const rootConfig = await write("project/opencode.json", '{ "plugin": ["my-pkg@1.0.0"] }\n'); + const captured = captureConsole("warn"); + + try { + const found = await editor().findRegistration("my-pkg", { scope: "local", projectDir }); + + expect(found).toBe(rootConfig); + expect(captured.lines.some((message) => message.includes(".opencode"))).toBe(true); + } finally { + captured.restore(); + } + }); + + test("unparseable read-only candidate is warned about and skipped, never blocking removal", async () => { + const projectDir = await makeDir("project"); + const xdg = await makeDir("xdg"); + const readOnlyConfig = await write("xdg/opencode/config.json", "{ broken ]"); + process.env.XDG_CONFIG_HOME = xdg; + const captured = captureConsole("warn"); + + try { + const outcome = await editor().removePluginEntry("my-pkg", { scope: "global", projectDir }); + + expect(outcome.action).toBe("noop"); + expect(captured.lines.some((message) => message.includes(readOnlyConfig))).toBe(true); + expect(await readFile(readOnlyConfig, "utf-8")).toBe("{ broken ]"); + } finally { + captured.restore(); + delete process.env.XDG_CONFIG_HOME; + } + }); + test("strict .json rejects comments and trailing commas", async () => { const projectDir = await makeDir("project"); await write("project/opencode.json", '{ "plugin": ["a",], }\n'); @@ -166,6 +229,18 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { expect(outcome.action).toBe("updated"); }); + test("lenient .jsonc tolerates a comment between a trailing comma and its closer", async () => { + const projectDir = await makeDir("project"); + await write("project/opencode.jsonc", '{ "plugin": ["other",/* c */], }\n'); + + const outcome = await editor().ensurePluginEntry("my-pkg", { scope: "local", projectDir }); + + expect(outcome.action).toBe("updated"); + const parsed = parseJsonc(await readFile(path.join(projectDir, "opencode.jsonc"), "utf-8")); + expect(parsed.plugin).toContain("my-pkg@latest"); + expect(parsed.plugin).toContain("other"); + }); + test("schema URLs with // and escaped quotes survive splicing and parsing", async () => { const projectDir = await makeDir("project"); const configPath = await write( @@ -177,7 +252,7 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { 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"]); + expect(parsed.plugin).toEqual(["my-pkg@latest"]); }); test("scope base config wins over repo root; existing scope-base file is edited", async () => { @@ -233,7 +308,7 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { 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"]); + expect(created.plugin).toEqual(["my-pkg@latest"]); } finally { delete process.env.XDG_CONFIG_HOME; } @@ -244,7 +319,7 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { 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'); + expect(await readFile(configPath, "utf-8")).toBe('{"plugin":["my-pkg@latest"]}\n'); }); test("config without a plugin key gets one spliced in, rest untouched", async () => { @@ -256,9 +331,9 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { 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('"plugin": ["my-pkg@latest"],'); expect(after).toContain('"model": "x/y"'); const parsed = JSON.parse(after.replace(/,(\s*[}\]])/g, "$1")); - expect(parsed.plugin).toEqual(["my-pkg"]); + expect(parsed.plugin).toEqual(["my-pkg@latest"]); }); }); diff --git a/tests/readme.test.ts b/tests/readme.test.ts new file mode 100644 index 0000000..4a804dc --- /dev/null +++ b/tests/readme.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +const README_PATH = path.resolve(import.meta.dirname, "..", "README.md"); + +async function badgeRow(): Promise { + const lines = (await readFile(README_PATH, "utf-8")).split(/\r?\n/); + const headingIndex = lines.findIndex((line) => line.startsWith("# ")); + const row = lines[headingIndex + 1] ?? ""; + return row.trim(); +} + +describe("README badge row (D7)", () => { + test("one single line directly below the heading with the required badges", async () => { + const row = await badgeRow(); + + expect(row).toContain("img.shields.io/npm/v/opencode-architect"); + expect(row).toContain("Runtime-Bun"); + expect(row).toContain("License-MIT"); + expect(row).toContain("Platforms-Linux"); + expect(row).toContain("opencode-plugin-blueviolet"); + expect(row).not.toMatch(/\r?\n.*shields\.io/); + }); + + test("badge links resolve: license file and platforms anchor exist", async () => { + const row = await badgeRow(); + + const licenseTarget = row.match(/\[!\[License: MIT\]\([^)]+\)\]\(([^)]+)\)/)?.[1] ?? ""; + expect(licenseTarget).toBe("LICENSE.md"); + expect(existsSync(path.resolve(path.dirname(README_PATH), licenseTarget))).toBe(true); + + const platformsTarget = row.match(/\[!\[Platforms\]\([^)]+\)\]\(([^)]+)\)/)?.[1] ?? ""; + expect(platformsTarget.startsWith("#")).toBe(true); + const readme = await readFile(README_PATH, "utf-8"); + expect(headingSlugExists(readme, platformsTarget.slice(1))).toBe(true); + }); + + test("badge URLs use the repo's canonical casing", async () => { + const row = await badgeRow(); + expect(row).not.toContain("expert-vision-software/opencode-architect"); + }); +}); + +function headingSlugExists(readme: string, slug: string): boolean { + return readme + .split(/\r?\n/) + .filter((line) => line.startsWith("##")) + .some((line) => { + const text = line.replace(/^#+\s*/, "").toLowerCase().replace(/[^a-z0-9 -]/g, "").trim(); + return text.replace(/\s+/g, "-") === slug; + }); +} + +describe("shipped config snippets (D8)", () => { + test("README snippets use the plugin key with canonical entries, never plugins", async () => { + const readme = await readFile(README_PATH, "utf-8"); + + expect(readme).toContain('"plugin": ["opencode-architect@latest"]'); + expect(readme).not.toContain('"plugins"'); + expect(readme).toContain("opencode-architect@latest"); + }); +}); diff --git a/tests/test-helpers.ts b/tests/test-helpers.ts index 8fccfbf..e10f653 100644 --- a/tests/test-helpers.ts +++ b/tests/test-helpers.ts @@ -1,7 +1,20 @@ import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; -import { expect } from "bun:test"; -import { ClearCacheUsageError } from "../clear-cache-usage-error"; +import { expect, spyOn } from "bun:test"; +import { ClearCacheUsageError } from "../src/clear-cache-usage-error"; + +export interface CapturedConsole { + lines: string[]; + restore: () => void; +} + +export function captureConsole(method: "log" | "warn"): CapturedConsole { + const lines: string[] = []; + const spy = spyOn(console, method).mockImplementation((message: unknown) => { + lines.push(String(message)); + }); + return { lines, restore: () => spy.mockRestore() }; +} export async function seedCachedPackage(dir: string, name: string): Promise { const target = path.join(dir, name);