Package CodeNomad OpenCode plugin (#433)

## Summary
- Rename the OpenCode config template into a versioned npm-packable
CodeNomad plugin package.
- Build and package the plugin through the server bundle, with
Electron/Tauri carrying it via existing server resources.
- Replace OPENCODE_CONFIG_DIR injection with JSONC-aware
OPENCODE_CONFIG_CONTENT merging that appends the CodeNomad plugin while
preserving user config.

## Validation
- npm run build --workspace @codenomad/codenomad-opencode-plugin
- npm run prepare-plugin --workspace @neuralnomads/codenomad
- npm run typecheck --workspace @neuralnomads/codenomad
- npm run typecheck --workspace @neuralnomads/codenomad-electron-app
- node --import tsx --test \"src/opencode-plugin.test.ts\"
\"src/workspaces/__tests__/spawn.test.ts\"

## Notes
- Production plugin loading uses an explicit npm file alias for the
packaged tarball.
- Dev loading still references the TypeScript plugin entry directly.

---------

Co-authored-by: Pascal André <pascalandr@gmail.com>
This commit is contained in:
Shantur Rathore 2026-05-12 09:00:22 +01:00 committed by GitHub
parent 9165299027
commit cf88dc06ef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 671 additions and 566 deletions

254
package-lock.json generated
View file

@ -29,7 +29,7 @@
"packages/ui",
"packages/electron-app",
"packages/tauri-app",
"packages/opencode-config"
"packages/opencode-plugin"
]
}
},
@ -1575,8 +1575,8 @@
"dev": true,
"license": "MIT"
},
"node_modules/@codenomad/opencode-config": {
"resolved": "packages/opencode-config",
"node_modules/@codenomad/codenomad-opencode-plugin": {
"resolved": "packages/opencode-plugin",
"link": true
},
"node_modules/@codenomad/tauri-app": {
@ -3228,6 +3228,43 @@
"node": ">= 8"
}
},
"node_modules/@opencode-ai/plugin": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.3.7.tgz",
"integrity": "sha512-pVBIcYtHiniQ93Gj/KRkhrIz1oIAwGRifb7+dfGWdHRy00gr9DyEHFYmgHcBYgfrBavZrWw2xmqEDJdjdBuC7g==",
"license": "MIT",
"dependencies": {
"@opencode-ai/sdk": "1.3.7",
"zod": "4.1.8"
},
"peerDependencies": {
"@opentui/core": ">=0.1.92",
"@opentui/solid": ">=0.1.92"
},
"peerDependenciesMeta": {
"@opentui/core": {
"optional": true
},
"@opentui/solid": {
"optional": true
}
}
},
"node_modules/@opencode-ai/plugin/node_modules/@opencode-ai/sdk": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.3.7.tgz",
"integrity": "sha512-ugkta0v0dMZchN15QGmqHb9zf35k+K1VM9wt3x4ZRJ6GxKAs0XlCmQPQJflgV9YSedNxjkgTud0GCCIWUSiUOg==",
"license": "MIT"
},
"node_modules/@opencode-ai/plugin/node_modules/zod": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz",
"integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@opencode-ai/sdk": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.2.6.tgz",
@ -4013,6 +4050,157 @@
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.9.4.tgz",
"integrity": "sha512-tTWkEPig+2z3Rk0zqZYfjUYcgD+aSm72wdrIhdYobxbQZOBw0zfn50YtWv+av7bm0SHvv75f0l7JuwgZM1HFow==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.9.4.tgz",
"integrity": "sha512-ql6vJ611qoqRYHxkKPnb2vHa27U+YRKRmIpLMMBeZnfFtZ938eao7402AQCH1mO2+/8ioUhbpy9R/ZcLTXVmkg==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.9.4.tgz",
"integrity": "sha512-vg7yNn7ICTi6hRrcA/6ff2UpZQP7un3xe3SEld5QM0prgridbKAiXGaCKr3BnUBx/rGXegQlD/wiLcWdiiraSw==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.9.4.tgz",
"integrity": "sha512-l8L+3VxNk6yv5T/Z/gv5ysngmIpsai40B9p6NQQyqYqxImqYX37pqREoEBl1YwG7szGnDibpWhidPrWKR59OJA==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.9.4.tgz",
"integrity": "sha512-PepPhCXc/xVvE3foykNho46OmCyx47E/aG676vKTVp+mqin5d+IBqDL6wDKiGNT5OTTxKEyNlCQ81Xs2BQhhqA==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.9.4.tgz",
"integrity": "sha512-zcd1QVffh5tZs1u1SCKUV/V7RRynebgYUNWHuV0FsIF1MjnULUChEXhAhug7usCDq4GZReMJOoXa6rukEozWIw==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.9.4.tgz",
"integrity": "sha512-/7ZhnP6PY04bEob23q8MH/EoDISdmR1wuNm0k9d5HV7TDMd2GGCDa8dPXA4vJuglJKXIfXqxFmZ4L+J+MO42+w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.9.4.tgz",
"integrity": "sha512-1LmAfaC4Cq+3O1Ir1ksdhczhdtFSTIV51tbAGtbV/mr348O+M52A/xwCCXQank0OcdBxy5BctqkMtuZnQvA8uQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.9.4.tgz",
@ -4030,6 +4218,23 @@
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli/node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.9.4.tgz",
"integrity": "sha512-9rHkMVtbMhe0AliVbrGpzMahOBg3rwV46JYRELxR9SN6iu1dvPOaMaiC4cP6M/aD1424ziXnnMdYU06RAH8oIw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
@ -13243,49 +13448,16 @@
"dev": true,
"license": "MIT"
},
"packages/opencode-config": {
"name": "@codenomad/opencode-config",
"packages/opencode-plugin": {
"name": "@codenomad/codenomad-opencode-plugin",
"version": "0.15.0",
"license": "MIT",
"dependencies": {
"@opencode-ai/plugin": "1.3.7"
}
},
"packages/opencode-config/node_modules/@opencode-ai/plugin": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.3.7.tgz",
"integrity": "sha512-pVBIcYtHiniQ93Gj/KRkhrIz1oIAwGRifb7+dfGWdHRy00gr9DyEHFYmgHcBYgfrBavZrWw2xmqEDJdjdBuC7g==",
"license": "MIT",
"dependencies": {
"@opencode-ai/sdk": "1.3.7",
"zod": "4.1.8"
},
"peerDependencies": {
"@opentui/core": ">=0.1.92",
"@opentui/solid": ">=0.1.92"
},
"peerDependenciesMeta": {
"@opentui/core": {
"optional": true
},
"@opentui/solid": {
"optional": true
}
}
},
"packages/opencode-config/node_modules/@opencode-ai/sdk": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.3.7.tgz",
"integrity": "sha512-ugkta0v0dMZchN15QGmqHb9zf35k+K1VM9wt3x4ZRJ6GxKAs0XlCmQPQJflgV9YSedNxjkgTud0GCCIWUSiUOg==",
"license": "MIT"
},
"packages/opencode-config/node_modules/zod": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz",
"integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
"devDependencies": {
"@types/node": "^22.18.0",
"typescript": "^5.6.3"
}
},
"packages/server": {

View file

@ -10,7 +10,7 @@
"packages/ui",
"packages/electron-app",
"packages/tauri-app",
"packages/opencode-config"
"packages/opencode-plugin"
]
},
"scripts": {

View file

@ -79,8 +79,8 @@
]
},
{
"from": "../server/dist/opencode-config",
"to": "opencode-config"
"from": "../server/dist/opencode-plugin",
"to": "opencode-plugin"
}
],
"mac": {

View file

@ -137,6 +137,7 @@ async function build(platform) {
console.log(`\n📦 Preparing resources for ${job.nodeTarget}...\n`)
await run(process.execPath, [join(appDir, "scripts", "prepare-resources.js")], {
cwd: workspaceRoot,
shell: false,
env: { NODE_PATH: workspaceNodeModulesPath, CODENOMAD_NODE_TARGET: job.nodeTarget },
})

View file

@ -1,3 +0,0 @@
{
"$schema": "https://opencode.ai/config.json"
}

View file

@ -1,380 +0,0 @@
{
"name": "@codenomad/opencode-config",
"version": "0.15.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@codenomad/opencode-config",
"version": "0.15.0",
"license": "MIT",
"dependencies": {
"@opencode-ai/plugin": "1.14.24"
}
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
"integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz",
"integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz",
"integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz",
"integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
"integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz",
"integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@opencode-ai/plugin": {
"version": "1.14.24",
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.14.24.tgz",
"integrity": "sha512-upzw2a9KfzIkIvvjYSPJiyV6o85D3HLmhVvAJIwV8mYWxbvi2wP2NA0hJaMp2+GZVuUl/ra8WV8kacD1CWcb4w==",
"license": "MIT",
"dependencies": {
"@opencode-ai/sdk": "1.14.24",
"effect": "4.0.0-beta.48",
"zod": "4.1.8"
},
"peerDependencies": {
"@opentui/core": ">=0.1.99",
"@opentui/solid": ">=0.1.99"
},
"peerDependenciesMeta": {
"@opentui/core": {
"optional": true
},
"@opentui/solid": {
"optional": true
}
}
},
"node_modules/@opencode-ai/sdk": {
"version": "1.14.24",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.14.24.tgz",
"integrity": "sha512-hZWc1jx+gtZBM6Mff9iOMlXM1at9BbAGg0uNrQk8DuXpd8K19fu942emojdInO2zy0jC5/wWggsi7GJu7HMp/w==",
"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.48",
"resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.48.tgz",
"integrity": "sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw==",
"license": "MIT",
"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"
}
},
"node_modules/fast-check": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz",
"integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==",
"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": "6.0.0",
"resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz",
"integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==",
"license": "ISC",
"engines": {
"node": "^20.17.0 || >=22.9.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/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": "1.11.10",
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.10.tgz",
"integrity": "sha512-iCZNq+HszvF+fC3anCm4nBmWEnbeIAfpDs6IStAEKhQ2YSgkjzVG2FF9XJqwwQh5bH3N9OUTUt4QwVN6MLMLtA==",
"license": "MIT",
"optionalDependencies": {
"msgpackr-extract": "^3.0.2"
}
},
"node_modules/msgpackr-extract": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz",
"integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==",
"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.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"
}
},
"node_modules/multipasta": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz",
"integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==",
"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.0",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz",
"integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==",
"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.1.1",
"resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz",
"integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/uuid": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"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.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"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"
}
}
}
}

