From 9b865bc1b3e8dc706035a16cc5487bc2d8da78c1 Mon Sep 17 00:00:00 2001 From: dbpolito Date: Mon, 8 Jun 2026 11:42:28 -0300 Subject: [PATCH] fix: run opencode tools with internal shell - add a Node-backed shell runner for Kompass OpenCode tools - register shell-backed tools without depending on plugin input shell state - cover shell execution through tool registration and plugin tests - update OpenCode SDK and plugin dependencies --- bun.lock | 42 ++--- packages/opencode/index.ts | 148 +++++++++++++++--- packages/opencode/package.json | 6 +- .../opencode/test/tool-registration.test.ts | 100 +++++++++--- 4 files changed, 231 insertions(+), 65 deletions(-) diff --git a/bun.lock b/bun.lock index 382ffa2..ca6898b 100644 --- a/bun.lock +++ b/bun.lock @@ -18,14 +18,14 @@ }, "packages/opencode": { "name": "@kompassdev/opencode", - "version": "0.12.1", + "version": "0.13.2", "devDependencies": { - "@opencode-ai/plugin": "^1.4.8", - "@opencode-ai/sdk": "^1.4.8", + "@opencode-ai/plugin": "^1.16.2", + "@opencode-ai/sdk": "^1.16.2", "yaml": "^2.8.2", }, "peerDependencies": { - "@opencode-ai/plugin": "^1.4.8", + "@opencode-ai/plugin": "^1.16.2", }, }, "packages/web": { @@ -214,21 +214,21 @@ "@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="], - "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="], + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="], - "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="], + "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="], - "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw=="], + "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw=="], - "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg=="], + "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw=="], - "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg=="], + "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ=="], - "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ=="], + "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="], - "@opencode-ai/plugin": ["@opencode-ai/plugin@1.4.8", "", { "dependencies": { "@opencode-ai/sdk": "1.4.8", "effect": "4.0.0-beta.48", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.1.100", "@opentui/solid": ">=0.1.100" }, "optionalPeers": ["@opentui/core", "@opentui/solid"] }, "sha512-arbggGAwR7vE6d5a/Ra8A7yECXYcOAPyRbJHzkofLLiVzyclsThFaL2SSCZw/UNJJTtt3L7JGl95phFodJq8tQ=="], + "@opencode-ai/plugin": ["@opencode-ai/plugin@1.16.2", "", { "dependencies": { "@opencode-ai/sdk": "1.16.2", "effect": "4.0.0-beta.74", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.3.2", "@opentui/keymap": ">=0.3.2", "@opentui/solid": ">=0.3.2" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-FaZhVXrbz93xsdGLCtarRDTeqFt8AkLfh8B34tFBj6G4HXVmKSgBwVXmtELKKC+08xMtawBC9hshiMbXryv6cg=="], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.4.8", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-DTN0TwRxuBxdm2JvJO3Dg7Vp9/j8PFpTS/26qD6Mzi6UPI5+NBxgcDVkozKygi55Goj3AAQGJPp63qzbdc+8ag=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.16.2", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-Z/xZ7q79dYeE0afqIk/yFEcRNGEQFcE+H8ssYivUiy+xGZ1mGwT72jpaQZKBwPn3JH4sRCu4KA2lcktBQfcOjg=="], "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], @@ -490,7 +490,7 @@ "dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="], - "effect": ["effect@4.0.0-beta.48", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw=="], + "effect": ["effect@4.0.0-beta.74", "", { "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-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="], "emmet": ["emmet@2.4.11", "", { "dependencies": { "@emmetio/abbreviation": "^2.3.3", "@emmetio/css-abbreviation": "^2.1.8" } }, "sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ=="], @@ -534,7 +534,7 @@ "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], - "fast-check": ["fast-check@4.7.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ=="], + "fast-check": ["fast-check@4.8.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], @@ -612,7 +612,7 @@ "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], - "ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], + "ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="], "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], @@ -774,9 +774,9 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "msgpackr": ["msgpackr@1.11.9", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-FkoAAyyA6HM8wL882EcEyFZ9s7hVADSwG9xrVx3dxxNQAtgADTrJoEWivID82Iv1zWDsv/OtbrrcZAzGzOMdNw=="], + "msgpackr": ["msgpackr@2.0.3", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-vFKpMYFTEQujRQxvdS/u6zlfesws0J40K74w6E1fVsYnIa9WKJKB5xIVVON8L7S39hCNrCVGXcPjrYmCb9lT+w=="], - "msgpackr-extract": ["msgpackr-extract@3.0.3", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA=="], + "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=="], "muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="], @@ -1008,7 +1008,7 @@ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], - "uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="], + "uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="], "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], @@ -1068,7 +1068,7 @@ "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], - "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], + "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], "yaml-language-server": ["yaml-language-server@1.20.0", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "prettier": "^3.5.0", "request-light": "^0.5.7", "vscode-json-languageservice": "4.1.8", "vscode-languageserver": "^9.0.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.16.0", "vscode-uri": "^3.0.2", "yaml": "2.7.1" }, "bin": { "yaml-language-server": "bin/yaml-language-server" } }, "sha512-qhjK/bzSRZ6HtTvgeFvjNPJGWdZ0+x5NREV/9XZWFjIGezew2b4r5JPy66IfOhd5OA7KeFwk1JfmEbnTvev0cA=="], @@ -1092,6 +1092,8 @@ "@astrojs/sitemap/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "@astrojs/yaml2ts/yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], + "@expressive-code/plugin-shiki/shiki": ["shiki@1.29.2", "", { "dependencies": { "@shikijs/core": "1.29.2", "@shikijs/engine-javascript": "1.29.2", "@shikijs/engine-oniguruma": "1.29.2", "@shikijs/langs": "1.29.2", "@shikijs/themes": "1.29.2", "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-njXuliz/cP+67jU2hukkxCNuH1yUi4QfdZZY+sMr5PPrIyXSu5iTb/qYC4BiWWB0vZ+7TbdvYUCeL23zpwCfbg=="], "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], @@ -1108,7 +1110,7 @@ "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], - "effect/yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], + "effect/yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], diff --git a/packages/opencode/index.ts b/packages/opencode/index.ts index 07eabbd..f44eb46 100644 --- a/packages/opencode/index.ts +++ b/packages/opencode/index.ts @@ -1,5 +1,7 @@ import type { Hooks, Plugin, PluginInput } from "@opencode-ai/plugin"; import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; import { createChangesLoadTool, @@ -11,6 +13,7 @@ import { getEnabledToolNames, type MergedKompassConfig, type Shell, + type ShellPromise, } from "../core/index.ts"; import { loadConfiguredNames, loadMergedKompassConfig } from "./cache.ts"; import { applyAgentsConfig, applyCommandsConfig } from "./config.ts"; @@ -20,6 +23,7 @@ import { } from "./tool-names.ts"; const AGENT_HANDOFF_MARKER = "generate a prompt and call the task tool with subagent:"; +const execFileAsync = promisify(execFile); type ToolExecuteBeforeHook = NonNullable; type ToolExecuteBeforeInput = Parameters[0]; @@ -30,14 +34,122 @@ type CommandExecuteBeforeOutput = Parameters[1]; type ChatMessageHook = NonNullable; type ChatMessageOutput = Parameters[1]; type OpenCodeToolCreator = ( - $: PluginInput["$"], client: PluginInput["client"], config: MergedKompassConfig, projectRoot: string, + shell: Shell, ) => ToolDefinition; -function asShell(shell: PluginInput["$"]): Shell { - return shell as unknown as Shell; +type ShellResult = ShellPromise & { + stdout: Buffer; +}; + +function shellEscape(value: unknown): string { + return `'${String(value).replaceAll("'", `'\\''`)}'`; +} + +function shellResult(stdout: string, stderr: string, exitCode: number): ShellResult { + return { + cwd: () => { + throw new Error("Cannot change cwd after execution"); + }, + quiet: () => { + throw new Error("Cannot change quiet after execution"); + }, + nothrow: () => { + throw new Error("Cannot change nothrow after execution"); + }, + text: () => stdout, + json: () => JSON.parse(stdout), + exitCode, + stderr: Buffer.from(stderr), + stdout: Buffer.from(stdout), + }; +} + +class NodeShellCommand implements PromiseLike { + #command: string; + #cwd: string; + #quiet = false; + #nothrow = false; + #result?: Promise; + + constructor(command: string, cwd: string) { + this.#command = command; + this.#cwd = cwd; + } + + cwd(dir: string) { + this.#cwd = dir; + return this; + } + + quiet() { + this.#quiet = true; + return this; + } + + nothrow() { + this.#nothrow = true; + return this; + } + + then( + onfulfilled?: ((value: ShellResult) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, + ) { + return this.run().then(onfulfilled, onrejected); + } + + private run() { + this.#result ??= this.execute(); + return this.#result; + } + + private async execute() { + try { + const { stdout, stderr } = await execFileAsync("/bin/bash", ["-lc", this.#command], { + cwd: this.#cwd, + encoding: "utf8", + maxBuffer: 10 * 1024 * 1024, + }); + + this.write(stdout, stderr); + return shellResult(stdout, stderr, 0); + } catch (error) { + const failed = error as { stdout?: unknown; stderr?: unknown; message?: unknown; code?: unknown }; + const stdout = String(failed.stdout ?? ""); + const stderr = String(failed.stderr ?? failed.message ?? ""); + const exitCode = typeof failed.code === "number" ? failed.code : 1; + const result = shellResult(stdout, stderr, exitCode); + + this.write(stdout, stderr); + if (this.#nothrow) return result; + + throw new Error(stderr || `Command failed: ${this.#command}`); + } + } + + private write(stdout: string, stderr: string) { + if (this.#quiet) return; + if (stdout) process.stdout.write(stdout); + if (stderr) process.stderr.write(stderr); + } +} + +function createNodeShell(defaultDirectory: string): Shell { + return (strings: TemplateStringsArray, ...expressions: unknown[]) => { + let command = strings[0] ?? ""; + + expressions.forEach((expression, index) => { + command += Array.isArray(expression) + ? expression.map((item) => shellEscape(item)).join(" ") + : shellEscape(expression); + command += strings[index + 1] ?? ""; + }); + + return new NodeShellCommand(command, defaultDirectory) as unknown as ShellPromise; + }; } export type TaskToolExecution = { @@ -124,8 +236,8 @@ export function removeSyntheticAgentHandoff(output: ChatMessageOutput): boolean } const opencodeToolCreators: Record = { - changes_load($: PluginInput["$"], _: PluginInput["client"], __: MergedKompassConfig, _projectRoot: string) { - const definition = createChangesLoadTool(asShell($)); + changes_load(_: PluginInput["client"], __: MergedKompassConfig, _projectRoot: string, shell: Shell) { + const definition = createChangesLoadTool(shell); return tool({ description: definition.description, args: { @@ -141,7 +253,7 @@ const opencodeToolCreators: Record = { execute: (args, context) => definition.execute(args, context), }); }, - command_expansion(_: PluginInput["$"], _client: PluginInput["client"], config: MergedKompassConfig, projectRoot: string) { + command_expansion(_client: PluginInput["client"], config: MergedKompassConfig, projectRoot: string, _shell: Shell) { return tool({ description: "Expand a delegated command body into a runnable prompt for immediate task execution.", args: { @@ -163,8 +275,8 @@ const opencodeToolCreators: Record = { }, }); }, - pr_load($: PluginInput["$"], _: PluginInput["client"], __: MergedKompassConfig, _projectRoot: string) { - const definition = createPrLoadTool(asShell($)); + pr_load(_: PluginInput["client"], __: MergedKompassConfig, _projectRoot: string, shell: Shell) { + const definition = createPrLoadTool(shell); return tool({ description: definition.description, args: { @@ -173,8 +285,8 @@ const opencodeToolCreators: Record = { execute: (args, context) => definition.execute(args, context), }); }, - pr_sync($: PluginInput["$"], _: PluginInput["client"], config: MergedKompassConfig, _projectRoot: string) { - const definition = createPrSyncTool(asShell($)); + pr_sync(_: PluginInput["client"], config: MergedKompassConfig, _projectRoot: string, shell: Shell) { + const definition = createPrSyncTool(shell); return tool({ description: definition.description, @@ -219,8 +331,8 @@ const opencodeToolCreators: Record = { execute: (args, context) => definition.execute(args, context), }); }, - ticket_sync($: PluginInput["$"], _: PluginInput["client"], __: MergedKompassConfig, _projectRoot: string) { - const definition = createTicketSyncTool(asShell($)); + ticket_sync(_: PluginInput["client"], __: MergedKompassConfig, _projectRoot: string, shell: Shell) { + const definition = createTicketSyncTool(shell); return tool({ description: definition.description, args: { @@ -242,8 +354,8 @@ const opencodeToolCreators: Record = { execute: (args, context) => definition.execute(args, context), }); }, - ticket_load($: PluginInput["$"], _: PluginInput["client"], __: MergedKompassConfig, _projectRoot: string) { - const definition = createTicketLoadTool(asShell($)); + ticket_load(_: PluginInput["client"], __: MergedKompassConfig, _projectRoot: string, shell: Shell) { + const definition = createTicketLoadTool(shell); return tool({ description: definition.description, args: { @@ -256,19 +368,19 @@ const opencodeToolCreators: Record = { }; export async function createOpenCodeTools( - $: PluginInput["$"], client: PluginInput["client"], projectRoot: string, ): Promise> { const config = await loadMergedKompassConfig(projectRoot); const tools: Record = {}; const logger = createPluginLogger(client, projectRoot); + const shell = createNodeShell(projectRoot); for (const toolName of getEnabledToolNames(config.tools)) { const creator = opencodeToolCreators[toolName as keyof typeof opencodeToolCreators]; if (creator) { const registeredName = getConfiguredOpenCodeToolName(toolName, config.tools[toolName].name); - tools[registeredName] = creator($, client, config, projectRoot); + tools[registeredName] = creator(client, config, projectRoot, shell); logger.info("Loaded Kompass tool", { tool: toolName, registeredName, @@ -280,7 +392,7 @@ export async function createOpenCodeTools( } export const OpenCodeCompassPlugin: Plugin = async (input: PluginInput) => { - const { $, client, worktree } = input; + const { client, worktree } = input; const logger = createPluginLogger(client, worktree); logger.info("Initialized Kompass plugin", { @@ -291,7 +403,7 @@ export const OpenCodeCompassPlugin: Plugin = async (input: PluginInput) => { async function createToolsSafely() { try { - return await createOpenCodeTools($, client, worktree); + return await createOpenCodeTools(client, worktree); } catch (error) { logger.warn("Skipping Kompass tool registration", { ...getErrorDetails(error), diff --git a/packages/opencode/package.json b/packages/opencode/package.json index e967f1e..1eb7524 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -47,11 +47,11 @@ "url": "https://github.com/kompassdev/kompass/issues" }, "peerDependencies": { - "@opencode-ai/plugin": "^1.4.8" + "@opencode-ai/plugin": "^1.16.2" }, "devDependencies": { - "@opencode-ai/plugin": "^1.4.8", - "@opencode-ai/sdk": "^1.4.8", + "@opencode-ai/plugin": "^1.16.2", + "@opencode-ai/sdk": "^1.16.2", "yaml": "^2.8.2" } } diff --git a/packages/opencode/test/tool-registration.test.ts b/packages/opencode/test/tool-registration.test.ts index 7d3db55..cdc6b7f 100644 --- a/packages/opencode/test/tool-registration.test.ts +++ b/packages/opencode/test/tool-registration.test.ts @@ -1,11 +1,15 @@ import { describe, test } from "node:test"; import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { promisify } from "node:util"; import { createOpenCodeTools, OpenCodeCompassPlugin } from "../index.ts"; +const execFileAsync = promisify(execFile); + type MockLogEntry = { query?: { directory?: string }; body?: { level?: string; message?: string; extra?: Record }; @@ -105,12 +109,27 @@ function createMockClient(): MockClient { }; } +async function git(cwd: string, args: string[]) { + await execFileAsync("git", args, { cwd }); +} + +async function createTempGitRepo() { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "kompass-tools-git-")); + + await git(tempDir, ["init", "-b", "main"]); + await git(tempDir, ["config", "user.email", "test@example.com"]); + await git(tempDir, ["config", "user.name", "Test User"]); + await writeFile(path.join(tempDir, "README.md"), "test\n"); + await git(tempDir, ["add", "README.md"]); + await git(tempDir, ["commit", "-m", "initial"]); + + return tempDir; +} + describe("createOpenCodeTools", () => { test("registers Kompass tools with prefixed names", async () => { await withTempHome(async () => { - const tools = await createOpenCodeTools((() => { - throw new Error("not implemented"); - }) as never, createMockClient() as never, process.cwd()); + const tools = await createOpenCodeTools(createMockClient() as never, process.cwd()); assert.ok(tools.kompass_changes_load); assert.ok(tools.kompass_command_expansion); @@ -127,6 +146,27 @@ describe("createOpenCodeTools", () => { }); }); + test("runs shell tools with the Kompass shell runner", async () => { + await withTempHome(async () => { + const tempDir = await createTempGitRepo(); + + try { + const tools = await createOpenCodeTools(createMockClient() as never, tempDir); + const output = await (tools.kompass_changes_load as any).execute( + { base: "HEAD" }, + { + directory: tempDir, + worktree: tempDir, + }, + ); + + assert.deepEqual(JSON.parse(output).files, []); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + }); + test("registers configured tool aliases instead of default prefixed names", async () => { await withTempHome(async () => { const tempDir = await mkdtemp(path.join(os.tmpdir(), "kompass-tools-")); @@ -150,9 +190,7 @@ describe("createOpenCodeTools", () => { }`, ); - const tools = await createOpenCodeTools((() => { - throw new Error("not implemented"); - }) as never, createMockClient() as never, tempDir); + const tools = await createOpenCodeTools(createMockClient() as never, tempDir); assert.ok(tools.custom_ticket_name); assert.equal(tools.kompass_ticket_sync, undefined); @@ -185,9 +223,7 @@ describe("createOpenCodeTools", () => { }`, ); - const tools = await createOpenCodeTools((() => { - throw new Error("not implemented"); - }) as never, createMockClient() as never, tempDir); + const tools = await createOpenCodeTools(createMockClient() as never, tempDir); assert.ok(tools.pull_request_context); assert.equal(tools.kompass_pr_load, undefined); @@ -202,9 +238,7 @@ describe("createOpenCodeTools", () => { const tempDir = await mkdtemp(path.join(os.tmpdir(), "kompass-tools-no-approve-")); try { - const tools = await createOpenCodeTools((() => { - throw new Error("not implemented"); - }) as never, createMockClient() as never, tempDir); + const tools = await createOpenCodeTools(createMockClient() as never, tempDir); const reviewShape = (tools.kompass_pr_sync as any).args.review.unwrap().shape; assert.equal(reviewShape.approve, undefined); @@ -229,9 +263,7 @@ describe("createOpenCodeTools", () => { }`, ); - const tools = await createOpenCodeTools((() => { - throw new Error("not implemented"); - }) as never, createMockClient() as never, tempDir); + const tools = await createOpenCodeTools(createMockClient() as never, tempDir); const reviewShape = (tools.kompass_pr_sync as any).args.review.unwrap().shape; assert.ok(reviewShape.approve); @@ -243,9 +275,7 @@ describe("createOpenCodeTools", () => { test("exposes ticket assignees and comments, and PR assignees", async () => { await withTempHome(async () => { - const tools = await createOpenCodeTools((() => { - throw new Error("not implemented"); - }) as never, createMockClient() as never, process.cwd()); + const tools = await createOpenCodeTools(createMockClient() as never, process.cwd()); const prSyncArgs = (tools.kompass_pr_sync as any).args; const ticketSyncArgs = (tools.kompass_ticket_sync as any).args; @@ -259,9 +289,7 @@ describe("createOpenCodeTools", () => { test("command_expansion returns expanded prompts for delegated task execution", async () => { await withTempHome(async () => { const client = createMockClient(); - const tools = await createOpenCodeTools((() => { - throw new Error("not implemented"); - }) as never, client as never, process.cwd()); + const tools = await createOpenCodeTools(client as never, process.cwd()); const output = await (tools.kompass_command_expansion as any).execute( { command: "review", body: "auth bug" }, @@ -289,9 +317,7 @@ describe("createOpenCodeTools", () => { test("command tool rejects missing commands", async () => { await withTempHome(async () => { const client = createMockClient(); - const tools = await createOpenCodeTools((() => { - throw new Error("not implemented"); - }) as never, client as never, process.cwd()); + const tools = await createOpenCodeTools(client as never, process.cwd()); await assert.rejects( (tools.kompass_command_expansion as any).execute( @@ -314,6 +340,32 @@ describe("createOpenCodeTools", () => { }); }); + test("plugin registers shell-backed tools that execute in the worktree", async () => { + await withTempHome(async () => { + const tempDir = await createTempGitRepo(); + + try { + const plugin = await OpenCodeCompassPlugin({ + client: createMockClient() as never, + directory: tempDir, + worktree: tempDir, + } as never); + + const output = await (plugin.tool?.kompass_changes_load as any).execute( + { base: "HEAD" }, + { + directory: tempDir, + worktree: tempDir, + }, + ); + + assert.deepEqual(JSON.parse(output).files, []); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + }); + test("does not expand slash commands in the task hook", async () => { await withTempHome(async () => { const client = createMockClient();