View file

@ -1,9 +0,0 @@
{
"name": "@codenomad/opencode-config",
"version": "0.15.0",
"private": true,
"license": "MIT",
"dependencies": {
"@opencode-ai/plugin": "1.3.7"
}
}

View file

@ -1,16 +1,16 @@
# opencode-config
# CodeNomad OpenCode Plugin
## TLDR
Template config + plugins injected into every OpenCode instance that CodeNomad launches. It provides a CodeNomad bridge plugin for local event exchange between the CLI server and opencode.
Packaged OpenCode plugin injected into every OpenCode instance that CodeNomad launches. It provides the CodeNomad bridge for local event exchange between the CLI server and OpenCode.
## What it is
A packaged config directory that CodeNomad copies into `~/.config/codenomad/opencode-config` for production builds or uses directly in dev. OpenCode autoloads any `plugin/*.ts` or `plugin/*.js` from this directory.
An npm-packable plugin package. Production builds ship a local `.tgz` and inject it through `OPENCODE_CONFIG_CONTENT`; dev runs reference the TypeScript plugin entry directly with a `file://` URL.
## How it works
- CodeNomad sets `OPENCODE_CONFIG_DIR` when spawning each opencode instance (`packages/server/src/workspaces/manager.ts`).
- This template is synced from `packages/opencode-config` (`packages/server/src/opencode-config.ts`, `packages/server/scripts/copy-opencode-config.mjs`).
- OpenCode autoloads plugins from `plugin/` (`packages/opencode-config/plugin/codenomad.ts`).
- The `CodeNomadPlugin` reads `CODENOMAD_INSTANCE_ID` + `CODENOMAD_BASE_URL`, connects to `GET /workspaces/:id/plugin/events`, and posts to `POST /workspaces/:id/plugin/event` (`packages/opencode-config/plugin/lib/client.ts`).
- CodeNomad sets `OPENCODE_CONFIG_CONTENT` when spawning each OpenCode instance (`packages/server/src/workspaces/manager.ts`).
- The server packs this package during build (`packages/server/scripts/package-opencode-plugin.mjs`).
- OpenCode loads the plugin from `plugin` entries injected into the config content.
- The `CodeNomadPlugin` reads `CODENOMAD_INSTANCE_ID` + `CODENOMAD_BASE_URL`, connects to `GET /workspaces/:id/plugin/events`, and posts to `POST /workspaces/:id/plugin/event` (`packages/opencode-plugin/plugin/lib/client.ts`).
- The server exposes the plugin routes and maps events into the UI SSE pipeline (`packages/server/src/server/routes/plugin.ts`, `packages/server/src/plugins/handlers.ts`).
## Expectations
@ -25,8 +25,8 @@ A packaged config directory that CodeNomad copies into `~/.config/codenomad/open
- Promote stable event shapes and version tags once the protocol settles.
## Pointers
- Plugin entry: `packages/opencode-config/plugin/codenomad.ts`
- Plugin client: `packages/opencode-config/plugin/lib/client.ts`
- Plugin entry: `packages/opencode-plugin/plugin/codenomad.ts`
- Plugin client: `packages/opencode-plugin/plugin/lib/client.ts`
- Plugin server routes: `packages/server/src/server/routes/plugin.ts`
- Plugin event handling: `packages/server/src/plugins/handlers.ts`
- Workspace env injection: `packages/server/src/workspaces/manager.ts`

View file

@ -0,0 +1,22 @@
{
"name": "@codenomad/codenomad-opencode-plugin",
"version": "0.15.0",
"private": true,
"license": "MIT",
"type": "module",
"main": "dist/codenomad.js",
"files": [
"dist",
"README.md"
],
"scripts": {
"build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json"
},
"dependencies": {
"@opencode-ai/plugin": "1.3.7"
},
"devDependencies": {
"@types/node": "^22.18.0",
"typescript": "^5.6.3"
}
}

View file

@ -1,10 +1,14 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { createCodeNomadClient, getCodeNomadConfig } from "./lib/client"
import { createBackgroundProcessTools } from "./lib/background-process"
import { createCodeNomadClient, getCodeNomadConfig } from "./lib/client.js"
import { createBackgroundProcessTools } from "./lib/background-process.js"
let voiceModeEnabled = false
export async function CodeNomadPlugin(input: PluginInput) {
export async function CodeNomadPlugin(input: PluginInput): Promise<{
tool: ReturnType<typeof createBackgroundProcessTools>
"chat.message": CodeNomadChatMessageHook
event: CodeNomadEventHook
}> {
const config = getCodeNomadConfig()
const client = createCodeNomadClient(config)
const backgroundProcessTools = createBackgroundProcessTools(config, { baseDir: input.directory })
@ -45,6 +49,13 @@ export async function CodeNomadPlugin(input: PluginInput) {
}
}
type CodeNomadChatMessageHook = (
_input: { sessionID: string },
output: { message: { system?: string } },
) => Promise<void>
type CodeNomadEventHook = (input: { event: any }) => Promise<void>
function buildVoiceModePrompt(): string {
return [
"Voice conversation mode is enabled.",

View file

@ -1,6 +1,6 @@
import path from "path"
import { tool } from "@opencode-ai/plugin/tool"
import { createCodeNomadRequester, type CodeNomadConfig } from "./request"
import { createCodeNomadRequester, type CodeNomadConfig } from "./request.js"
type BackgroundProcess = {
id: string

View file

@ -1,6 +1,6 @@
import { createCodeNomadRequester, type CodeNomadConfig, type PluginEvent } from "./request"
import { createCodeNomadRequester, type CodeNomadConfig, type PluginEvent } from "./request.js"
export { getCodeNomadConfig, type CodeNomadConfig, type PluginEvent } from "./request"
export { getCodeNomadConfig, type CodeNomadConfig, type PluginEvent } from "./request.js"
export function createCodeNomadClient(config: CodeNomadConfig) {
const requester = createCodeNomadRequester(config)

View file

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": false,
"outDir": "dist",
"rootDir": "plugin",
"types": ["node"]
},
"include": ["plugin/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View file

@ -17,10 +17,10 @@
"codenomad": "dist/bin.js"
},
"scripts": {
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json && node ./scripts/copy-auth-pages.mjs && npm run prepare-config",
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json && node ./scripts/copy-auth-pages.mjs && npm run prepare-plugin",
"build:ui": "npm run build --prefix ../ui",
"prepare-ui": "node ./scripts/copy-ui-dist.mjs",
"prepare-config": "node ./scripts/copy-opencode-config.mjs",
"prepare-plugin": "node ./scripts/package-opencode-plugin.mjs",
"dev": "cross-env CODENOMAD_DEV=1 CODENOMAD_SERVER_PASSWORD=codenomad-dev CLI_UI_DEV_SERVER=http://localhost:3000 CLI_HTTPS=false CLI_HTTP=true tsx src/index.ts",
"typecheck": "tsc --noEmit -p tsconfig.json"
},

View file

@ -1,61 +0,0 @@
#!/usr/bin/env node
import { spawnSync } from "child_process"
import { cpSync, existsSync, mkdirSync, rmSync } from "fs"
import path from "path"
import { fileURLToPath } from "url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const cliRoot = path.resolve(__dirname, "..")
const sourceDir = path.resolve(cliRoot, "../opencode-config")
const targetDir = path.resolve(cliRoot, "dist/opencode-config")
const nodeModulesDir = path.resolve(sourceDir, "node_modules")
const selfLinkDir = path.resolve(nodeModulesDir, "@codenomad", "opencode-config")
const npmExecPath = process.env.npm_execpath
const npmNodeExecPath = process.env.npm_node_execpath
if (!existsSync(sourceDir)) {
console.error(`[copy-opencode-config] Missing source directory at ${sourceDir}`)
process.exit(1)
}
if (!existsSync(nodeModulesDir)) {
console.log(`[copy-opencode-config] Installing opencode-config dependencies in ${sourceDir}`)
const npmArgs = [
"install",
"--prefix",
sourceDir,
"--omit=dev",
"--ignore-scripts",
"--fund=false",
"--audit=false",
"--package-lock=false",
"--workspaces=false",
]
const env = { ...process.env, npm_config_workspaces: "false" }
const npmCli = npmExecPath && npmNodeExecPath ? [npmNodeExecPath, [npmExecPath, ...npmArgs]] : null
const result = npmCli
? spawnSync(npmCli[0], npmCli[1], { cwd: sourceDir, stdio: "inherit", env })
: spawnSync("npm", npmArgs, { cwd: sourceDir, stdio: "inherit", env, shell: process.platform === "win32" })
if (result.status !== 0) {
if (result.error) {
console.error("[copy-opencode-config] npm install failed to start", result.error)
}
console.error("[copy-opencode-config] Failed to install opencode-config dependencies")
process.exit(result.status ?? 1)
}
}
// npm can create a self-referential link for scoped packages on Windows.
// That link causes recursive copies (ELOOP) during bundling.
rmSync(selfLinkDir, { recursive: true, force: true })
rmSync(targetDir, { recursive: true, force: true })
mkdirSync(path.dirname(targetDir), { recursive: true })
cpSync(sourceDir, targetDir, { recursive: true })
console.log(`[copy-opencode-config] Copied ${sourceDir} -> ${targetDir}`)

View file

@ -0,0 +1,59 @@
#!/usr/bin/env node
import { readdirSync, renameSync, rmSync, mkdirSync } from "fs"
import path from "path"
import { spawnSync } from "child_process"
import { fileURLToPath } from "url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const serverRoot = path.resolve(__dirname, "..")
const workspaceRoot = path.resolve(serverRoot, "../..")
const pluginRoot = path.resolve(serverRoot, "../opencode-plugin")
const targetDir = path.resolve(serverRoot, "dist/opencode-plugin")
const targetTarballName = "codenomad-opencode-plugin.tgz"
const pluginWorkspace = "@codenomad/codenomad-opencode-plugin"
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"
function run(command, args, options) {
const result = spawnSync(command, args, {
stdio: options?.capture ? ["ignore", "pipe", "inherit"] : "inherit",
shell: process.platform === "win32",
encoding: "utf8",
...options,
})
if (result.error) {
console.error(`[package-opencode-plugin] ${command} failed to start`, result.error)
process.exit(1)
}
if (result.status !== 0) {
console.error(`[package-opencode-plugin] ${command} exited with code ${result.status ?? 1}`)
process.exit(result.status ?? 1)
}
return result.stdout ?? ""
}
rmSync(targetDir, { recursive: true, force: true })
mkdirSync(targetDir, { recursive: true })
console.log(`[package-opencode-plugin] Building ${pluginWorkspace}`)
run(npmCommand, ["run", "build", "--workspace", pluginWorkspace], { cwd: workspaceRoot })
console.log(`[package-opencode-plugin] Packing ${pluginWorkspace}`)
run(npmCommand, ["pack", "--pack-destination", targetDir], { cwd: pluginRoot, capture: true })
const tarballs = readdirSync(targetDir).filter((name) => name.endsWith(".tgz"))
if (tarballs.length !== 1) {
console.error(`[package-opencode-plugin] Expected exactly one packed plugin tarball in ${targetDir}, found ${tarballs.length}`)
process.exit(1)
}
const packedTarball = path.join(targetDir, tarballs[0])
const targetTarball = path.join(targetDir, targetTarballName)
if (packedTarball !== targetTarball) {
renameSync(packedTarball, targetTarball)
}
console.log(`[package-opencode-plugin] Packed ${targetTarball}`)

View file

@ -1,31 +0,0 @@
import { existsSync } from "fs"
import path from "path"
import { fileURLToPath } from "url"
import { createLogger } from "./logger"
const log = createLogger({ component: "opencode-config" })
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const devTemplateDir = path.resolve(__dirname, "../../opencode-config")
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath
const prodTemplateDirs = [
resourcesPath ? path.resolve(resourcesPath, "opencode-config") : undefined,
path.resolve(__dirname, "opencode-config"),
].filter((dir): dir is string => Boolean(dir))
const isDevBuild = Boolean(process.env.CODENOMAD_DEV ?? process.env.CLI_UI_DEV_SERVER) || existsSync(devTemplateDir)
const templateDir = isDevBuild
? devTemplateDir
: prodTemplateDirs.find((dir) => existsSync(dir)) ?? prodTemplateDirs[0]
export function getOpencodeConfigDir(): string {
if (!existsSync(templateDir)) {
throw new Error(`CodeNomad Opencode config template missing at ${templateDir}`)
}
if (isDevBuild) {
log.debug({ templateDir }, "Using Opencode config template directly (dev mode)")
}
return templateDir
}

View file

@ -0,0 +1,38 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { buildOpencodeConfigContent } from "./opencode-plugin"
describe("buildOpencodeConfigContent", () => {
it("creates config content with the CodeNomad plugin", () => {
const content = buildOpencodeConfigContent(undefined, "file:///plugin.tgz")
assert.deepEqual(JSON.parse(content), {
"$schema": "https://opencode.ai/config.json",
plugin: ["file:///plugin.tgz"],
})
})
it("merges with existing JSONC content", () => {
const content = buildOpencodeConfigContent(
`{
// user plugin
"plugin": ["npm:user-plugin",],
"model": "test-model",
}`,
"file:///plugin.tgz",
)
assert.deepEqual(JSON.parse(content), {
"$schema": "https://opencode.ai/config.json",
plugin: ["npm:user-plugin", "file:///plugin.tgz"],
model: "test-model",
})
})
it("does not duplicate the CodeNomad plugin", () => {
const content = buildOpencodeConfigContent('{"plugin":["file:///plugin.tgz"]}', "file:///plugin.tgz")
assert.deepEqual(JSON.parse(content).plugin, ["file:///plugin.tgz"])
})
})

View file

@ -0,0 +1,175 @@
import { existsSync, readdirSync } from "fs"
import path from "path"
import { fileURLToPath, pathToFileURL } from "url"
import { createLogger } from "./logger"
const log = createLogger({ component: "opencode-plugin" })
const pluginPackageName = "@codenomad/codenomad-opencode-plugin"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath
const devPluginEntry = path.resolve(__dirname, "../../opencode-plugin/plugin/codenomad.ts")
const prodPluginDirs = [
resourcesPath ? path.resolve(resourcesPath, "opencode-plugin") : undefined,
resourcesPath ? path.resolve(resourcesPath, "server/dist/opencode-plugin") : undefined,
path.resolve(__dirname, "opencode-plugin"),
].filter((dir): dir is string => Boolean(dir))
const isDevBuild = Boolean(
process.env.CODENOMAD_DEV ??
process.env.CLI_UI_DEV_SERVER ??
process.env.VITE_DEV_SERVER_URL ??
process.env.ELECTRON_RENDERER_URL,
)
const isSourceRun = path.basename(__dirname) === "src" && existsSync(devPluginEntry)
export function getCodeNomadPluginUrl(): string {
if (isDevBuild || isSourceRun) {
if (!existsSync(devPluginEntry)) {
throw new Error(`CodeNomad OpenCode plugin entry missing at ${devPluginEntry}`)
}
log.debug({ pluginEntry: devPluginEntry }, "Using OpenCode plugin source directly (dev mode)")
return pathToFileURL(devPluginEntry).href
}
for (const dir of prodPluginDirs) {
const tarball = findPluginTarball(dir)
if (tarball) {
return toNpmFileSpecifier(tarball)
}
}
throw new Error(`CodeNomad OpenCode plugin package missing in ${prodPluginDirs.join(", ")}`)
}
export function buildOpencodeConfigContent(existingContent: string | undefined, pluginUrl: string): string {
const config = existingContent?.trim() ? parseJsoncObject(existingContent) : {}
const existingPlugins = normalizePluginEntries(config.plugin)
if (!existingPlugins.includes(pluginUrl)) {
existingPlugins.push(pluginUrl)
}
return JSON.stringify(
{
"$schema": typeof config["$schema"] === "string" ? config["$schema"] : "https://opencode.ai/config.json",
...config,
plugin: existingPlugins,
},
null,
2,
)
}
export function resolveExistingOpencodeConfigContent(userEnvironment: Record<string, unknown>): string | undefined {
const userValue = normalizeConfigContentValue(userEnvironment.OPENCODE_CONFIG_CONTENT)
if (userValue) {
return userValue
}
return normalizeConfigContentValue(process.env.OPENCODE_CONFIG_CONTENT)
}
function toNpmFileSpecifier(filePath: string): string {
return `${pluginPackageName}@file:${filePath.replace(/\\/g, "/")}`
}
function findPluginTarball(dir: string): string | null {
if (!existsSync(dir)) {
return null
}
const tarballs = readdirSync(dir)
.filter((name) => name.endsWith(".tgz"))
.sort()
return tarballs.length > 0 ? path.resolve(dir, tarballs[tarballs.length - 1]) : null
}
function normalizeConfigContentValue(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value : undefined
}
function parseJsoncObject(content: string): Record<string, unknown> {
try {
const parsed = JSON.parse(stripJsonc(content))
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("OPENCODE_CONFIG_CONTENT must be a JSON object")
}
return parsed as Record<string, unknown>
} catch (error) {
const reason = error instanceof Error ? error.message : String(error)
throw new Error(`Failed to parse OPENCODE_CONFIG_CONTENT: ${reason}`)
}
}
function normalizePluginEntries(value: unknown): string[] {
if (value === undefined) {
return []
}
if (typeof value === "string") {
return [value]
}
if (Array.isArray(value) && value.every((item) => typeof item === "string")) {
return [...value]
}
throw new Error("OPENCODE_CONFIG_CONTENT plugin field must be a string or string array")
}
function stripJsonc(input: string): string {
let output = ""
let inString = false
let escape = false
for (let index = 0; index < input.length; index += 1) {
const char = input[index]
const next = input[index + 1]
if (escape) {
output += char
escape = false
continue
}
if (char === "\\" && inString) {
output += char
escape = true
continue
}
if (char === '"') {
output += char
inString = !inString
continue
}
if (!inString && char === "/" && next === "/") {
while (index < input.length && input[index] !== "\n") {
index += 1
}
output += "\n"
continue
}
if (!inString && char === "/" && next === "*") {
index += 2
while (index < input.length && !(input[index] === "*" && input[index + 1] === "/")) {
output += input[index] === "\n" ? "\n" : ""
index += 1
}
index += 1
continue
}
if (!inString && char === ",") {
let lookahead = index + 1
while (lookahead < input.length && /\s/.test(input[lookahead])) {
lookahead += 1
}
if (input[lookahead] === "}" || input[lookahead] === "]") {
continue
}
}
output += char
}
return output
}

View file

@ -54,12 +54,12 @@ describe("buildWindowsSpawnSpec", () => {
{
cwd: String.raw`\\wsl.localhost\Ubuntu\home\dev\workspace`,
env: {
OPENCODE_CONFIG_DIR: String.raw`C:\Users\dev\AppData\Roaming\CodeNomad\opencode-config`,
OPENCODE_CONFIG_CONTENT: JSON.stringify({ plugin: ["file:///C:/Users/dev/AppData/Roaming/CodeNomad/plugin.tgz"] }),
CODENOMAD_INSTANCE_ID: "workspace-123",
OPENCODE_SERVER_BASE_URL: "https://127.0.0.1:4321/workspaces/workspace-123/worktrees/root/instance",
OPENCODE_SERVER_PASSWORD: "secret",
},
propagateEnvKeys: ["OPENCODE_CONFIG_DIR", "CODENOMAD_INSTANCE_ID", "OPENCODE_SERVER_BASE_URL", "OPENCODE_SERVER_PASSWORD"],
propagateEnvKeys: ["OPENCODE_CONFIG_CONTENT", "CODENOMAD_INSTANCE_ID", "OPENCODE_SERVER_BASE_URL", "OPENCODE_SERVER_PASSWORD"],
},
)
@ -76,23 +76,47 @@ describe("buildWindowsSpawnSpec", () => {
"0",
])
assert.equal(spec.cwd, undefined)
assert.equal(spec.env?.WSLENV, "OPENCODE_CONFIG_DIR/p:CODENOMAD_INSTANCE_ID:OPENCODE_SERVER_BASE_URL:OPENCODE_SERVER_PASSWORD")
assert.equal(spec.env?.WSLENV, "OPENCODE_CONFIG_CONTENT:CODENOMAD_INSTANCE_ID:OPENCODE_SERVER_BASE_URL:OPENCODE_SERVER_PASSWORD")
})
it("upgrades existing WSLENV path entries to include /p", () => {
it("preserves non-path OPENCODE_CONFIG_CONTENT WSLENV entries", () => {
const spec = buildWindowsSpawnSpec(
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
["serve"],
{
env: {
OPENCODE_CONFIG_DIR: String.raw`C:\Users\dev\AppData\Roaming\CodeNomad\opencode-config`,
WSLENV: "OPENCODE_CONFIG_DIR:CODENOMAD_INSTANCE_ID/u",
OPENCODE_CONFIG_CONTENT: JSON.stringify({ plugin: ["file:///C:/Users/dev/AppData/Roaming/CodeNomad/plugin.tgz"] }),
WSLENV: "OPENCODE_CONFIG_CONTENT:CODENOMAD_INSTANCE_ID/u",
},
propagateEnvKeys: ["OPENCODE_CONFIG_DIR", "CODENOMAD_INSTANCE_ID"],
propagateEnvKeys: ["OPENCODE_CONFIG_CONTENT", "CODENOMAD_INSTANCE_ID"],
},
)
assert.equal(spec.env?.WSLENV, "OPENCODE_CONFIG_DIR/p:CODENOMAD_INSTANCE_ID/u")
assert.equal(spec.env?.WSLENV, "OPENCODE_CONFIG_CONTENT:CODENOMAD_INSTANCE_ID/u")
})
it("rewrites packaged plugin paths for WSL before launching", () => {
const spec = buildWindowsSpawnSpec(
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
["serve"],
{
env: {
OPENCODE_CONFIG_CONTENT: JSON.stringify({
plugin: [
"@codenomad/codenomad-opencode-plugin@file:C:/Users/dev/AppData/Roaming/CodeNomad/codenomad-opencode-plugin.tgz",
],
}),
},
propagateEnvKeys: ["OPENCODE_CONFIG_CONTENT"],
},
)
assert.equal(spec.command, "wsl.exe")
assert.equal(spec.env?.CODENOMAD_OPENCODE_PLUGIN_WSL_PATH, String.raw`C:\Users\dev\AppData\Roaming\CodeNomad\codenomad-opencode-plugin.tgz`)
assert.match(spec.env?.OPENCODE_CONFIG_CONTENT ?? "", /__CODENOMAD_OPENCODE_PLUGIN_WSL_PATH__/)
assert.equal(spec.env?.WSLENV, "OPENCODE_CONFIG_CONTENT:CODENOMAD_OPENCODE_PLUGIN_WSL_PATH/p")
assert.deepEqual(spec.args.slice(0, 4), ["--distribution", "Ubuntu", "--exec", "sh"])
assert.match(spec.args[5] ?? "", /CODENOMAD_OPENCODE_PLUGIN_WSL_PATH/)
})
it("propagates inherited known path variables even when they are not explicitly requested", () => {

View file

@ -10,7 +10,11 @@ import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types"
import { WorkspaceRuntime, ProcessExitInfo } from "./runtime"
import { Logger } from "../logger"
import { getOpencodeConfigDir } from "../opencode-config.js"
import {
buildOpencodeConfigContent,
getCodeNomadPluginUrl,
resolveExistingOpencodeConfigContent,
} from "../opencode-plugin.js"
import {
OPENCODE_SERVER_BASE_URL_ENV,
buildOpencodeBasicAuthHeader,
@ -37,12 +41,12 @@ interface WorkspaceRecord extends WorkspaceDescriptor {}
export class WorkspaceManager {
private readonly workspaces = new Map<string, WorkspaceRecord>()
private readonly runtime: WorkspaceRuntime
private readonly opencodeConfigDir: string
private readonly codeNomadPluginUrl: string
private readonly opencodeAuth = new Map<string, { username: string; password: string; authorization: string }>()
constructor(private readonly options: WorkspaceManagerOptions) {
this.runtime = new WorkspaceRuntime(this.options.eventBus, this.options.logger)
this.opencodeConfigDir = getOpencodeConfigDir()
this.codeNomadPluginUrl = getCodeNomadPluginUrl()
}
list(): WorkspaceDescriptor[] {
@ -125,6 +129,10 @@ export class WorkspaceManager {
const serverConfig = this.options.settings.getOwner("config", "server")
const envVars = (serverConfig as any)?.environmentVariables
const userEnvironment = envVars && typeof envVars === "object" && !Array.isArray(envVars) ? (envVars as any) : {}
const opencodeConfigContent = buildOpencodeConfigContent(
resolveExistingOpencodeConfigContent(userEnvironment),
this.codeNomadPluginUrl,
)
const serverBaseUrl = this.options.getServerBaseUrl()
const normalizedServerBaseUrl = serverBaseUrl.replace(/\/+$/, "")
@ -140,7 +148,7 @@ export class WorkspaceManager {
const environment = {
...userEnvironment,
OPENCODE_CONFIG_DIR: this.opencodeConfigDir,
OPENCODE_CONFIG_CONTENT: opencodeConfigContent,
CODENOMAD_INSTANCE_ID: id,
CODENOMAD_BASE_URL: serverBaseUrl,
...(this.options.nodeExtraCaCertsPath ? { NODE_EXTRA_CA_CERTS: this.options.nodeExtraCaCertsPath } : {}),

View file

@ -6,7 +6,13 @@ export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"])
const VERSION_REGEX = /([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/
const WSL_UNC_PATH_REGEX = /^\\\\wsl(?:\.localhost|\$)\\([^\\/]+)(?:[\\/](.*))?$/i
const WSL_PATH_ENV_KEYS = new Set(["OPENCODE_CONFIG_DIR", "NODE_EXTRA_CA_CERTS"])
const CODENOMAD_PLUGIN_PACKAGE_NAME = "@codenomad/codenomad-opencode-plugin"
const WSL_PLUGIN_PATH_ENV = "CODENOMAD_OPENCODE_PLUGIN_WSL_PATH"
const WSL_PLUGIN_PATH_PLACEHOLDER = "__CODENOMAD_OPENCODE_PLUGIN_WSL_PATH__"
const CODENOMAD_PLUGIN_FILE_SPEC_REGEX = new RegExp(
`(${escapeRegex(CODENOMAD_PLUGIN_PACKAGE_NAME)}@file:)([A-Za-z]:[^"\\r\\n]+?\\.tgz)`,
)
const WSL_PATH_ENV_KEYS = new Set(["NODE_EXTRA_CA_CERTS", WSL_PLUGIN_PATH_ENV])
export interface SpawnSpec {
command: string
@ -187,6 +193,8 @@ export function probeBinaryVersion(binaryPath: string): {
function buildWslSpawnSpec(wslPath: WslPath, args: string[], options: BuildSpawnSpecOptions): SpawnSpec {
const workingDirectory = options.cwd ? resolveWslWorkingDirectory(options.cwd, wslPath.distro) : undefined
const env = buildWslEnvironment(options.env, options.propagateEnvKeys)
const shouldTranslatePluginPath = Boolean(env?.[WSL_PLUGIN_PATH_ENV])
if (options.cwd && !workingDirectory) {
throw new Error(
`Unable to translate workspace folder for WSL binary in distro "${wslPath.distro}": ${options.cwd}`,
@ -194,14 +202,14 @@ function buildWslSpawnSpec(wslPath: WslPath, args: string[], options: BuildSpawn
}
const wslArgs = ["--distribution", wslPath.distro]
const shouldWrapWithShell = Boolean(options.wslPidMarker) || workingDirectory?.kind === "windows"
const shouldWrapWithShell = Boolean(options.wslPidMarker) || workingDirectory?.kind === "windows" || shouldTranslatePluginPath
if (!shouldWrapWithShell && workingDirectory?.kind === "linux") {
wslArgs.push("--cd", workingDirectory.path)
}
if (shouldWrapWithShell) {
const launchScript = buildWslLaunchScript(workingDirectory ?? undefined, options.wslPidMarker)
const launchScript = buildWslLaunchScript(workingDirectory ?? undefined, options.wslPidMarker, shouldTranslatePluginPath)
wslArgs.push(
"--exec",
"sh",
@ -224,12 +232,16 @@ function buildWslSpawnSpec(wslPath: WslPath, args: string[], options: BuildSpawn
command: "wsl.exe",
args: wslArgs,
options: {},
env: buildWslEnvironment(options.env, options.propagateEnvKeys),
env,
wsl: { distro: wslPath.distro, pidMarker: options.wslPidMarker },
}
}
function buildWslLaunchScript(workingDirectory: WslWorkingDirectory | undefined, pidMarker: string | undefined): string {
function buildWslLaunchScript(
workingDirectory: WslWorkingDirectory | undefined,
pidMarker: string | undefined,
translatePluginPath: boolean,
): string {
const steps: string[] = []
if (pidMarker) {
@ -244,6 +256,12 @@ function buildWslLaunchScript(workingDirectory: WslWorkingDirectory | undefined,
steps.push("shift")
}
if (translatePluginPath) {
steps.push(
`if [ -n "$${WSL_PLUGIN_PATH_ENV}" ] && [ -n "$OPENCODE_CONFIG_CONTENT" ]; then escaped_plugin_path=$(printf '%s' "$${WSL_PLUGIN_PATH_ENV}" | sed 's/[\\&|]/\\\\&/g'); OPENCODE_CONFIG_CONTENT=$(printf '%s' "$OPENCODE_CONFIG_CONTENT" | sed "s|${WSL_PLUGIN_PATH_PLACEHOLDER}|$escaped_plugin_path|g"); export OPENCODE_CONFIG_CONTENT; unset ${WSL_PLUGIN_PATH_ENV}; fi`,
)
}
steps.push('exec "$@"')
return steps.join(" && ")
}
@ -266,17 +284,19 @@ function buildWslEnvironment(env: NodeJS.ProcessEnv | undefined, propagateEnvKey
return env
}
const next = { ...env }
rewriteOpencodePluginPathForWsl(next)
const keysToPropagate = Array.from(
new Set([
...(propagateEnvKeys ?? []).filter((key) => env[key] !== undefined),
...Array.from(WSL_PATH_ENV_KEYS).filter((key) => env[key] !== undefined),
...(propagateEnvKeys ?? []).filter((key) => next[key] !== undefined),
...Array.from(WSL_PATH_ENV_KEYS).filter((key) => next[key] !== undefined),
]),
)
if (keysToPropagate.length === 0) {
return env
return next
}
const next = { ...env }
const entries = (next.WSLENV ?? "").split(":").filter((entry) => entry.length > 0)
const byName = new Map(entries.map((entry) => [entry.split("/")[0] ?? entry, entry]))
@ -293,6 +313,22 @@ function buildWslEnvironment(env: NodeJS.ProcessEnv | undefined, propagateEnvKey
return next
}
function rewriteOpencodePluginPathForWsl(env: NodeJS.ProcessEnv) {
const content = env.OPENCODE_CONFIG_CONTENT
if (!content) {
return
}
const match = content.match(CODENOMAD_PLUGIN_FILE_SPEC_REGEX)
const hostPath = match?.[2]
if (!hostPath) {
return
}
env.OPENCODE_CONFIG_CONTENT = content.replace(hostPath, WSL_PLUGIN_PATH_PLACEHOLDER)
env[WSL_PLUGIN_PATH_ENV] = path.win32.normalize(hostPath)
}
function ensureWslenvEntry(entry: string, requiresPathTranslation: boolean): string {
if (!requiresPathTranslation) {
return entry
@ -305,3 +341,7 @@ function ensureWslenvEntry(entry: string, requiresPathTranslation: boolean): str
return rawFlags.length > 0 ? `${name}/${rawFlags}p` : `${name}/p`
}
function escapeRegex(input: string): string {
return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}

View file

@ -20,6 +20,8 @@ const serverInstallCommand =
"npm install --omit=dev --ignore-scripts --workspaces=false --package-lock=false --install-strategy=shallow --fund=false --audit=false"
const serverDevInstallCommand =
"npm install --workspace @neuralnomads/codenomad --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
const pluginDevInstallCommand =
"npm install --workspace @codenomad/codenomad-opencode-plugin --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
const uiDevInstallCommand =
"npm install --workspace @codenomad/ui --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
const serverPrepareUiCommand = "npm run prepare-ui --workspace @neuralnomads/codenomad"
@ -45,6 +47,12 @@ const serverBuildDependencyPaths = [
path.join(serverRoot, "node_modules", "@types", "yauzl", "package.json"),
]
const pluginRoot = path.resolve(root, "..", "opencode-plugin")
const pluginBuildDependencyPaths = [
path.join(pluginRoot, "node_modules", "typescript", "package.json"),
path.join(pluginRoot, "node_modules", "@types", "node", "package.json"),
]
const viteBinPath = path.join(uiRoot, "node_modules", ".bin", "vite")
async function ensureMonacoAssets() {
@ -118,6 +126,19 @@ function ensureServerDevDependencies() {
})
}
function ensurePluginDevDependencies() {
if (pluginBuildDependencyPaths.every((filePath) => fs.existsSync(filePath))) {
return
}
console.log("[prebuild] ensuring OpenCode plugin build dependencies...")
execSync(pluginDevInstallCommand, {
cwd: workspaceRoot,
stdio: "inherit",
env: envWithRootBin,
})
}
function ensureServerDependencies() {
if (fs.existsSync(braceExpansionPath)) {
return
@ -302,6 +323,7 @@ function copyUiLoadingAssets() {
;(async () => {
ensureServerDevDependencies()
ensurePluginDevDependencies()
ensureUiDevDependencies()
await ensureMonacoAssets()
ensureRollupPlatformBinary()