feat: load Claude Code plugins and marketplaces into opencode v2

Adds the c4o CLI and the opencode plugin behind it.

- spec: readers for plugin.json, the standard plugin layout and marketplace.json,
  following the manifest and marketplace references
- store: marketplace add/update/remove and plugin install/enable/disable/uninstall,
  with git, git-subdir and marketplace.json-URL sources and Claude Code's naming rules
- translate: skills, commands, agents and MCP servers as opencode values, plus a
  report of what a plugin contributes and what cannot be carried over
- runtime: registers those with opencode v2 and runs command and http hooks for
  PreToolUse, PostToolUse, UserPromptSubmit, SessionStart, Stop, SubagentStop and SessionEnd

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
John Smith
2026-10-10 16:46:28 -04:00
co-authored by Claude Opus 5.5
parent b8d6449bee
commit cbcf742755
34 changed files with 4787 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
dist/
node_modules/
review/
.e2e/
bun.lock
e2e/golden/
COMPATIBILITY.md
e2e/fixtures/
+4
View File
@@ -0,0 +1,4 @@
{
"semi": false,
"printWidth": 120
}
+650
View File
@@ -0,0 +1,650 @@
{
"lockfileVersion": 2,
"configVersion": 1,
"workspaces": {
"": {
"name": "claude-4-opencode",
"dependencies": {
"jsonc-parser": "^3.3.1",
"yaml": "^2.9.1",
},
"devDependencies": {
"@opencode/plugin": "2.0.26",
"@types/bun": "^1.4.2",
"prettier": "^3.9.10",
"typescript": "^7.0.2",
},
},
},
"packages": {
"@ai-sdk/provider": ["@ai-sdk/[email protected]", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
"@aws-crypto/crc32": ["@aws-crypto/[email protected]", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="],
"@aws-crypto/sha256-browser": ["@aws-crypto/[email protected]", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="],
"@aws-crypto/sha256-js": ["@aws-crypto/[email protected]", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="],
"@aws-crypto/supports-web-crypto": ["@aws-crypto/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg=="],
"@aws-crypto/util": ["@aws-crypto/[email protected]", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
"@aws-sdk/client-cognito-identity": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.15", "@aws-sdk/credential-provider-node": "^3.972.47", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/fetch-http-handler": "^5.4.5", "@smithy/node-http-handler": "^4.7.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-5MliYkp2u0+2arTp5fZIaxl+xmm90LEKv/VeSxhfNQW4t0fvWJrNO429/jchWQenNoDRrOGE59VfbuZUfwFujg=="],
"@aws-sdk/core": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/types": "^3.974.6", "@aws-sdk/xml-builder": "^3.972.41", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.35.2", "@smithy/signature-v4": "^5.7.4", "@smithy/types": "^4.19.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-ViOBLuGSSyPAmkKGJ6KJXtVN7MOEp3rnI7uH+kWxnHccvo8C9cvunEAnN5/oaZH1laQfsTBw7IOnmNkWlFZl2A=="],
"@aws-sdk/credential-provider-cognito-identity": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/nested-clients": "^3.997.47", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.2", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-UlY1+6rV1K7bFeMivM6qfQuVSBrkm/3fXBDzQO+3izAMZ64JAm0QQquCV+Ib/fGJgMNe02koq/Bgs1SBZTwfZw=="],
"@aws-sdk/credential-provider-env": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/core": "^3.978.2", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.2", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-PxlFZiF5IPUsPnuVSqjM9jlmRwZhL0xx40ccWfZiLQS8TRnHQpegpK1uAidatWuEqbJtbbY97A2Sezikz2AMUw=="],
"@aws-sdk/credential-provider-http": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/core": "^3.978.2", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.2", "@smithy/fetch-http-handler": "^5.8.0", "@smithy/node-http-handler": "^4.12.1", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-Q/uIDy8eAVN7JgSKDg16F/f+hmNJj/ZyLJN8AkW2ofVdDgJVh8fO/A26iZ7YsvM2/k76hsf/u2lLAtyXsK8Ziw=="],
"@aws-sdk/credential-provider-ini": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/core": "^3.978.2", "@aws-sdk/credential-provider-env": "^3.972.73", "@aws-sdk/credential-provider-http": "^3.972.75", "@aws-sdk/credential-provider-login": "^3.972.80", "@aws-sdk/credential-provider-process": "^3.972.73", "@aws-sdk/credential-provider-sso": "^3.973.17", "@aws-sdk/credential-provider-web-identity": "^3.972.79", "@aws-sdk/nested-clients": "^3.997.47", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.2", "@smithy/credential-provider-imds": "^4.5.2", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-KoyjZ9vdjuxEQ8obAH06xKP8H0WKqGhwUZLMqAOiS7G0xgUAht4X8f0ASLC/vhNLCWGblZOD3VQHWyTROGJHLg=="],
"@aws-sdk/credential-provider-login": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/core": "^3.978.2", "@aws-sdk/nested-clients": "^3.997.47", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.2", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-xF9A0gaO7k+1dYPFoo69OQatA9Ov0QuDZfqE8H0FLyIoOph+mlEC+I1Qo9UULupGuDfSYh+kVpfcDSxSYf/2fg=="],
"@aws-sdk/credential-provider-node": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.73", "@aws-sdk/credential-provider-http": "^3.972.75", "@aws-sdk/credential-provider-ini": "^3.973.18", "@aws-sdk/credential-provider-process": "^3.972.73", "@aws-sdk/credential-provider-sso": "^3.973.17", "@aws-sdk/credential-provider-web-identity": "^3.972.79", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.2", "@smithy/credential-provider-imds": "^4.5.2", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-CrFcML/xImiSlquH4W3V6Npz3aSkPXHDVciAU1JOBN0vqjmkcz9H9up2sCHQqN76J/WrT+TJ580L8h2joksQrg=="],
"@aws-sdk/credential-provider-process": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/core": "^3.978.2", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.2", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-tyNnT4aVuOEXmKKKZPExBi828vRd9heshobBsKF0bov5EORHSJc1AGsHOMRP57sDt+dIuVASUOywaixiX26t4g=="],
"@aws-sdk/credential-provider-sso": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/core": "^3.978.2", "@aws-sdk/nested-clients": "^3.997.47", "@aws-sdk/token-providers": "3.1149.0", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.2", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-gxKTWaUk2gSbBOH4m3NPRfEKcGXeDAOgHr7nNG5JktR1xZLo0R+DpMjbcleoHbB0uJREOG7hjIRE635IuBWsRQ=="],
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/core": "^3.978.2", "@aws-sdk/nested-clients": "^3.997.47", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.2", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-/jn0Gs/qm6fs6nTIA5yncHayybz42SATZ3rNxCEBYPJ9v69PLrDRgk07E9xTT2gpRcNmDXXbi27ZbuU06N0Z6Q=="],
"@aws-sdk/credential-providers": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/client-cognito-identity": "3.1057.0", "@aws-sdk/core": "^3.974.15", "@aws-sdk/credential-provider-cognito-identity": "^3.972.38", "@aws-sdk/credential-provider-env": "^3.972.41", "@aws-sdk/credential-provider-http": "^3.972.43", "@aws-sdk/credential-provider-ini": "^3.972.46", "@aws-sdk/credential-provider-login": "^3.972.45", "@aws-sdk/credential-provider-node": "^3.972.47", "@aws-sdk/credential-provider-process": "^3.972.41", "@aws-sdk/credential-provider-sso": "^3.972.45", "@aws-sdk/credential-provider-web-identity": "^3.972.45", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/credential-provider-imds": "^4.3.6", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-rbrEHtz11g0kxsSkYr3fx2HABNNblp4AhB2MgPvJHgYOWfJ2eBviU7Mvoaef0PW8QH6lbZDfJcnM7eKvtvz3sw=="],
"@aws-sdk/nested-clients": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/core": "^3.978.2", "@aws-sdk/signature-v4-multi-region": "^3.996.48", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.2", "@smithy/fetch-http-handler": "^5.8.0", "@smithy/node-http-handler": "^4.12.1", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-bovCKvDsLocNTzaj6VrrMVJ2BGROqO0JlwcGseO67a+I9Bo+08nUJhSQfZUIx4+A4a7I4101GEJd/AYm7dQ68w=="],
"@aws-sdk/signature-v4-multi-region": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/types": "^3.974.6", "@smithy/signature-v4": "^5.7.4", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-cFDXfEbXaGwbnpvR/DyqfxfjxpjXVRVthQLgkwvcfDS5u3+8G9WSj1X1Ag/pTRg/QOeOno1Tq3ycEdrKsCUcOA=="],
"@aws-sdk/token-providers": ["@aws-sdk/[email protected]", "", { "dependencies": { "@aws-sdk/core": "^3.978.2", "@aws-sdk/nested-clients": "^3.997.47", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.2", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-CAwYyA8eVk1w6h8YgLvkjIOakWPwURaUFrP7BRVgkxTFUM+O87thyKsGKJ6rVPbP8eoEV10cBWQ5YfaYl3qSVQ=="],
"@aws-sdk/types": ["@aws-sdk/[email protected]", "", { "dependencies": { "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg=="],
"@aws-sdk/util-locate-window": ["@aws-sdk/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-ycwH6Zd2GhuSqdXX9ihbCjeGTB6xOJs+O3+Jb8/zDG9978XU80qs75dfkPJRMNKe5MvBZPuNeFpd4JZKPoUF4g=="],
"@aws-sdk/xml-builder": ["@aws-sdk/[email protected]", "", { "dependencies": { "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ=="],
"@aws/lambda-invoke-store": ["@aws/[email protected]", "", {}, "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ=="],
"@effect/opentelemetry": ["@effect/[email protected]", "", { "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <2.0.0", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-base": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-node": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-web": ">=2.0.0 <3.0.0", "@opentelemetry/semantic-conventions": ">=1.33.0 <2.0.0", "effect": "^4.0.0-rc.112" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-OTRv1DxTHUmnakgJ6XVM8wVgF1KgZH4UXnOemwSLUwUjXO+RCikzF8oR/rlVmOGq81KtQzj1URM4M4nchlQOuQ=="],
"@effect/platform-node": ["@effect/[email protected]", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-rc.112", "mime": "^4.1.0", "undici": "^8.10.0" }, "peerDependencies": { "effect": "^4.0.0-rc.112", "redis": ">=5.0.0 <7.0.0" } }, "sha512-/BMAcdNGQQskLmI0Zoa95KfTZkr9HV9N4NSxaSrusG6GeW6Ulp9KvZ+Rlaiw8lnOt43CXjFLdfll5/k5rxL4hQ=="],
"@effect/platform-node-shared": ["@effect/[email protected]", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.3" }, "peerDependencies": { "effect": "^4.0.0-rc.112" } }, "sha512-ttjz0xKamFN7vL8pNDYVwddJLjZvqKePc05djlz2VcdaKbLsnYbtMnL1rbOfHgEnIUSHGh7FkjaN4DM1Ov81sQ=="],
"@gar/promise-retry": ["@gar/[email protected]", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="],
"@isaacs/cliui": ["@isaacs/[email protected]", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"@isaacs/fs-minipass": ["@isaacs/[email protected]", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
"@isaacs/string-locale-compare": ["@isaacs/[email protected]", "", {}, "sha512-SQ7Kzhh9+D+ZW9MA0zkYv3VXhIDNx+LzM6EJ+/65I3QY+enU6Itte7E5XX7EWrqLW2FN4n06GWzBnPoC3th2aQ=="],
"@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="],
"@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="],
"@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw=="],
"@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw=="],
"@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ=="],
"@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="],
"@npmcli/agent": ["@npmcli/[email protected]", "", { "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "lru-cache": "^11.2.1", "socks-proxy-agent": "^8.0.3" } }, "sha512-EUEuWAxnL07Sp5/iC/1X6Xj+XThUvnbei9zfRWZdEXa7lss9RTHMhAHBeg+MZ5To9s/gGaSI+UwZTPdYMvKSeg=="],
"@npmcli/arborist": ["@npmcli/[email protected]", "", { "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", "@npmcli/fs": "^5.0.0", "@npmcli/installed-package-contents": "^4.0.0", "@npmcli/map-workspaces": "^5.0.0", "@npmcli/metavuln-calculator": "^9.0.2", "@npmcli/name-from-folder": "^4.0.0", "@npmcli/node-gyp": "^5.0.0", "@npmcli/package-json": "^7.0.0", "@npmcli/query": "^5.0.0", "@npmcli/redact": "^4.0.0", "@npmcli/run-script": "^10.0.0", "bin-links": "^6.0.0", "cacache": "^20.0.1", "common-ancestor-path": "^2.0.0", "hosted-git-info": "^9.0.0", "json-stringify-nice": "^1.1.4", "lru-cache": "^11.2.1", "minimatch": "^10.0.3", "nopt": "^9.0.0", "npm-install-checks": "^8.0.0", "npm-package-arg": "^13.0.0", "npm-pick-manifest": "^11.0.1", "npm-registry-fetch": "^19.0.0", "pacote": "^21.0.2", "parse-conflict-json": "^5.0.1", "proc-log": "^6.0.0", "proggy": "^4.0.0", "promise-all-reject-late": "^1.0.0", "promise-call-limit": "^3.0.1", "semver": "^7.3.7", "ssri": "^13.0.0", "treeverse": "^3.0.0", "walk-up-path": "^4.0.0" }, "bin": { "arborist": "bin/index.js" } }, "sha512-4Bm8hNixJG/sii1PMnag0V9i/sGOX9VRzFrUiZMSBJpGlLR38f+Btl85d07G9GL56xO0l0OZjvrGNYsDYp0xKA=="],
"@npmcli/config": ["@npmcli/[email protected]", "", { "dependencies": { "@npmcli/map-workspaces": "^5.0.0", "@npmcli/package-json": "^7.0.0", "ci-info": "^4.0.0", "ini": "^6.0.0", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "walk-up-path": "^4.0.0" } }, "sha512-MAYk9IlIGiyC0c9fnjdBSQfIFPZT0g1MfeSiD1UXTq2zJOLX55jS9/sETJHqw/7LN18JjITrhYfgCfapbmZHiQ=="],
"@npmcli/fs": ["@npmcli/[email protected]", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og=="],
"@npmcli/git": ["@npmcli/[email protected]", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/promise-spawn": "^9.0.0", "ini": "^6.0.0", "lru-cache": "^11.2.1", "npm-pick-manifest": "^11.0.1", "proc-log": "^6.0.0", "semver": "^7.3.5", "which": "^6.0.0" } }, "sha512-oeolHDjExNAJAnlYP2qzNjMX/Xi9bmu78C9dIGr4xjobrSKbuMYCph8lTzn4vnW3NjIqVmw/f8BCfouqyJXlRg=="],
"@npmcli/installed-package-contents": ["@npmcli/[email protected]", "", { "dependencies": { "npm-bundled": "^5.0.0", "npm-normalize-package-bin": "^5.0.0" }, "bin": { "installed-package-contents": "bin/index.js" } }, "sha512-yNyAdkBxB72gtZ4GrwXCM0ZUedo9nIbOMKfGjt6Cu6DXf0p8y1PViZAKDC8q8kv/fufx0WTjRBdSlyrvnP7hmA=="],
"@npmcli/map-workspaces": ["@npmcli/[email protected]", "", { "dependencies": { "@npmcli/name-from-folder": "^4.0.0", "@npmcli/package-json": "^7.0.0", "glob": "^13.0.0", "minimatch": "^10.0.3" } }, "sha512-o2grssXo1e774E5OtEwwrgoszYRh0lqkJH+Pb9r78UcqdGJRDRfhpM8DvZPjzNLLNYeD/rNbjOKM3Ss5UABROw=="],
"@npmcli/metavuln-calculator": ["@npmcli/[email protected]", "", { "dependencies": { "cacache": "^20.0.0", "json-parse-even-better-errors": "^5.0.0", "pacote": "^21.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5" } }, "sha512-94GLSYhLXF2t2LAC7pDwLaM4uCARzxShyAQKsirmlNcpidH89VA4/+K1LbJmRMgz5gy65E/QBBWQdUvGLe2Frg=="],
"@npmcli/name-from-folder": ["@npmcli/[email protected]", "", {}, "sha512-qfrhVlOSqmKM8i6rkNdZzABj8MKEITGFAY+4teqBziksCQAOLutiAxM1wY2BKEd8KjUSpWmWCYxvXr0y4VTlPg=="],
"@npmcli/node-gyp": ["@npmcli/[email protected]", "", {}, "sha512-uuG5HZFXLfyFKqg8QypsmgLQW7smiRjVc45bqD/ofZZcR/uxEjgQU8qDPv0s9TEeMUiAAU/GC5bR6++UdTirIQ=="],
"@npmcli/package-json": ["@npmcli/[email protected]", "", { "dependencies": { "@npmcli/git": "^7.0.0", "glob": "^13.0.0", "hosted-git-info": "^9.0.0", "json-parse-even-better-errors": "^5.0.0", "proc-log": "^6.0.0", "semver": "^7.5.3", "spdx-expression-parse": "^4.0.0" } }, "sha512-iVuTlG3ORq2iaVa1IWUxAO/jIp77tUKBhoMjuzYW2kL4MLN1bi/ofqkZ7D7OOwh8coAx1/S2ge0rMdGv8sLSOQ=="],
"@npmcli/promise-spawn": ["@npmcli/[email protected]", "", { "dependencies": { "which": "^6.0.0" } }, "sha512-OLUaoqBuyxeTqUvjA3FZFiXUfYC1alp3Sa99gW3EUDz3tZ3CbXDdcZ7qWKBzicrJleIgucoWamWH1saAmH/l2Q=="],
"@npmcli/query": ["@npmcli/[email protected]", "", { "dependencies": { "postcss-selector-parser": "^7.0.0" } }, "sha512-8TZWfTQOsODpLqo9SVhVjHovmKXNpevHU0gO9e+y4V4fRIOneiXy0u0sMP9LmS71XivrEWfZWg50ReH4WRT4aQ=="],
"@npmcli/redact": ["@npmcli/[email protected]", "", {}, "sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q=="],
"@npmcli/run-script": ["@npmcli/[email protected]", "", { "dependencies": { "@npmcli/node-gyp": "^5.0.0", "@npmcli/package-json": "^7.0.0", "@npmcli/promise-spawn": "^9.0.0", "node-gyp": "^12.1.0", "proc-log": "^6.0.0" } }, "sha512-mGUWr1uMnf0le2TwfOZY4SFxZGXGfm4Jtay/nwAa2FLNAKXUoUwaGwBMNH36UHPtinWfTSJ3nqFQr0091CxVGg=="],
"@opencode/ai": ["@opencode/[email protected]", "", { "dependencies": { "@aws-sdk/credential-providers": "3.1057.0", "@opencode/schema": "2.0.26", "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", "aws4fetch": "1.0.20", "effect": "4.0.0-rc.112", "google-auth-library": "10.5.0" } }, "sha512-CB+k3Ed6mtat2Or/6YZiXSPrI8q02NfECEKKO9HNeYLKFG3UrbIxrhnaRLbA9YMQp9mKOA1qWIWXJDnkxdVjMQ=="],
"@opencode/client": ["@opencode/[email protected]", "", { "dependencies": { "@opencode/protocol": "2.0.26", "@opencode/schema": "2.0.26" }, "peerDependencies": { "effect": "4.0.0-rc.112", "solid-js": ">=1.9.0" }, "optionalPeers": ["effect", "solid-js"] }, "sha512-0uqhXq6DJYjas3mc4roTyWAmhJpyO2gJaMcenoiA22RXJSOSIwPFho5K4qGmYDJGd5Fvvswh++Nr/wfjaHAYyg=="],
"@opencode/plugin": ["@opencode/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode/ai": "2.0.26", "@opencode/client": "2.0.26", "@opencode/protocol": "2.0.26", "@opencode/schema": "2.0.26", "@opencode/util": "2.0.26", "effect": "4.0.0-rc.112" }, "peerDependencies": { "@opencode/theme": "2.0.26", "@opentui/core": ">=0.5.17", "@opentui/solid": ">=0.5.17", "solid-js": ">=1.9.0" }, "optionalPeers": ["@opencode/theme", "@opentui/core", "@opentui/solid", "solid-js"] }, "sha512-8fu/WokR5hxGjclLQJKL0PAgsMEfh81MHnUBsJuhcnYUiTJ2ZQPLqSYAhzoYEQu/Vj4M65D3Xx7QS0Q9b9Z4fw=="],
"@opencode/protocol": ["@opencode/[email protected]", "", { "dependencies": { "@opencode/schema": "2.0.26", "effect": "4.0.0-rc.112" } }, "sha512-Q+kbu0zTneh0RC4jhiuB19WeP8et9dWXGJVjb5EbUdJ8E1j5Eh0lAyqLXlM2tzTxfMfK3gos9ss6mxMVsvDFIg=="],
"@opencode/schema": ["@opencode/[email protected]", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "effect": "4.0.0-rc.112" } }, "sha512-DZ09xXCTkTI3Y9VzuQHsm5AMGh67EqkUM13lC0LGKvD12RAYaVmRX5lDv5AnD3OkmUmPPWLYWjicT18CDloy6Q=="],
"@opencode/util": ["@opencode/[email protected]", "", { "dependencies": { "@effect/opentelemetry": "4.0.0-rc.112", "@effect/platform-node": "4.0.0-rc.112", "@effect/platform-node-shared": "4.0.0-rc.112", "@npmcli/arborist": "9.4.0", "@npmcli/config": "10.8.1", "@opentelemetry/api": "1.9.0", "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/context-async-hooks": "2.8.0", "@opentelemetry/exporter-trace-otlp-http": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0", "@opentelemetry/sdk-trace-node": "2.8.0", "cross-spawn": "7.0.6", "effect": "4.0.0-rc.112", "glob": "13.0.5", "mime-types": "3.0.2", "minimatch": "10.2.5", "npm-package-arg": "13.0.2", "open": "11.0.4", "pacote": "21.5.1" } }, "sha512-RebqfdFEEby7IFA7agajYvCpeW4VoaPkKu/ZMhBXsEYRnpz2NMyoI7zapFvvDJdYl4B0/kPnmt5vUVuqRWm0lQ=="],
"@opentelemetry/api": ["@opentelemetry/[email protected]", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@opentelemetry/api-logs": ["@opentelemetry/[email protected]", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg=="],
"@opentelemetry/context-async-hooks": ["@opentelemetry/[email protected]", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-/3FIraneMcng67SUJCxvyInk/oxzwsxyadufk0wwfOBLf5wqtAGX4MoQASwSbndBPeARzBryUM9Azr5kHIdWLw=="],
"@opentelemetry/core": ["@opentelemetry/[email protected]", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww=="],
"@opentelemetry/exporter-trace-otlp-http": ["@opentelemetry/[email protected]", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-9t6SvBXXBEjOBcIzgozvBbd3jWrv3Gt3ngGhl1fhdZ/zRc7oZDVOFEqbi2zlBpW9BXhgDMKv422J0DL/3iQWfw=="],
"@opentelemetry/otlp-exporter-base": ["@opentelemetry/[email protected]", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/otlp-transformer": "0.219.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-zvIxQX/AZUVKDU+hCuYx+7UkiP7GRdnk1ZbFQRYzHvYp47cAWR4j3IhoPhV9KaeXEv2xdGq3IA6PnpzDmLcmSA=="],
"@opentelemetry/otlp-transformer": ["@opentelemetry/[email protected]", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="],
"@opentelemetry/resources": ["@opentelemetry/[email protected]", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg=="],
"@opentelemetry/sdk-logs": ["@opentelemetry/[email protected]", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA=="],
"@opentelemetry/sdk-metrics": ["@opentelemetry/[email protected]", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-UDBGaj6W0Rgy5rTTaoxs8gVGF/aGkAKyjurJv7se6wjRxJu7FoquTLT/vt54DZfo4crbprYfhX/SOK9+BPw1qg=="],
"@opentelemetry/sdk-trace-base": ["@opentelemetry/[email protected]", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ=="],
"@opentelemetry/sdk-trace-node": ["@opentelemetry/[email protected]", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.8.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-nZt9OGufioAc3AfoLTqA9bsAeaMJAictYDdI2VcNQ+PmT+3rfKjAZDZvgPfd8VPX0O5Bw1hdQF6kDK8VSpZiWg=="],
"@opentelemetry/semantic-conventions": ["@opentelemetry/[email protected]", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
"@pkgjs/parseargs": ["@pkgjs/[email protected]", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
"@redis/bloom": ["@redis/[email protected]", "", { "peerDependencies": { "@redis/client": "^6.3.0" } }, "sha512-NQ5poYpZr0jv6zazN1cy6JY5kIipKFZ3R9GTL4kOUTX3a9YAfY29U8bsE4lWhVQEM+G4V0BXVC57mubIdDijeA=="],
"@redis/client": ["@redis/[email protected]", "", { "peerDependencies": { "@node-rs/xxhash": "^1.1.0", "@opentelemetry/api": ">=1 <2" }, "optionalPeers": ["@node-rs/xxhash", "@opentelemetry/api"] }, "sha512-fHrjwGCBvAANGi+TuRuwKi83sIOzgHr8eh+Uf48w3fSX7Hhrz174NrWqfJhAC2vnN0d/tfaZpdr6+o6JC3QHwg=="],
"@redis/json": ["@redis/[email protected]", "", { "peerDependencies": { "@redis/client": "^6.3.0" } }, "sha512-yt6vRfPBXtW/3qm53cF81TSQpLMjWdW4/2E9WQ2GgbvsKkuCKCFQIcMiKlil1ZMnezK+IQuS795xKU5psbaegw=="],
"@redis/search": ["@redis/[email protected]", "", { "peerDependencies": { "@redis/client": "^6.3.0" } }, "sha512-RPXAZmKjZZG7Tm7m1w9keFdXzbL91PiSHQBfQRe9xwW40jNsTRbG0FwhIz4QlHPU4gKDAsCy8IQHj4yZl4H9rw=="],
"@redis/time-series": ["@redis/[email protected]", "", { "peerDependencies": { "@redis/client": "^6.3.0" } }, "sha512-+fJOB8mN1z5WRakCJNh5lVdz1KqvHGSOistsKeitD24QsCtPwyRQXWC0iq6T/i/ubLE/YcguZqPm8F598MKk4A=="],
"@sigstore/bundle": ["@sigstore/[email protected]", "", { "dependencies": { "@sigstore/protobuf-specs": "^0.5.0" } }, "sha512-NwCl5Y0V6Di0NexvkTqdoVfmjTaQwoLM236r89KEojGmq/jMls8S+zb7yOwAPdXvbwfKDlP+lmXgAL4vKSQT+A=="],
"@sigstore/core": ["@sigstore/[email protected]", "", {}, "sha512-qRsxPnCrbC/puegGxKuynfnxgLiHqWStrSjxkoB4YKqq3Z3s4cyZyj42ZdWFAEblNP65C+rBH8EuREHIXoi83g=="],
"@sigstore/protobuf-specs": ["@sigstore/[email protected]", "", {}, "sha512-SQqvFMt4V78fdjcDdYX6HbiVSOR4QK3ZgwCa2KOsopAgPIHy1rU5UDUmzLl02r5oyyaYcYHR1hpwDRk/yUe+Mw=="],
"@sigstore/sign": ["@sigstore/[email protected]", "", { "dependencies": { "@gar/promise-retry": "^1.0.2", "@sigstore/bundle": "^4.0.0", "@sigstore/core": "^3.2.0", "@sigstore/protobuf-specs": "^0.5.0", "make-fetch-happen": "^15.0.4", "proc-log": "^6.1.0" } }, "sha512-Hf4xglukg0XXQ2RiD5vSoLjdPe8OBUPA8XeVjUObheuDcWdYWrnH/BNmxZCzkAy68MzmNCxXLeurJvs6hcP2OQ=="],
"@sigstore/tuf": ["@sigstore/[email protected]", "", { "dependencies": { "@sigstore/protobuf-specs": "^0.5.0", "tuf-js": "^4.1.0" } }, "sha512-TCAzTy0xzdP79EnxSjq9KQ3eaR7+FmudLC6eRKknVKZbV7ZNlGLClAAQb/HMNJ5n2OBNk2GT1tEmU0xuPr+SLQ=="],
"@sigstore/verify": ["@sigstore/[email protected]", "", { "dependencies": { "@sigstore/bundle": "^4.0.0", "@sigstore/core": "^3.2.1", "@sigstore/protobuf-specs": "^0.5.0" } }, "sha512-qv7+G3J2cc6wwFj3yKvXOamzqhMwSk1ogPGmhpS8iXllcPrJaIIBA+4HbttlHVu1pqWTdmaCH/WE7UOC51kdoA=="],
"@smithy/core": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-ejTukBuP/Pg3t9N8Ux+0zKsXeHWm7p6W3HbXFOyltKasSxZwCFmvTut7kBdZSCF3m9y1bXXyVu6p2xuMFcsTyQ=="],
"@smithy/credential-provider-imds": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/core": "^3.33.2", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg=="],
"@smithy/eventstream-codec": ["@smithy/[email protected]", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw=="],
"@smithy/fetch-http-handler": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.18.0", "tslib": "^2.6.2" } }, "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA=="],
"@smithy/is-array-buffer": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@smithy/node-http-handler": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.18.0", "tslib": "^2.6.2" } }, "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw=="],
"@smithy/signature-v4": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/core": "^3.35.0", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ=="],
"@smithy/types": ["@smithy/[email protected]", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="],
"@smithy/util-buffer-from": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/core": "^3.33.2", "tslib": "^2.6.2" } }, "sha512-nxu3SgmAw9JXT2CtkU0m/XNLWpP9MsaBx1zAGAypCbYj15tIFlmcYwpF+Oh18le83d+IM9PT7ENdXnE4C+d5mA=="],
"@smithy/util-hex-encoding": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/core": "^3.33.2", "tslib": "^2.6.2" } }, "sha512-iq+cW3mAb7vfcxEEpYi3zXKpDtbrIFyanWjQl4zBq4seWD4OSxXDWSfespZxenX6aEaighn+NR3u1nU1DSvs3w=="],
"@smithy/util-utf8": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
"@standard-schema/spec": ["@standard-schema/[email protected]", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@tufjs/canonical-json": ["@tufjs/[email protected]", "", {}, "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA=="],
"@tufjs/models": ["@tufjs/[email protected]", "", { "dependencies": { "@tufjs/canonical-json": "2.0.0", "minimatch": "^10.1.1" } }, "sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww=="],
"@types/bun": ["@types/[email protected]", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="],
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="],
"@types/ws": ["@types/[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w=="],
"@typescript/typescript-aix-ppc64": ["@typescript/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
"@typescript/typescript-darwin-arm64": ["@typescript/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="],
"@typescript/typescript-darwin-x64": ["@typescript/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="],
"@typescript/typescript-freebsd-arm64": ["@typescript/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="],
"@typescript/typescript-freebsd-x64": ["@typescript/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="],
"@typescript/typescript-linux-arm": ["@typescript/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="],
"@typescript/typescript-linux-arm64": ["@typescript/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="],
"@typescript/typescript-linux-loong64": ["@typescript/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="],
"@typescript/typescript-linux-mips64el": ["@typescript/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="],
"@typescript/typescript-linux-ppc64": ["@typescript/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="],
"@typescript/typescript-linux-riscv64": ["@typescript/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="],
"@typescript/typescript-linux-s390x": ["@typescript/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="],
"@typescript/typescript-linux-x64": ["@typescript/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="],
"@typescript/typescript-netbsd-arm64": ["@typescript/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="],
"@typescript/typescript-netbsd-x64": ["@typescript/[email protected]", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="],
"@typescript/typescript-openbsd-arm64": ["@typescript/[email protected]", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="],
"@typescript/typescript-openbsd-x64": ["@typescript/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="],
"@typescript/typescript-sunos-x64": ["@typescript/[email protected]", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="],
"@typescript/typescript-win32-arm64": ["@typescript/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="],
"@typescript/typescript-win32-x64": ["@typescript/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="],
"abbrev": ["[email protected]", "", {}, "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA=="],
"agent-base": ["[email protected]", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"ansi-regex": ["[email protected]", "", {}, "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw=="],
"ansi-styles": ["[email protected]", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"aws4fetch": ["[email protected]", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
"balanced-match": ["[email protected]", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"base64-js": ["[email protected]", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"bignumber.js": ["[email protected]", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
"bin-links": ["[email protected]", "", { "dependencies": { "cmd-shim": "^8.0.0", "npm-normalize-package-bin": "^5.0.0", "proc-log": "^6.0.0", "read-cmd-shim": "^6.0.0", "write-file-atomic": "^7.0.0" } }, "sha512-frE1t78WOwJ45PKV2cF2tNPjTcs9L1J9s6VkrV59wanRP4GlaomuxYPVma7BwthMg8WnfSory4w5PTE6FZZ81w=="],
"bowser": ["[email protected]", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="],
"brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="],
"buffer-equal-constant-time": ["[email protected]", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
"bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
"bundle-name": ["[email protected]", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA=="],
"cacache": ["[email protected]", "", { "dependencies": { "@npmcli/fs": "^5.0.0", "fs-minipass": "^3.0.0", "glob": "^13.0.0", "lru-cache": "^11.1.0", "minipass": "^7.0.3", "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^7.0.2", "ssri": "^13.0.0" } }, "sha512-M3Lab8NPYlZU2exsL3bMVvMrMqgwCnMWfdZbK28bn3pK6APT/Te/I8hjRPNu1uwORY9a1eEQoifXbKPQMfMTOA=="],
"chownr": ["[email protected]", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
"ci-info": ["[email protected]", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
"cmd-shim": ["[email protected]", "", {}, "sha512-Jk/BK6NCapZ58BKUxlSI+ouKRbjH1NLZCgJkYoab+vEHUY3f6OzpNBN9u7HFSv9J6TRDGs4PLOHezoKGaFRSCA=="],
"color-convert": ["[email protected]", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["[email protected]", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"common-ancestor-path": ["[email protected]", "", {}, "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng=="],
"content-type": ["[email protected]", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
"cross-spawn": ["[email protected]", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"cssesc": ["[email protected]", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"data-uri-to-buffer": ["[email protected]", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
"debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"default-browser": ["[email protected]", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw=="],
"default-browser-id": ["[email protected]", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="],
"define-lazy-prop": ["[email protected]", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="],
"detect-libc": ["[email protected]", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"eastasianwidth": ["[email protected]", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
"ecdsa-sig-formatter": ["[email protected]", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
"effect": ["[email protected]", "", { "dependencies": { "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A=="],
"emoji-regex": ["[email protected]", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"env-paths": ["[email protected]", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
"exponential-backoff": ["[email protected]", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="],
"extend": ["[email protected]", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fast-check": ["[email protected]", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw=="],
"fdir": ["[email protected]", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fetch-blob": ["[email protected]", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
"foreground-child": ["[email protected]", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
"formdata-polyfill": ["[email protected]", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
"fs-minipass": ["[email protected]", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw=="],
"gaxios": ["[email protected]", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ=="],
"gcp-metadata": ["[email protected]", "", { "dependencies": { "gaxios": "7.1.3", "google-logging-utils": "1.1.3", "json-bigint": "^1.0.0" } }, "sha512-iJ9KMsiu+xKtNRX0PmGLSaIU3bUBAyzWTyqKemKPzNPsmmsBCQYmlNg+brEbES7IHSXtdVwzBPzx1vz3FAaipw=="],
"glob": ["[email protected]", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
"google-auth-library": ["[email protected]", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.0.0", "gcp-metadata": "^8.0.0", "google-logging-utils": "^1.0.0", "gtoken": "^8.0.0", "jws": "^4.0.0" } }, "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w=="],
"google-logging-utils": ["[email protected]", "", {}, "sha512-WE9av4wKDZgRjBwgVUabocx8T6/7o3Ca1Fat46FXDhXVAFibzNadedcOXrdgd1Kzmk8tsk/9ZH89Wyf/SqeZ3A=="],
"graceful-fs": ["[email protected]", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"gtoken": ["[email protected]", "", { "dependencies": { "gaxios": "^7.0.0", "jws": "^4.0.0" } }, "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw=="],
"hosted-git-info": ["[email protected]", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="],
"http-cache-semantics": ["[email protected]", "", {}, "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA=="],
"http-proxy-agent": ["[email protected]", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
"https-proxy-agent": ["[email protected]", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"iconv-lite": ["[email protected]", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="],
"ignore-walk": ["[email protected]", "", { "dependencies": { "minimatch": "^10.0.3" } }, "sha512-FCeMZT4NiRQGh+YkeKMtWrOmBgWjHjMJ26WQWrRQyoyzqevdaGSakUaJW5xQYmjLlUVk2qUnCjYVBax9EKKg8A=="],
"ini": ["[email protected]", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="],
"ip-address": ["[email protected]", "", {}, "sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg=="],
"is-docker": ["[email protected]", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
"is-fullwidth-code-point": ["[email protected]", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"is-in-ssh": ["[email protected]", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="],
"is-inside-container": ["[email protected]", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="],
"is-wsl": ["[email protected]", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
"isexe": ["[email protected]", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jackspeak": ["[email protected]", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
"json-bigint": ["[email protected]", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
"json-parse-even-better-errors": ["[email protected]", "", {}, "sha512-ZF1nxZ28VhQouRWhUcVlUIN3qwSgPuswK05s/HIaoetAoE/9tngVmCHjSxmSQPav1nd+lPtTL0YZ/2AFdR/iYQ=="],
"json-schema": ["[email protected]", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
"json-stringify-nice": ["[email protected]", "", {}, "sha512-5Z5RFW63yxReJ7vANgW6eZFGWaQvnPE3WNmZoOJrSkGju2etKA2L5rrOa1sm877TVTFt57A80BH1bArcmlLfPw=="],
"jsonc-parser": ["[email protected]", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
"jsonparse": ["[email protected]", "", {}, "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg=="],
"just-diff": ["[email protected]", "", {}, "sha512-S59eriX5u3/QhMNq3v/gm8Kd0w8OS6Tz2FS1NG4blv+z0MuQcBRJyFWjdovM0Rad4/P4aUPFtnkNjMjyMlMSYA=="],
"just-diff-apply": ["[email protected]", "", {}, "sha512-OYTthRfSh55WOItVqwpefPtNt2VdKsq5AnAK6apdtR6yCH8pr0CmSr710J0Mf+WdQy7K/OzMy7K2MgAfdQURDw=="],
"jwa": ["[email protected]", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
"jws": ["[email protected]", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
"lru-cache": ["[email protected]", "", {}, "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg=="],
"make-fetch-happen": ["[email protected]", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/agent": "^4.0.0", "@npmcli/redact": "^4.0.0", "cacache": "^20.0.1", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", "minipass-fetch": "^5.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^1.0.0", "proc-log": "^6.0.0", "ssri": "^13.0.0" } }, "sha512-Je0fLJ0F5atA7F+eIlLzk+Wkcl57JDf4kf+EW8xiP5E31xOQxkIxTbgf1Oi1Lw9tRI9UEMRdI5Vz2xTzoNU1Jw=="],
"mime": ["[email protected]", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="],
"mime-db": ["[email protected]", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"mime-types": ["[email protected]", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"minimatch": ["[email protected]", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
"minipass": ["[email protected]", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
"minipass-collect": ["[email protected]", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw=="],
"minipass-fetch": ["[email protected]", "", { "dependencies": { "minipass": "^7.0.3", "minipass-sized": "^2.0.0", "minizlib": "^3.0.1" }, "optionalDependencies": { "iconv-lite": "^0.7.2" } }, "sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ=="],
"minipass-flush": ["[email protected]", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA=="],
"minipass-pipeline": ["[email protected]", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A=="],
"minipass-sized": ["[email protected]", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-zSsHhto5BcUVM2m1LurnXY6M//cGhVaegT71OfOXoprxT6o780GZd792ea6FfrQkuU4usHZIUczAQMRUE2plzA=="],
"minizlib": ["[email protected]", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="],
"ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"msgpackr": ["[email protected]", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ=="],
"msgpackr-extract": ["[email protected]", "", { "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=="],
"negotiator": ["[email protected]", "", { "dependencies": { "content-type": "^2.1.0" } }, "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg=="],
"node-domexception": ["[email protected]", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
"node-fetch": ["[email protected]", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
"node-gyp": ["[email protected]", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw=="],
"node-gyp-build-optional-packages": ["[email protected]", "", { "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" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="],
"nopt": ["[email protected]", "", { "dependencies": { "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw=="],
"npm-bundled": ["[email protected]", "", { "dependencies": { "npm-normalize-package-bin": "^5.0.0" } }, "sha512-JLSpbzh6UUXIEoqPsYBvVNVmyrjVZ1fzEFbqxKkTJQkWBO3xFzFT+KDnSKQWwOQNbuWRwt5LSD6HOTLGIWzfrw=="],
"npm-install-checks": ["[email protected]", "", { "dependencies": { "semver": "^7.1.1" } }, "sha512-ScAUdMpyzkbpxoNekQ3tNRdFI8SJ86wgKZSQZdUxT+bj0wVFpsEMWnkXP0twVe1gJyNF5apBWDJhhIbgrIViRA=="],
"npm-normalize-package-bin": ["[email protected]", "", {}, "sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag=="],
"npm-package-arg": ["[email protected]", "", { "dependencies": { "hosted-git-info": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "validate-npm-package-name": "^7.0.0" } }, "sha512-IciCE3SY3uE84Ld8WZU23gAPPV9rIYod4F+rc+vJ7h7cwAJt9Vk6TVsK60ry7Uj3SRS3bqRRIGuTp9YVlk6WNA=="],
"npm-packlist": ["[email protected]", "", { "dependencies": { "ignore-walk": "^8.0.0", "proc-log": "^6.0.0" } }, "sha512-uMW73iajD8hiH4ZBxEV3HC+eTnppIqwakjOYuvgddnalIw2lJguKviK1pcUJDlIWm1wSJkchpDZDSVVsZEYRng=="],
"npm-pick-manifest": ["[email protected]", "", { "dependencies": { "npm-install-checks": "^8.0.0", "npm-normalize-package-bin": "^5.0.0", "npm-package-arg": "^13.0.0", "semver": "^7.3.5" } }, "sha512-buzyCfeoGY/PxKqmBqn1IUJrZnUi1VVJTdSSRPGI60tJdUhUoSQFhs0zycJokDdOznQentgrpf8LayEHyyYlqQ=="],
"npm-registry-fetch": ["[email protected]", "", { "dependencies": { "@npmcli/redact": "^4.0.0", "jsonparse": "^1.3.1", "make-fetch-happen": "^15.0.0", "minipass": "^7.0.2", "minipass-fetch": "^5.0.0", "minizlib": "^3.0.1", "npm-package-arg": "^13.0.0", "proc-log": "^6.0.0" } }, "sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw=="],
"open": ["[email protected]", "", { "dependencies": { "default-browser": "^5.5.1", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.2.1", "wsl-utils": "^1.0.0" } }, "sha512-++Zlftm0kVLPmzC06t6epuWmcRMDbI4z5P3NNX979WA/k23+NtSOynEGzsVfZwguKw2mi5umVgnBlJQMwRz4Pg=="],
"p-map": ["[email protected]", "", {}, "sha512-HaSJ9hNZOxTUSATfB48Qi7U2+GF1eECjI39nkeLmCWQrSOYr1z4NepR0rpWiZO77pYwUWU2cJ5NIgsmoFCsYDQ=="],
"package-json-from-dist": ["[email protected]", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
"pacote": ["[email protected]", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/git": "^7.0.0", "@npmcli/installed-package-contents": "^4.0.0", "@npmcli/package-json": "^7.0.0", "@npmcli/promise-spawn": "^9.0.0", "@npmcli/run-script": "^10.0.0", "cacache": "^20.0.0", "fs-minipass": "^3.0.0", "minipass": "^7.0.2", "npm-package-arg": "^13.0.0", "npm-packlist": "^10.0.1", "npm-pick-manifest": "^11.0.1", "npm-registry-fetch": "^19.0.0", "proc-log": "^6.0.0", "sigstore": "^4.0.0", "ssri": "^13.0.0", "tar": "^7.4.3" }, "bin": { "pacote": "bin/index.js" } }, "sha512-KvcJ9iy3crysCsgqc4+PknH/w6jkrp8JN36mpZBPwNaDRwTfMZD37YzRazNstiZUOhuF5pno9f78n9mEJBavwg=="],
"parse-conflict-json": ["[email protected]", "", { "dependencies": { "json-parse-even-better-errors": "^5.0.0", "just-diff": "^6.0.0", "just-diff-apply": "^5.2.0" } }, "sha512-ZHEmNKMq1wyJXNwLxyHnluPfRAFSIliBvbK/UiOceROt4Xh9Pz0fq49NytIaeaCUf5VR86hwQ/34FCcNU5/LKQ=="],
"path-key": ["[email protected]", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-scurry": ["[email protected]", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="],
"picomatch": ["[email protected]", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
"postcss-selector-parser": ["[email protected]", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw=="],
"powershell-utils": ["[email protected]", "", {}, "sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A=="],
"prettier": ["[email protected]", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA=="],
"proc-log": ["[email protected]", "", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="],
"proggy": ["[email protected]", "", {}, "sha512-MbA4R+WQT76ZBm/5JUpV9yqcJt92175+Y0Bodg3HgiXzrmKu7Ggq+bpn6y6wHH+gN9NcyKn3yg1+d47VaKwNAQ=="],
"promise-all-reject-late": ["[email protected]", "", {}, "sha512-vuf0Lf0lOxyQREH7GDIOUMLS7kz+gs8i6B+Yi8dC68a2sychGrHTJYghMBD6k7eUcH0H5P73EckCA48xijWqXw=="],
"promise-call-limit": ["[email protected]", "", {}, "sha512-mRPQO2T1QQVw11E7+UdCJu7S61eJVWknzml9sC1heAdj1jxl0fWMBypIt9ZOcLFf8FkG995ZD7RnVk7HH72fZw=="],
"pure-rand": ["[email protected]", "", {}, "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng=="],
"read-cmd-shim": ["[email protected]", "", {}, "sha512-1zM5HuOfagXCBWMN83fuFI/x+T/UhZ7k+KIzhrHXcQoeX5+7gmaDYjELQHmmzIodumBHeByBJT4QYS7ufAgs7A=="],
"redis": ["[email protected]", "", { "dependencies": { "@redis/bloom": "6.3.0", "@redis/client": "6.3.0", "@redis/json": "6.3.0", "@redis/search": "6.3.0", "@redis/time-series": "6.3.0" } }, "sha512-XFQbPie1lGpKeUZ8ySYY43yLnQy/iIQtHNvIr/1XdaABpb96rUSdm3Mfl+98FEVbv9ZJIzINMno5s0Nh8E5LEw=="],
"rimraf": ["[email protected]", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="],
"run-applescript": ["[email protected]", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="],
"safe-buffer": ["[email protected]", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safer-buffer": ["[email protected]", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"shebang-command": ["[email protected]", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["[email protected]", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"signal-exit": ["[email protected]", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"sigstore": ["[email protected]", "", { "dependencies": { "@sigstore/bundle": "^4.0.0", "@sigstore/core": "^3.2.1", "@sigstore/protobuf-specs": "^0.5.0", "@sigstore/sign": "^4.1.1", "@sigstore/tuf": "^4.0.2", "@sigstore/verify": "^3.1.1" } }, "sha512-endqECJkfhozrXMK5ngu/UAA0xVcVEFdnHJCElGaExypjW+HK5i6zu3NteLoaX/iFbRUbC3+DjttQs0GARr+5w=="],
"smart-buffer": ["[email protected]", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="],
"socks": ["[email protected]", "", { "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" } }, "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ=="],
"socks-proxy-agent": ["[email protected]", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="],
"spdx-exceptions": ["[email protected]", "", {}, "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w=="],
"spdx-expression-parse": ["[email protected]", "", { "dependencies": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" } }, "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ=="],
"spdx-license-ids": ["[email protected]", "", {}, "sha512-cLS9TtWkIQFyLkJ3/5aFQAOHOSKTlOs/7WDut/XPSdjom1fJhUdkepeJAKXa9Y05+FabHd0sti+Et559vDtkpQ=="],
"ssri": ["[email protected]", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ=="],
"string-width": ["[email protected]", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
"string-width-cjs": ["[email protected]", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"strip-ansi": ["[email protected]", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"strip-ansi-cjs": ["[email protected]", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"tar": ["[email protected]", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA=="],
"tinyglobby": ["[email protected]", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"treeverse": ["[email protected]", "", {}, "sha512-gcANaAnd2QDZFmHFEOF4k7uc1J/6a6z3DJMd/QwEyxLoKGiptJRwid582r7QIsFlFMIZ3SnxfS52S4hm2DHkuQ=="],
"tslib": ["[email protected]", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tuf-js": ["[email protected]", "", { "dependencies": { "@tufjs/models": "4.1.0", "debug": "^4.4.3", "make-fetch-happen": "^15.0.1" } }, "sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ=="],
"typescript": ["[email protected]", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="],
"undici": ["[email protected]", "", {}, "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ=="],
"undici-types": ["[email protected]", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
"util-deprecate": ["[email protected]", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"validate-npm-package-name": ["[email protected]", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="],
"walk-up-path": ["[email protected]", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="],
"web-streams-polyfill": ["[email protected]", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
"which": ["[email protected]", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"wrap-ansi": ["[email protected]", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
"wrap-ansi-cjs": ["[email protected]", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"write-file-atomic": ["[email protected]", "", { "dependencies": { "signal-exit": "^4.0.1" } }, "sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg=="],
"ws": ["[email protected]", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg=="],
"wsl-utils": ["[email protected]", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.2.0" } }, "sha512-c16PKLIRxBYpR3jSWsdS7SFp0COm5SOQdiFjY818dYFHJbMGuLBszpF07LBWmOS1uzrHa9VGdynsEgoeKy0oOQ=="],
"yallist": ["[email protected]", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"yaml": ["[email protected]", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@aws-crypto/util/@smithy/util-utf8": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@npmcli/git/which": ["[email protected]", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="],
"@npmcli/promise-spawn/which": ["[email protected]", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="],
"gcp-metadata/gaxios": ["[email protected]", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "rimraf": "^5.0.1" } }, "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ=="],
"gcp-metadata/google-logging-utils": ["[email protected]", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="],
"minipass-flush/minipass": ["[email protected]", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
"minipass-pipeline/minipass": ["[email protected]", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
"node-gyp/undici": ["[email protected]", "", {}, "sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg=="],
"node-gyp/which": ["[email protected]", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="],
"rimraf/glob": ["[email protected]", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
"string-width-cjs/emoji-regex": ["[email protected]", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"string-width-cjs/strip-ansi": ["[email protected]", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-ansi-cjs/ansi-regex": ["[email protected]", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"wrap-ansi-cjs/ansi-styles": ["[email protected]", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"wrap-ansi-cjs/string-width": ["[email protected]", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"wrap-ansi-cjs/strip-ansi": ["[email protected]", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@npmcli/git/which/isexe": ["[email protected]", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
"@npmcli/promise-spawn/which/isexe": ["[email protected]", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
"minipass-flush/minipass/yallist": ["[email protected]", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"minipass-pipeline/minipass/yallist": ["[email protected]", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"node-gyp/which/isexe": ["[email protected]", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
"rimraf/glob/minimatch": ["[email protected]", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
"rimraf/glob/path-scurry": ["[email protected]", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
"string-width-cjs/strip-ansi/ansi-regex": ["[email protected]", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"wrap-ansi-cjs/string-width/emoji-regex": ["[email protected]", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"wrap-ansi-cjs/strip-ansi/ansi-regex": ["[email protected]", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"rimraf/glob/minimatch/brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g=="],
"rimraf/glob/path-scurry/lru-cache": ["[email protected]", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"rimraf/glob/minimatch/brace-expansion/balanced-match": ["[email protected]", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
}
}
+44
View File
@@ -0,0 +1,44 @@
{
"name": "claude-4-opencode",
"version": "0.0.1",
"description": "Run Claude Code plugins and marketplaces on opencode v2",
"type": "module",
"repository": {
"type": "git",
"url": "git+https://github.com/Heretek-AI/claude-4-opencode.git"
},
"bin": {
"c4o": "./dist/cli.js"
},
"exports": {
".": "./dist/server.js",
"./server": "./dist/server.js"
},
"files": [
"dist"
],
"engines": {
"node": ">=22"
},
"scripts": {
"build": "bun build src/server.ts src/cli.ts --outdir dist --target node --format esm",
"typecheck": "tsc --noEmit",
"test": "bun test ./test",
"e2e": "bun run build && bun test ./e2e --timeout 600000 --reporter=junit --reporter-outfile=.e2e/report/junit.xml",
"c4o": "bun src/cli.ts",
"e2e:report": "bun e2e/report/matrix.ts",
"e2e:oracle": "bun e2e/oracle/capture.ts",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"dependencies": {
"jsonc-parser": "^3.3.1",
"yaml": "^2.9.1"
},
"devDependencies": {
"@opencode/plugin": "2.0.26",
"@types/bun": "^1.4.2",
"prettier": "^3.9.10",
"typescript": "^7.0.2"
}
}
+309
View File
@@ -0,0 +1,309 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process"
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
import { homedir } from "node:os"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { parseArgs } from "node:util"
import { applyEdits, modify, parse as parseJsonc } from "jsonc-parser/lib/esm/main.js"
import { loadPlugin } from "./spec/plugin.ts"
import {
formatId,
installPlugin,
listInstalled,
loadActivePlugins,
resolveRef,
setEnabled,
uninstallPlugin,
type ActivePlugin,
} from "./store/installs.ts"
import { addMarketplace, parseMarketplaceInput, removeMarketplace, updateMarketplace } from "./store/marketplaces.ts"
import { dataDirectory, storePaths } from "./store/paths.ts"
import { readKnownMarketplaces } from "./store/state.ts"
import { buildReport, formatReport } from "./translate/report.ts"
const USAGE = `c4o - run Claude Code plugins and marketplaces on opencode v2
Usage:
c4o marketplace add <owner/repo[@ref] | git-url[#ref] | marketplace.json-url | path>
c4o marketplace list
c4o marketplace update [name]
c4o marketplace remove <name>
c4o install <plugin>[@marketplace] [more plugins...]
c4o uninstall <plugin>[@marketplace] [--keep-data]
c4o enable <plugin>[@marketplace]
c4o disable <plugin>[@marketplace]
c4o update [plugin[@marketplace] ...]
c4o list
c4o inspect <plugin>[@marketplace] | <plugin directory>
c4o setup
c4o doctor
Options:
--json Machine-readable output
--help Show this text
The store lives in $C4O_HOME, or $XDG_DATA_HOME/opencode/claude-plugins.`
async function main(argv: string[]): Promise<number> {
const { values, positionals } = parseArgs({
args: argv,
allowPositionals: true,
options: { json: { type: "boolean" }, help: { type: "boolean" }, "keep-data": { type: "boolean" } },
})
const [command, ...rest] = positionals
if (values.help || command === undefined) {
console.log(USAGE)
return command === undefined && !values.help ? 1 : 0
}
const paths = storePaths()
const print = (value: unknown, text: string) => console.log(values.json ? JSON.stringify(value, null, 2) : text)
const need = (value: string | undefined, what: string) => {
if (value === undefined) throw new Error(`Missing ${what}. Run c4o --help.`)
return value
}
if (command === "marketplace") {
const [action, target] = rest
if (action === "add") {
const result = await addMarketplace(
paths,
parseMarketplaceInput(need(target, "marketplace source"), process.cwd()),
)
const count = result.read.marketplace!.plugins.length
print(
{ name: result.name, plugins: count, ...result.known },
`Added marketplace ${result.name} (${count} plugins)`,
)
return 0
}
if (action === "list") {
const known = readKnownMarketplaces(paths)
const rows = Object.entries(known).map(([name, item]) => ({ name, ...item }))
print(rows, rows.map((row) => `${row.name}\t${describeOrigin(row.source)}`).join("\n") || "No marketplaces added")
return 0
}
if (action === "update") {
const names = target === undefined ? Object.keys(readKnownMarketplaces(paths)) : [target]
const updated = []
for (const name of names) updated.push((await updateMarketplace(paths, name)).name)
print(updated, updated.map((name) => `Updated ${name}`).join("\n") || "No marketplaces to update")
return 0
}
if (action === "remove") {
removeMarketplace(paths, need(target, "marketplace name"))
print({ removed: target }, `Removed marketplace ${target}`)
return 0
}
throw new Error(`Unknown marketplace action: ${action ?? "(none)"}. Run c4o --help.`)
}
if (command === "install") {
need(rest[0], "plugin name")
const reports = []
for (const name of rest) {
const ref = resolveRef(paths, name)
const record = await installPlugin(paths, ref)
reports.push({ ...buildReport(activeFor(formatId(ref))), installPath: record.installPath })
}
print(
rest.length === 1 ? reports[0] : reports,
reports.map((report) => `Installed ${formatReport(report)}`).join("\n"),
)
return 0
}
if (command === "uninstall") {
const ref = resolveRef(paths, need(rest[0], "plugin name"))
uninstallPlugin(paths, ref, { keepData: values["keep-data"] === true })
print({ uninstalled: formatId(ref) }, `Uninstalled ${formatId(ref)}`)
return 0
}
if (command === "enable" || command === "disable") {
const ref = resolveRef(paths, need(rest[0], "plugin name"))
setEnabled(paths, ref, command === "enable")
print(
{ id: formatId(ref), enabled: command === "enable" },
`${command === "enable" ? "Enabled" : "Disabled"} ${formatId(ref)}`,
)
return 0
}
if (command === "update") {
// Refresh the marketplaces first, so a reinstall picks up what they now point at.
const targets =
rest.length > 0
? rest.map((name) => resolveRef(paths, name))
: listInstalled(paths).map((item) => resolveRef(paths, item.id))
for (const name of new Set(targets.map((ref) => ref.marketplace))) await updateMarketplace(paths, name)
const rows = []
for (const ref of targets) {
const before = listInstalled(paths).find((item) => item.id === formatId(ref))?.record.version
const record = await installPlugin(paths, ref)
rows.push({ id: formatId(ref), from: before, to: record.version })
}
print(
rows,
rows
.map((row) => `${row.id}\t${row.from === row.to ? `${row.to} (unchanged)` : `${row.from} -> ${row.to}`}`)
.join("\n") || "No plugins installed",
)
return 0
}
if (command === "setup") {
const result = registerWithOpencode()
print(
result,
result.changed ? `Added ${result.plugin} to ${result.config}` : `${result.plugin} is already in ${result.config}`,
)
return 0
}
if (command === "doctor") {
const checks = doctor(paths)
print(checks, checks.map((check) => `${check.ok ? "ok " : "FAIL"} ${check.name}: ${check.detail}`).join("\n"))
return checks.every((check) => check.ok) ? 0 : 1
}
if (command === "list") {
const rows = listInstalled(paths).map((item) => ({
id: item.id,
version: item.record.version,
enabled: item.enabled,
}))
print(
rows,
rows.map((row) => `${row.id}\t${row.version}\t${row.enabled ? "enabled" : "disabled"}`).join("\n") ||
"No plugins installed",
)
return 0
}
if (command === "inspect") {
const target = need(rest[0], "plugin name or directory")
const directory = path.resolve(target)
const active = existsSync(directory)
? activeForDirectory(directory)
: activeFor(formatId(resolveRef(paths, target)))
const report = buildReport(active)
print(report, formatReport(report))
return report.issues.some((issue) => issue.level === "error") ? 1 : 0
}
throw new Error(`Unknown command: ${command}. Run c4o --help.`)
function activeFor(id: string): ActivePlugin {
const installed = listInstalled(paths).find((item) => item.id === id)
if (!installed) throw new Error(`${id} is not installed`)
// Inspect disabled plugins too: load straight from the record rather than the enabled set.
const enabled = loadActivePlugins(paths).find((item) => item.id === id)
if (enabled) return enabled
const plugin = loadPlugin({
root: installed.record.installPath,
...(installed.record.entry === undefined ? {} : { entry: installed.record.entry }),
...(installed.record.isMarketplaceRoot ? { entryIsMarketplaceRoot: true } : {}),
})
return { id, plugin, dataDir: dataDirectory(paths, id), options: {} }
}
function activeForDirectory(directory: string): ActivePlugin {
const plugin = loadPlugin({ root: directory })
const id = `${plugin.name}@inline`
return { id, plugin, dataDir: dataDirectory(paths, id), options: {} }
}
}
/** The directory opencode should load as a plugin: the one holding this build's `server` entry. */
function pluginDirectory() {
return path.dirname(fileURLToPath(import.meta.url))
}
function opencodeConfigFile() {
const directory = path.join(
process.env.XDG_CONFIG_HOME ?? path.join(process.env.HOME ?? homedir(), ".config"),
"opencode",
)
return (
["opencode.jsonc", "opencode.json"].map((name) => path.join(directory, name)).find((file) => existsSync(file)) ??
path.join(directory, "opencode.jsonc")
)
}
function configuredPlugins(file: string): string[] {
if (!existsSync(file)) return []
const plugins = (parseJsonc(readFileSync(file, "utf8")) as { plugins?: unknown } | undefined)?.plugins
return Array.isArray(plugins)
? plugins.map((item) => (typeof item === "string" ? item : String((item as { package?: unknown }).package)))
: []
}
/** Adds this build to the `plugins` list of opencode's global config, keeping comments and formatting. */
function registerWithOpencode() {
const config = opencodeConfigFile()
const plugin = pluginDirectory()
if (configuredPlugins(config).includes(plugin)) return { config, plugin, changed: false }
const text = existsSync(config) ? readFileSync(config, "utf8") : "{}\n"
const edits = modify(text, ["plugins", -1], plugin, { formattingOptions: { tabSize: 2, insertSpaces: true } })
mkdirSync(path.dirname(config), { recursive: true })
writeFileSync(config, applyEdits(text, edits))
return { config, plugin, changed: true }
}
function doctor(paths: ReturnType<typeof storePaths>) {
const version = (command: string) => {
try {
return execFileSync(command, ["--version"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] })
.trim()
.split("\n")[0]!
} catch {
return undefined
}
}
const git = version("git")
const opencode = version("opencode")
const config = opencodeConfigFile()
const registered = configuredPlugins(config).some(
(item) => item === pluginDirectory() || /(^|\/)claude-4-opencode(@|$)/.test(item),
)
const installed = listInstalled(paths)
const broken = loadActivePlugins(paths).flatMap((active) =>
active.plugin.issues.filter((issue) => issue.level === "error").map((issue) => `${active.id}: ${issue.message}`),
)
return [
{ name: "git", ok: git !== undefined, detail: git ?? "not found; needed to add marketplaces and install plugins" },
{
name: "opencode",
ok: opencode !== undefined && /\bv?2\./.test(opencode),
detail:
opencode === undefined
? "not found on PATH"
: /\bv?2\./.test(opencode)
? opencode
: `${opencode} (version 2 is required)`,
},
{ name: "plugin registered", ok: registered, detail: registered ? config : `not in ${config}; run c4o setup` },
{
name: "store",
ok: true,
detail: `${paths.root} (${installed.length} installed, ${installed.filter((item) => item.enabled).length} enabled)`,
},
{ name: "plugins load", ok: broken.length === 0, detail: broken.length === 0 ? "no errors" : broken.join("; ") },
]
}
function describeOrigin(origin: ReturnType<typeof parseMarketplaceInput>) {
if (origin.source === "github") return `github:${origin.repo}${origin.ref ? `@${origin.ref}` : ""}`
if (origin.source === "git") return `${origin.url}${origin.ref ? `#${origin.ref}` : ""}`
if (origin.source === "url") return origin.url
return origin.path
}
main(process.argv.slice(2)).then(
(code) => process.exit(code),
(error: unknown) => {
console.error(`c4o: ${error instanceof Error ? error.message : String(error)}`)
process.exit(1)
},
)
+15
View File
@@ -0,0 +1,15 @@
import { appendFileSync } from "node:fs"
/**
* Diagnostics go to the file named by `C4O_LOG`, never to stdout: opencode's stdio server mode
* uses stdout as its protocol channel.
*/
export function debug(message: string, detail?: Record<string, unknown>) {
const file = process.env.C4O_LOG
if (!file) return
try {
appendFileSync(file, JSON.stringify({ time: new Date().toISOString(), message, ...detail }) + "\n")
} catch {
// A broken log path must not take the plugin down.
}
}
+226
View File
@@ -0,0 +1,226 @@
import type { Plugin } from "@opencode/plugin"
import { isRecord } from "../../spec/frontmatter.ts"
import type { ActivePlugin } from "../../store/installs.ts"
import { mcpServerName } from "../../translate/components.ts"
import { applyUpdatedInput, toClaudeToolCall, type ClaudeToolCall } from "../../translate/tools.ts"
import { debug } from "../debug.ts"
import { hasHooks, runHooks, type Invocation, type Outcome } from "./engine.ts"
import { writeTranscript } from "./transcript.ts"
export interface HookOptions {
projectDir: string
/** Where synthesized transcripts are written. */
transcripts: string
}
interface SessionState {
/** Context from SessionStart hooks, re-added to the system prompt on every request. Unset until they ran. */
startContext?: string[]
/** True while the session is continuing because a Stop hook blocked. Reported as `stop_hook_active`. */
stopActive: boolean
child?: boolean
}
const NOTHING: Outcome = { context: [], ran: 0 }
/**
* Wires Claude Code hook events to the opencode surfaces that correspond to them.
* `state.plugins` is read on every event, so installs and enables take effect without rebinding.
*/
export async function bindHooks(
ctx: Plugin.Context,
state: { plugins: ActivePlugin[] },
options: HookOptions,
): Promise<() => void> {
const sessions = new Map<string, SessionState>()
/** Sessions whose next prompt is one this plugin sent, which must not trigger UserPromptSubmit. */
const internalPrompts = new Set<string>()
/** PreToolUse context, held until the tool finishes so it can ride along with the result. */
const pendingContext = new Map<string, string[]>()
let disposed = false
const sessionState = (sessionID: string) => {
const existing = sessions.get(sessionID)
if (existing) return existing
const created: SessionState = { stopActive: false }
sessions.set(sessionID, created)
return created
}
/** Subagents run as child sessions. Claude Code fires different events for them. */
const isChild = async (sessionID: string) => {
const session = sessionState(sessionID)
if (session.child !== undefined) return session.child
const info = unwrap(await ctx.session.get({ sessionID: sessionID as never }).catch(() => undefined))
session.child = isRecord(info) && typeof info.parentID === "string"
return session.child
}
const run = async (sessionID: string, invocation: Invocation): Promise<Outcome> => {
if (!hasHooks(state.plugins, invocation.event)) return NOTHING
const transcriptPath = await ctx.session
.context({ sessionID: sessionID as never })
.then((value) => {
const messages = unwrap(value)
return writeTranscript(
options.transcripts,
sessionID,
options.projectDir,
Array.isArray(messages) ? messages : [],
)
})
.catch(() => undefined)
return runHooks(state.plugins, invocation, {
sessionID,
cwd: options.projectDir,
...(transcriptPath === undefined ? {} : { transcriptPath }),
})
}
/** Names a tool call the way a Claude hook matcher expects, including plugin MCP tools. */
const describe = (tool: string, input: unknown): ClaudeToolCall => {
for (const active of state.plugins) {
for (const server of Object.keys(active.plugin.mcpServers)) {
const prefix = `${mcpServerName(active.plugin.name, server).replaceAll(".", "_")}_`
if (!tool.startsWith(prefix)) continue
return {
name: `mcp__plugin_${active.plugin.name}_${server}__${tool.slice(prefix.length)}`,
input: isRecord(input) ? input : {},
}
}
}
return toClaudeToolCall(tool, input)
}
const registrations = [
await ctx.tool.hook("execute.before", async (event) => {
if (!hasHooks(state.plugins, "PreToolUse")) return
const call = describe(event.tool, event.input)
const outcome = await run(event.sessionID, {
event: "PreToolUse",
subject: call.name,
toolCall: call,
payload: { tool_name: call.name, tool_input: call.input, tool_use_id: event.id },
})
if (outcome.blocked !== undefined) throw new Error([outcome.blocked, ...outcome.context].join("\n\n"))
if (outcome.updatedInput) event.input = applyUpdatedInput(event.tool, event.input, outcome.updatedInput)
if (outcome.context.length > 0) pendingContext.set(event.id, outcome.context)
}),
await ctx.tool.hook("execute.after", async (event) => {
const earlier = pendingContext.get(event.id) ?? []
pendingContext.delete(event.id)
const name = event.status === "completed" ? "PostToolUse" : "PostToolUseFailure"
const call = describe(event.tool, event.input)
const outcome = await run(event.sessionID, {
event: name,
subject: call.name,
toolCall: call,
payload: {
tool_name: call.name,
tool_input: call.input,
tool_use_id: event.id,
...(event.status === "completed"
? { tool_response: event.result.output ?? event.result.content ?? "" }
: { error: event.error.message }),
},
})
// A PostToolUse hook cannot undo the call. Its block reason and context are feedback for the model.
const feedback = [...earlier, ...outcome.context, ...(outcome.blocked === undefined ? [] : [outcome.blocked])]
if (feedback.length === 0 || event.status !== "completed") return
event.result = {
...event.result,
content: [...contentParts(event.result), { type: "text", text: feedback.join("\n\n") }],
}
}),
await ctx.session.hook("prompt", async (event) => {
const session = sessionState(event.sessionID)
if (internalPrompts.delete(event.sessionID)) return
session.stopActive = false
if (!hasHooks(state.plugins, "UserPromptSubmit") || (await isChild(event.sessionID))) return
const outcome = await run(event.sessionID, { event: "UserPromptSubmit", payload: { prompt: event.prompt.text } })
if (outcome.blocked !== undefined) throw new Error(outcome.blocked)
if (outcome.context.length > 0) event.prompt.text += `\n\n${reminder(outcome.context)}`
}),
await ctx.session.hook("context", async (event) => {
const session = sessionState(event.sessionID)
if (session.startContext === undefined) {
// Claim the slot before awaiting so a concurrent request cannot start the hooks twice.
session.startContext = []
if (hasHooks(state.plugins, "SessionStart") && !(await isChild(event.sessionID))) {
const source = event.messages.length > 1 ? "resume" : "startup"
const outcome = await run(event.sessionID, {
event: "SessionStart",
subject: source,
payload: { source, model: event.model.id },
})
session.startContext = outcome.context
}
}
// The system prompt is rebuilt per request, so the context is added each time.
for (const text of session.startContext)
event.system.push({ type: "text", text } as (typeof event.system)[number])
}),
]
const onEvent = async (event: { type: string; data?: unknown }) => {
const sessionID =
isRecord(event.data) && typeof event.data.sessionID === "string" ? event.data.sessionID : undefined
if (sessionID === undefined) return
if (event.type === "session.deleted") {
await run(sessionID, { event: "SessionEnd", payload: { reason: "other" } })
sessions.delete(sessionID)
return
}
if (event.type !== "session.execution.succeeded") return
const child = await isChild(sessionID)
const name = child ? "SubagentStop" : "Stop"
if (!hasHooks(state.plugins, name)) return
const session = sessionState(sessionID)
const outcome = await run(sessionID, { event: name, payload: { stop_hook_active: session.stopActive } })
// The parent has already consumed a subagent's result, so a child cannot be made to continue.
if (outcome.blocked === undefined || child) {
session.stopActive = false
return
}
session.stopActive = true
internalPrompts.add(sessionID)
await ctx.session.prompt({ sessionID: sessionID as never, text: outcome.blocked })
}
void (async () => {
const events = (await ctx.event.subscribe()) as unknown as AsyncIterable<{ type: string; data?: unknown }>
for await (const event of events) {
if (disposed) return
// Handle each event on its own so a slow Stop hook does not hold up the stream.
void onEvent(event).catch((error) => debug("event hook failed", { type: event.type, error: String(error) }))
}
})().catch((error) => debug("event stream ended", { error: String(error) }))
return () => {
disposed = true
for (const registration of registrations) void registration.dispose()
}
}
/** Hook context is delivered to the model the way Claude Code delivers it: as a system reminder. */
function reminder(context: string[]) {
return `<system-reminder>\n${context.join("\n\n")}\n</system-reminder>`
}
/** API calls answer either the value itself or `{ data }`. */
function unwrap(value: unknown): unknown {
return isRecord(value) && "data" in value ? value.data : value
}
/** The parts the model would see for a result, following opencode's own normalization. */
function contentParts(result: { output?: unknown; content?: string | ReadonlyArray<unknown> }): never[] {
if (typeof result.content === "string") return [{ type: "text", text: result.content }] as never[]
if (result.content !== undefined && result.content.length > 0) return [...result.content] as never[]
const output = result.output
return [
{ type: "text", text: typeof output === "string" ? output : (JSON.stringify(output) ?? String(output)) },
] as never[]
}
+264
View File
@@ -0,0 +1,264 @@
import { spawn } from "node:child_process"
import path from "node:path"
import { isRecord } from "../../spec/frontmatter.ts"
import type { CommandHook, HookHandler, HttpHook } from "../../spec/types.ts"
import type { ActivePlugin } from "../../store/installs.ts"
import { varContext } from "../../translate/components.ts"
import type { ClaudeToolCall } from "../../translate/tools.ts"
import { pluginEnvironment, substitute, substituteWithEnv, type VarContext } from "../../translate/vars.ts"
import { debug } from "../debug.ts"
export interface Invocation {
event: string
/** What matchers are tested against: the tool name for tool events, the source for SessionStart. */
subject?: string
/** The tool call, for `if` conditions. */
toolCall?: ClaudeToolCall
/** Event-specific input fields, added to the common ones. */
payload: Record<string, unknown>
}
export interface Session {
sessionID: string
cwd: string
transcriptPath?: string
}
/** The combined effect of every handler that ran for one event. */
export interface Outcome {
/** Set when any handler blocked. Holds the reason to show the model or user. */
blocked?: string
/** Text handlers asked to add to the conversation. */
context: string[]
/** Replacement tool input from a PreToolUse handler, in Claude's field names. */
updatedInput?: Record<string, unknown>
ran: number
}
interface HandlerResult {
blocked?: string
context?: string
updatedInput?: Record<string, unknown>
}
/** Events whose plain, non-JSON stdout is added to the conversation as context. */
const STDOUT_IS_CONTEXT = new Set(["UserPromptSubmit", "SessionStart"])
const DEFAULT_TIMEOUT_SECONDS = 60
export function hasHooks(plugins: readonly ActivePlugin[], event: string) {
return plugins.some((active) => (active.plugin.hooks[event]?.length ?? 0) > 0)
}
/**
* Runs every matching handler of an event across all active plugins, in parallel as Claude Code
* does, and merges their results. Any block wins.
*/
export async function runHooks(
plugins: readonly ActivePlugin[],
invocation: Invocation,
session: Session,
): Promise<Outcome> {
const tasks: Promise<HandlerResult>[] = []
for (const active of plugins) {
const context = varContext(active, session.cwd)
for (const matcher of active.plugin.hooks[invocation.event] ?? []) {
if (!matcherMatches(matcher.matcher, invocation.subject)) continue
for (const handler of matcher.hooks) {
if (typeof handler.if === "string" && !conditionMatches(handler.if, invocation.toolCall)) continue
const task = runHandler(handler, invocation, session, context).catch((error): HandlerResult => {
debug("hook failed", { plugin: active.id, event: invocation.event, error: String(error) })
return {}
})
// Async handlers run in the background and cannot influence the event.
if (handler.async === true) continue
tasks.push(task)
}
}
}
const results = await Promise.all(tasks)
const blocked = results.find((result) => result.blocked !== undefined)?.blocked
const updatedInput = results.findLast((result) => result.updatedInput !== undefined)?.updatedInput
return {
...(blocked === undefined ? {} : { blocked }),
context: results.flatMap((result) => (result.context ? [result.context] : [])),
...(updatedInput === undefined ? {} : { updatedInput }),
ran: results.length,
}
}
/** An absent, empty or `*` matcher matches everything. Otherwise it is a regex over the whole subject. */
export function matcherMatches(matcher: string | undefined, subject: string | undefined) {
if (matcher === undefined || matcher === "" || matcher === "*") return true
if (subject === undefined) return true
try {
return new RegExp(`^(?:${matcher})$`).test(subject)
} catch {
return matcher === subject
}
}
/**
* `if` narrows a handler with a permission rule such as `Bash(git commit:*)` or `Edit(*.ts)`.
* Bash rules test the command (`prefix:*` is a prefix match); other tools test the file path.
*/
export function conditionMatches(condition: string, call: ClaudeToolCall | undefined) {
if (!call) return true
const match = /^([A-Za-z_]\w*)(?:\((.*)\))?$/.exec(condition.trim())
if (!match) return true
if (match[1] !== call.name) return false
const rule = match[2]
if (rule === undefined || rule === "" || rule === "*") return true
if (call.name === "Bash") {
const command = String(call.input.command ?? "")
return rule.endsWith(":*") ? command.startsWith(rule.slice(0, -2)) : glob(rule).test(command)
}
const file = String(call.input.file_path ?? call.input.path ?? "")
return glob(rule).test(file) || glob(rule).test(path.basename(file))
}
function glob(pattern: string) {
const escaped = pattern
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*+/g, ".*")
.replace(/\?/g, ".")
return new RegExp(`^${escaped}$`)
}
function runHandler(
handler: HookHandler,
invocation: Invocation,
session: Session,
context: VarContext,
): Promise<HandlerResult> {
const input = {
session_id: session.sessionID,
...(session.transcriptPath === undefined ? {} : { transcript_path: session.transcriptPath }),
cwd: session.cwd,
permission_mode: "default",
hook_event_name: invocation.event,
...invocation.payload,
}
if (handler.type === "command") return runCommand(handler as CommandHook, input, invocation.event, session, context)
if (handler.type === "http") return runHttp(handler as HttpHook, input, invocation.event, context)
// prompt, agent and mcp_tool handlers are read but not run; `c4o inspect` reports them.
return Promise.resolve({})
}
function runCommand(
handler: CommandHook,
input: Record<string, unknown>,
event: string,
session: Session,
context: VarContext,
): Promise<HandlerResult> {
if (handler.shell === "powershell") return Promise.resolve({})
const command = substitute(handler.command, context)
// Exec form passes each argument as is; shell form hands the whole string to bash.
const [file, args] = Array.isArray(handler.args)
? [command, handler.args.map((item) => substitute(String(item), context))]
: ["bash", ["-c", command]]
return new Promise((resolve) => {
const child = spawn(file, args, {
cwd: session.cwd,
env: { ...process.env, ...pluginEnvironment(context) },
stdio: ["pipe", "pipe", "pipe"],
})
let stdout = ""
let stderr = ""
let settled = false
const finish = (result: HandlerResult) => {
if (settled) return
settled = true
clearTimeout(timer)
resolve(result)
}
const timer = setTimeout(
() => {
child.kill("SIGKILL")
debug("hook timed out", { event, command })
finish({})
},
(handler.timeout ?? DEFAULT_TIMEOUT_SECONDS) * 1000,
)
child.stdout.on("data", (chunk) => (stdout += chunk))
child.stderr.on("data", (chunk) => (stderr += chunk))
child.on("error", (error) => {
debug("hook could not start", { event, command, error: String(error) })
finish({})
})
child.on("close", (code) => {
debug("hook ran", { event, command, code, stdout: stdout.slice(0, 2000), stderr: stderr.slice(0, 2000) })
// Exit 2 is the blocking error: stderr is the reason.
if (code === 2) return finish({ blocked: stderr.trim() || "Blocked by hook" })
// Any other failure is non-blocking.
if (code !== 0) return finish({})
finish(interpret(stdout, event))
})
child.stdin.on("error", () => {})
child.stdin.end(JSON.stringify(input))
})
}
async function runHttp(
handler: HttpHook,
input: Record<string, unknown>,
event: string,
context: VarContext,
): Promise<HandlerResult> {
const expand = (value: string) => substituteWithEnv(value, context, process.env)
const response = await fetch(expand(handler.url), {
method: "POST",
headers: {
"content-type": "application/json",
...Object.fromEntries(Object.entries(handler.headers ?? {}).map(([key, value]) => [key, expand(value)])),
},
body: JSON.stringify(input),
signal: AbortSignal.timeout((handler.timeout ?? DEFAULT_TIMEOUT_SECONDS) * 1000),
})
if (!response.ok) return {}
return interpret(await response.text(), event)
}
/** Applies Claude Code's hook output protocol to a successful handler's stdout. */
export function interpret(stdout: string, event: string): HandlerResult {
const trimmed = stdout.trim()
const json = trimmed.startsWith("{") ? parse(trimmed) : undefined
if (!isRecord(json)) return trimmed !== "" && STDOUT_IS_CONTEXT.has(event) ? { context: trimmed } : {}
const specific = isRecord(json.hookSpecificOutput) ? json.hookSpecificOutput : {}
const text = (value: unknown) => (typeof value === "string" && value.trim() !== "" ? value : undefined)
const additional = text(specific.additionalContext)
// `systemMessage` is a warning Claude Code shows the user. Many hooks put the explanation for a
// block there, so it stands in when no reason is given.
const message = text(json.systemMessage)
const blocked = (reason: string | undefined, fallback: string): HandlerResult => ({
...(additional === undefined ? {} : { context: additional }),
blocked: reason ?? message ?? fallback,
})
if (json.continue === false) return blocked(text(json.stopReason), "Stopped by hook")
if (json.decision === "block") return blocked(text(json.reason), "Blocked by hook")
if (specific.permissionDecision === "deny") return blocked(text(specific.permissionDecisionReason), "Denied by hook")
// opencode's tool hook has no way to ask the user, so a request for confirmation fails closed.
if (specific.permissionDecision === "ask")
return blocked(
`A hook requires user confirmation for this action, which is not supported here. ${text(specific.permissionDecisionReason) ?? message ?? ""}`.trim(),
"",
)
// A server plugin has no channel to the user, so a non-blocking warning goes to the model instead.
const context = [additional, message === undefined ? undefined : `Hook message: ${message}`].filter(
(item) => item !== undefined,
)
return {
...(context.length === 0 ? {} : { context: context.join("\n\n") }),
...(isRecord(specific.updatedInput) ? { updatedInput: specific.updatedInput } : {}),
}
}
function parse(text: string): unknown {
try {
return JSON.parse(text)
} catch {
return undefined
}
}
+55
View File
@@ -0,0 +1,55 @@
import { mkdirSync, writeFileSync } from "node:fs"
import path from "node:path"
import { isRecord } from "../../spec/frontmatter.ts"
import { toClaudeToolCall } from "../../translate/tools.ts"
/**
* Hooks receive `transcript_path` and some read it, for example to find the last assistant message.
* opencode keeps history in a database, so this writes the session out in the JSONL shape
* Claude Code uses: one line per content block, each carrying `message.role` and `message.content`.
*/
export function transcriptLines(messages: readonly unknown[], sessionID: string, cwd: string): string[] {
const lines: string[] = []
const line = (type: "user" | "assistant", content: unknown) =>
lines.push(JSON.stringify({ type, sessionId: sessionID, cwd, message: { role: type, content } }))
for (const message of messages) {
if (!isRecord(message)) continue
if (message.type === "user" || message.type === "synthetic") {
line("user", String(message.text ?? ""))
continue
}
if (message.type !== "assistant" || !Array.isArray(message.content)) continue
for (const part of message.content) {
if (!isRecord(part)) continue
if (part.type === "text") line("assistant", [{ type: "text", text: String(part.text ?? "") }])
if (part.type !== "tool") continue
const state = isRecord(part.state) ? part.state : {}
const call = toClaudeToolCall(String(part.name), state.input)
line("assistant", [{ type: "tool_use", id: part.id, name: call.name, input: call.input }])
if (state.status !== "completed" && state.status !== "error") continue
const result = state.status === "completed" ? (state.output ?? state.content ?? "") : (state.error ?? "")
line("user", [
{
type: "tool_result",
tool_use_id: part.id,
content: typeof result === "string" ? result : JSON.stringify(result),
...(state.status === "error" ? { is_error: true } : {}),
},
])
}
}
return lines
}
export function writeTranscript(
directory: string,
sessionID: string,
cwd: string,
messages: readonly unknown[],
): string {
mkdirSync(directory, { recursive: true })
const file = path.join(directory, `${sessionID.replace(/[^A-Za-z0-9_-]/g, "-")}.jsonl`)
const lines = transcriptLines(messages, sessionID, cwd)
writeFileSync(file, lines.length === 0 ? "" : lines.join("\n") + "\n")
return file
}
+162
View File
@@ -0,0 +1,162 @@
import type { Plugin } from "@opencode/plugin"
import { mkdirSync, watch } from "node:fs"
import path from "node:path"
import { asBoolean } from "../spec/frontmatter.ts"
import type { CommandDef } from "../spec/types.ts"
import { loadActivePlugins, type ActivePlugin } from "../store/installs.ts"
import { storePaths } from "../store/paths.ts"
import { renderCommand } from "../translate/commands.ts"
import { mcpServerName, toAgent, toMcp, toSkill, varContext } from "../translate/components.ts"
import { debug } from "./debug.ts"
import { bindHooks } from "./hooks/bind.ts"
type Context = Plugin.Context
type Editor<Domain extends "skill" | "command" | "agent" | "mcp"> = Parameters<
Parameters<Context[Domain]["transform"]>[0]
>[0]
/**
* opencode's schema types are branded (`Skill.ID`, `AbsolutePath`, ...). The translate layer
* produces the same shapes as plain values, so they are cast once here at the boundary.
*/
const branded = <Target>(value: unknown) => value as Target
const plugin: Plugin.Plugin = {
id: "claude-4-opencode",
setup: async (ctx) => {
const paths = storePaths()
const projectDir = ctx.location.directory
const pluginDirs = [
...stringList(ctx.options.pluginDirs),
...(process.env.C4O_PLUGIN_DIRS?.split(path.delimiter).filter(Boolean) ?? []),
].map((directory) => path.resolve(projectDir, directory))
// `disable` turns off translation of whole component kinds, e.g. ["mcp", "hooks"].
const disabled = new Set([
...stringList(ctx.options.disable),
...(process.env.C4O_DISABLE?.split(",").filter(Boolean) ?? []),
])
const load = () =>
loadActivePlugins(paths, { pluginDirs }).map((active) => {
// Plugins expect ${CLAUDE_PLUGIN_DATA} to exist by the time they use it.
mkdirSync(active.dataDir, { recursive: true })
return withoutDisabled(active)
})
const withoutDisabled = (active: ActivePlugin): ActivePlugin => ({
...active,
plugin: {
...active.plugin,
...(disabled.has("skills") ? { skills: [] } : {}),
...(disabled.has("commands") ? { commands: [] } : {}),
...(disabled.has("agents") ? { agents: [] } : {}),
...(disabled.has("mcp") ? { mcpServers: {} } : {}),
...(disabled.has("hooks") ? { hooks: {} } : {}),
...(disabled.has("bin") ? { binDir: undefined } : {}),
},
})
const state = { plugins: load() }
debug("setup", { store: paths.root, projectDir, plugins: state.plugins.map((item) => item.id) })
/** PATH with each enabled plugin's bin/ in front, as Claude Code arranges for the Bash tool. */
const pathWithBins = (current: string | undefined) => {
const bins = state.plugins.flatMap((active) => (active.plugin.binDir === undefined ? [] : [active.plugin.binDir]))
return bins.length === 0 ? current : [...bins, current ?? ""].join(path.delimiter)
}
await ctx.skill.transform((editor) => {
for (const active of state.plugins) {
const context = varContext(active, projectDir)
for (const skill of active.plugin.skills)
editor.add(branded<Parameters<Editor<"skill">["add"]>[0]>(toSkill(skill, context)))
}
})
await ctx.command.transform((editor) => {
for (const active of state.plugins) {
const ids = new Set(active.plugin.commands.map((command) => command.id))
// Claude Code also lets the user run a skill as `/plugin:skill args`.
const skills = active.plugin.skills
.filter((skill) => !ids.has(skill.id) && asBoolean(skill.frontmatter["user-invocable"]) !== false)
.map((skill): CommandDef => ({
id: skill.id,
...(skill.description === undefined ? {} : { description: skill.description }),
body: `Base directory for this skill: ${path.dirname(skill.path)}\n\n${skill.body}`,
frontmatter: skill.frontmatter,
}))
for (const command of [...active.plugin.commands, ...skills]) {
editor.add({
name: command.id,
...(command.description === undefined ? {} : { description: command.description }),
execute: async (input) => {
const text = await renderCommand(command, input.prompt.text, varContext(active, projectDir), {
cwd: projectDir,
// Scripts run from a command body identify their session this way in Claude Code.
env: { ...process.env, PATH: pathWithBins(process.env.PATH), CLAUDE_CODE_SESSION_ID: input.sessionID },
})
await ctx.session.prompt({ ...input.prompt, sessionID: input.sessionID, text, delivery: input.delivery })
},
})
}
}
})
await ctx.agent.transform((editor) => {
for (const active of state.plugins) {
const context = varContext(active, projectDir)
for (const agent of active.plugin.agents) {
const translated = toAgent(agent, context)
editor.update(translated.id, (item) => {
if (translated.description !== undefined) item.description = translated.description
item.system = translated.system
item.mode = translated.mode
if (translated.color !== undefined) item.color = translated.color
item.permissions.push(...translated.permissions)
})
}
}
})
await ctx.mcp.transform((editor) => {
for (const active of state.plugins) {
const context = varContext(active, projectDir)
for (const [name, server] of Object.entries(active.plugin.mcpServers))
editor.set(
mcpServerName(active.plugin.name, name),
branded<Parameters<Editor<"mcp">["set"]>[1]>(toMcp(server, context, process.env)),
)
}
})
const shell = await ctx.shell.hook("create.before", (event) => {
event.env.PATH = pathWithBins(event.env.PATH ?? process.env.PATH)
})
const unbindHooks = await bindHooks(ctx, state, { projectDir, transcripts: path.join(paths.root, "transcripts") })
// `c4o install`, `enable` and friends rewrite the store's state files. Pick the change up
// without restarting opencode.
let timer: ReturnType<typeof setTimeout> | undefined
const reload = async () => {
state.plugins = load()
debug("reload", { plugins: state.plugins.map((item) => item.id) })
await Promise.all([ctx.skill.reload(), ctx.command.reload(), ctx.agent.reload(), ctx.mcp.reload()])
}
mkdirSync(paths.root, { recursive: true })
const watcher = watch(paths.root, () => {
clearTimeout(timer)
timer = setTimeout(() => void reload().catch((error) => debug("reload failed", { error: String(error) })), 150)
})
return () => {
clearTimeout(timer)
watcher.close()
unbindHooks()
void shell.dispose()
}
},
}
export default plugin
function stringList(value: unknown): string[] {
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []
}
+1
View File
@@ -0,0 +1 @@
export { default } from "./runtime/server.ts"
+80
View File
@@ -0,0 +1,80 @@
import { parse } from "yaml"
export interface Markdown {
data: Record<string, unknown>
body: string
}
const FENCE = /^---[ \t]*\r?\n(?:([\s\S]*?)\r?\n)?---[ \t]*(?:\r?\n|$)/
export function parseMarkdown(text: string): Markdown {
const source = text.replace(/^/, "")
const match = FENCE.exec(source)
if (!match) return { data: {}, body: source }
return { data: parseFrontmatter(match[1] ?? ""), body: source.slice(match[0].length) }
}
function parseFrontmatter(source: string): Record<string, unknown> {
try {
const value: unknown = parse(source)
if (isRecord(value)) return value
if (value === null || value === undefined) return {}
} catch {
// Plugin authors routinely write values YAML rejects, such as an unquoted
// description containing ": ". Claude Code still loads those files.
}
return parseLoose(source)
}
function parseLoose(source: string): Record<string, unknown> {
const data: Record<string, unknown> = {}
for (const line of source.split(/\r?\n/)) {
const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line)
if (!match) continue
const raw = match[2]!.trim()
const quoted = /^(["'])(.*)\1$/.exec(raw)
data[match[1]!] = quoted ? quoted[2] : raw
}
return data
}
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
export function asString(value: unknown): string | undefined {
if (typeof value === "string") return value.trim() === "" ? undefined : value.trim()
if (typeof value === "number" || typeof value === "boolean") return String(value)
return undefined
}
/** Accepts a YAML list or the comma-separated string form that `tools:` and `allowed-tools:` use. */
export function asStringList(value: unknown): string[] | undefined {
if (Array.isArray(value)) return value.map(asString).filter((item): item is string => item !== undefined)
if (typeof value !== "string") return undefined
const items: string[] = []
let depth = 0
let current = ""
for (const char of value) {
if (char === "(") depth++
if (char === ")") depth = Math.max(0, depth - 1)
// Commas inside `Bash(git add:*, git status:*)` belong to the rule, not the list.
if (char === "," && depth === 0) {
items.push(current)
current = ""
continue
}
current += char
}
items.push(current)
return items.map((item) => item.trim()).filter((item) => item !== "")
}
export function asBoolean(value: unknown): boolean | undefined {
if (typeof value === "boolean") return value
if (typeof value !== "string") return undefined
const normalized = value.trim().toLowerCase()
if (["true", "yes", "on", "1"].includes(normalized)) return true
if (["false", "no", "off", "0"].includes(normalized)) return false
return undefined
}
+116
View File
@@ -0,0 +1,116 @@
import { existsSync, readFileSync, statSync } from "node:fs"
import path from "node:path"
import { asString, isRecord } from "./frontmatter.ts"
import type { Issue, Marketplace, MarketplaceEntry, PluginSource } from "./types.ts"
export interface MarketplaceRead {
marketplace?: Marketplace
/** Directory that relative plugin sources resolve against. */
root: string
issues: Issue[]
}
/** Reads a marketplace from its repository root, or from a direct path to marketplace.json. */
export function readMarketplace(target: string): MarketplaceRead {
const resolved = path.resolve(target)
const isFile = existsSync(resolved) && statSync(resolved).isFile()
const file = isFile ? resolved : path.join(resolved, ".claude-plugin", "marketplace.json")
// marketplace.json lives in .claude-plugin/, so its sources resolve two levels up.
const root = isFile ? path.resolve(path.dirname(resolved), "..") : resolved
const issues: Issue[] = []
if (!existsSync(file)) {
issues.push({ level: "error", message: "No .claude-plugin/marketplace.json found", path: file })
return { root, issues }
}
const value = parseJson(file, issues)
if (!isRecord(value)) return { root, issues }
const name = asString(value.name)
if (name === undefined) {
issues.push({ level: "error", message: "Marketplace has no name", path: file })
return { root, issues }
}
const plugins = (Array.isArray(value.plugins) ? value.plugins : []).flatMap((item, index): MarketplaceEntry[] => {
if (!isRecord(item) || asString(item.name) === undefined || item.source === undefined) {
issues.push({ level: "error", message: `plugins[${index}] needs a name and a source`, path: file })
return []
}
return [item as MarketplaceEntry]
})
return {
marketplace: {
name,
...(isRecord(value.owner) ? { owner: value.owner } : {}),
...(isRecord(value.metadata) ? { metadata: value.metadata } : {}),
plugins,
},
root,
issues,
}
}
function parseJson(file: string, issues: Issue[]): unknown {
try {
return JSON.parse(readFileSync(file, "utf8"))
} catch (error) {
issues.push({ level: "error", message: `Invalid JSON: ${(error as Error).message}`, path: file })
return undefined
}
}
export type ResolvedSource =
| { kind: "local"; directory: string; isMarketplaceRoot: boolean }
| { kind: "git"; url: string; ref?: string; sha?: string; subdir?: string }
| { kind: "npm"; package: string; version?: string; registry?: string }
| { kind: "unsupported"; reason: string }
/** Turns an entry's `source` into something the installer can fetch. */
export function resolveSource(source: PluginSource, marketplace: Marketplace, root: string): ResolvedSource {
if (typeof source === "string") {
// Paths that do not start with ./ are relative to metadata.pluginRoot.
const base =
source.startsWith("./") || source === "." ? root : path.resolve(root, marketplace.metadata?.pluginRoot ?? ".")
const directory = path.resolve(base, source)
const offset = path.relative(root, directory)
if (offset.startsWith("..") || path.isAbsolute(offset))
return { kind: "unsupported", reason: `source path escapes the marketplace: ${source}` }
return { kind: "local", directory, isMarketplaceRoot: directory === root }
}
const ref = asString(source.ref)
const sha = asString(source.sha)
const pin = { ...(ref === undefined ? {} : { ref }), ...(sha === undefined ? {} : { sha }) }
if (source.source === "github") {
const repo = asString(source.repo)
if (repo === undefined) return { kind: "unsupported", reason: "github source has no repo" }
return { kind: "git", url: `https://github.com/${repo}.git`, ...pin }
}
if (source.source === "url" || source.source === "git") {
const url = asString(source.url)
if (url === undefined) return { kind: "unsupported", reason: "git source has no url" }
return { kind: "git", url, ...pin }
}
if (source.source === "git-subdir") {
const url = asString(source.url)
const subdir = asString(source.path)
if (url === undefined || subdir === undefined)
return { kind: "unsupported", reason: "git-subdir source needs url and path" }
return { kind: "git", url: gitUrl(url), subdir, ...pin }
}
if (source.source === "npm") {
const name = asString(source.package)
if (name === undefined) return { kind: "unsupported", reason: "npm source has no package" }
const version = asString(source.version)
const registry = asString(source.registry)
return {
kind: "npm",
package: name,
...(version === undefined ? {} : { version }),
...(registry === undefined ? {} : { registry }),
}
}
return { kind: "unsupported", reason: `source type "${String(source.source)}" is not supported` }
}
/** git-subdir accepts `owner/repo` shorthand as well as full URLs. */
function gitUrl(value: string) {
return /^[\w.-]+\/[\w.-]+$/.test(value) ? `https://github.com/${value}.git` : value
}
+527
View File
@@ -0,0 +1,527 @@
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"
import path from "node:path"
import { asBoolean, asString, asStringList, isRecord, parseMarkdown } from "./frontmatter.ts"
import type {
AgentDef,
CommandDef,
HookConfig,
HookHandler,
HookMatcher,
Issue,
LoadedPlugin,
LspServerDef,
MarketplaceEntry,
McpServerDef,
PluginManifest,
SkillDef,
Unsupported,
} from "./types.ts"
export interface LoadOptions {
root: string
/** The marketplace entry this plugin was installed from, if any. It can supply or extend the manifest. */
entry?: MarketplaceEntry
/** True when the entry's source is the marketplace root. Entry `skills` then replace the default scan. */
entryIsMarketplaceRoot?: boolean
/** Used when neither the manifest nor the entry names the plugin. */
name?: string
}
const COMPONENT_KEYS = ["commands", "agents", "skills", "hooks", "outputStyles", "themes"] as const
/**
* Reads a Claude Code plugin directory into a normalized description.
* Follows the standard layout and manifest rules in the plugin manifest reference.
*/
export function loadPlugin(options: LoadOptions): LoadedPlugin {
const issues: Issue[] = []
const root = path.resolve(options.root)
const own = readManifest(root, issues)
const entry = options.entry
const name = asString(own?.name) ?? asString(entry?.name) ?? options.name ?? path.basename(root)
// With no plugin.json the entry is the manifest. With one, a strict entry may add
// components, and a non-strict entry that declares any is a conflict.
const conflict =
own !== undefined && entry?.strict === false && COMPONENT_KEYS.some((key) => entry[key] !== undefined)
if (conflict) issues.push({ level: "error", message: `Plugin ${name} has conflicting manifests` })
const extra: PluginManifest | undefined = conflict ? undefined : entry
const manifest: PluginManifest = own ?? entry ?? {}
// Only component fields are appended to an existing manifest. Entry mcpServers and lspServers
// apply when the entry is the manifest.
const appendable = new Set<keyof PluginManifest>(COMPONENT_KEYS)
const declaredOrEntry = (key: keyof PluginManifest) =>
(own === undefined ? [entry?.[key]] : [own[key], appendable.has(key) ? extra?.[key] : undefined]).filter(
(value) => value !== undefined,
)
const context: Context = { root, name, issues }
const skills = new Map<string, SkillDef>()
const commands = new Map<string, CommandDef>()
const exclusiveSkills = own === undefined && options.entryIsMarketplaceRoot === true && entry?.skills !== undefined
const skillPaths = declaredOrEntry("skills").flatMap((value) => pathList(value, context, "skills"))
const defaultSkills = path.join(root, "skills")
if (!exclusiveSkills && isDirectory(defaultSkills)) loadSkillsFrom(defaultSkills, context, skills)
for (const directory of skillPaths) loadSkillsFrom(directory, context, skills)
if (skills.size === 0 && skillPaths.length === 0 && !isDirectory(defaultSkills)) {
const rootSkill = path.join(root, "SKILL.md")
if (existsSync(rootSkill)) addSkill(rootSkill, `${name}:${name}`, context, skills)
}
const commandDecls = declaredOrEntry("commands")
if (commandDecls.length === 0) {
const directory = path.join(root, "commands")
if (isDirectory(directory)) loadCommandsFrom(directory, directory, context, commands, skills)
}
for (const value of commandDecls) loadDeclaredCommands(value, context, commands, skills)
const agents = new Map<string, AgentDef>()
const agentDecls = declaredOrEntry("agents")
if (agentDecls.length === 0) {
const directory = path.join(root, "agents")
if (isDirectory(directory)) loadAgentsFrom(directory, directory, context, agents)
}
for (const value of agentDecls) {
for (const target of pathList(value, context, "agents")) {
if (isDirectory(target)) loadAgentsFrom(target, target, context, agents)
else addAgent(target, [], context, agents)
}
}
const hooks: HookConfig = {}
const defaultHooks = path.join(root, "hooks", "hooks.json")
if (existsSync(defaultHooks)) mergeHooks(hooks, readHooksFile(defaultHooks, context))
for (const value of own === undefined ? declaredOrEntry("hooks") : [own.hooks].filter((item) => item !== undefined))
mergeHooks(hooks, readDeclaredHooks(value, context))
// A strict entry's matchers replace the manifest's for the same event.
if (own !== undefined && extra?.hooks !== undefined) Object.assign(hooks, readDeclaredHooks(extra.hooks, context))
const mcpServers: Record<string, McpServerDef> = {}
const unsupported: Unsupported[] = []
const defaultMcp = path.join(root, ".mcp.json")
if (existsSync(defaultMcp)) Object.assign(mcpServers, readMcpFile(defaultMcp, context))
for (const value of declaredOrEntry("mcpServers"))
Object.assign(mcpServers, readDeclaredMcp(value, context, unsupported))
const lspServers: Record<string, LspServerDef> = {}
const defaultLsp = path.join(root, ".lsp.json")
if (existsSync(defaultLsp)) Object.assign(lspServers, readLspMap(readJson(defaultLsp, context), context))
for (const value of declaredOrEntry("lspServers")) {
for (const item of Array.isArray(value) ? value : [value]) {
if (typeof item === "string") {
const file = inside(item, context, "lspServers")
if (file) Object.assign(lspServers, readLspMap(readJson(file, context), context))
continue
}
Object.assign(lspServers, readLspMap(item, context))
}
}
collectUnsupported(root, manifest, unsupported)
const binDir = path.join(root, "bin")
return {
name,
root,
version: asString(own?.version) ?? asString(entry?.version),
manifest,
skills: [...skills.values()],
commands: [...commands.values()],
agents: [...agents.values()],
hooks,
mcpServers,
lspServers,
...(isDirectory(binDir) ? { binDir } : {}),
unsupported,
issues,
}
}
interface Context {
root: string
name: string
issues: Issue[]
}
export function readManifest(root: string, issues: Issue[]): PluginManifest | undefined {
const file = path.join(root, ".claude-plugin", "plugin.json")
if (!existsSync(file)) return undefined
const value = readJson(file, { root, name: path.basename(root), issues })
if (isRecord(value)) return value
issues.push({ level: "error", message: "plugin.json must be a JSON object", path: file })
return undefined
}
function readJson(file: string, context: Context): unknown {
try {
return JSON.parse(readFileSync(file, "utf8"))
} catch (error) {
context.issues.push({ level: "error", message: `Invalid JSON: ${(error as Error).message}`, path: file })
return undefined
}
}
function isDirectory(target: string) {
return existsSync(target) && statSync(target).isDirectory()
}
/** Resolves a manifest path, enforcing that it stays inside the plugin root and exists. */
function inside(relative: string, context: Context, component: string): string | undefined {
const resolved = path.resolve(context.root, relative)
const offset = path.relative(context.root, resolved)
if (offset.startsWith("..") || path.isAbsolute(offset)) {
context.issues.push({ level: "error", message: `${component} path escapes plugin directory: ${relative}` })
return undefined
}
if (!existsSync(resolved)) {
context.issues.push({ level: "error", message: `${component} path not found: ${relative}` })
return undefined
}
return resolved
}
function pathList(value: unknown, context: Context, component: string): string[] {
const items = Array.isArray(value) ? value : [value]
return items.flatMap((item) => {
if (typeof item !== "string") {
context.issues.push({ level: "error", message: `${component} entries must be paths` })
return []
}
const resolved = inside(item, context, component)
return resolved ? [resolved] : []
})
}
function markdownFiles(directory: string): string[] {
return readdirSync(directory, { withFileTypes: true })
.toSorted((a, b) => a.name.localeCompare(b.name))
.flatMap((entry) => {
const target = path.join(directory, entry.name)
if (isDirectory(target)) return markdownFiles(target)
return entry.name.toLowerCase().endsWith(".md") ? [target] : []
})
}
function loadSkillsFrom(directory: string, context: Context, skills: Map<string, SkillDef>) {
const direct = path.join(directory, "SKILL.md")
if (existsSync(direct)) {
const leaf = directory === context.root ? context.name : path.basename(directory)
addSkill(direct, `${context.name}:${leaf}`, context, skills)
return
}
for (const entry of readdirSync(directory, { withFileTypes: true }).toSorted((a, b) =>
a.name.localeCompare(b.name),
)) {
const file = path.join(directory, entry.name, "SKILL.md")
if (existsSync(file)) addSkill(file, `${context.name}:${entry.name}`, context, skills)
}
}
function addSkill(file: string, id: string, context: Context, skills: Map<string, SkillDef>) {
if (skills.has(id)) return
const markdown = parseMarkdown(readFileSync(file, "utf8"))
const description = asString(markdown.data.description) ?? firstParagraph(markdown.body)
skills.set(id, {
id,
displayName: asString(markdown.data.name) ?? id.slice(id.lastIndexOf(":") + 1),
...(description === undefined ? {} : { description }),
path: file,
body: markdown.body,
frontmatter: markdown.data,
disableModelInvocation: asBoolean(markdown.data["disable-model-invocation"]) === true,
})
if (description === undefined)
context.issues.push({ level: "warning", message: `Skill ${id} has no description`, path: file })
}
function firstParagraph(body: string): string | undefined {
const line = body.split(/\r?\n/).find((item) => item.trim() !== "" && !item.trim().startsWith("#"))
return line?.trim().slice(0, 250)
}
function loadCommandsFrom(
directory: string,
base: string,
context: Context,
commands: Map<string, CommandDef>,
skills: Map<string, SkillDef>,
) {
// A directory holding SKILL.md under commands/ is a skill named by that directory.
if (existsSync(path.join(directory, "SKILL.md")) && directory !== base) {
const segments = path.relative(base, directory).split(path.sep)
addSkill(path.join(directory, "SKILL.md"), [context.name, ...segments].join(":"), context, skills)
return
}
for (const entry of readdirSync(directory, { withFileTypes: true }).toSorted((a, b) =>
a.name.localeCompare(b.name),
)) {
const target = path.join(directory, entry.name)
if (isDirectory(target)) {
loadCommandsFrom(target, base, context, commands, skills)
continue
}
if (!entry.name.toLowerCase().endsWith(".md")) continue
const segments = path.relative(base, target).replace(/\.md$/i, "").split(path.sep)
addCommand([context.name, ...segments].join(":"), readFileSync(target, "utf8"), target, {}, commands)
}
}
function loadDeclaredCommands(
value: unknown,
context: Context,
commands: Map<string, CommandDef>,
skills: Map<string, SkillDef>,
) {
if (isRecord(value)) {
for (const [key, spec] of Object.entries(value)) {
if (!isRecord(spec)) continue
const id = `${context.name}:${key}`
const source = asString(spec.source)
const content = typeof spec.content === "string" ? spec.content : undefined
if ((source === undefined) === (content === undefined)) {
context.issues.push({ level: "error", message: `Command ${id} must set exactly one of source or content` })
continue
}
const file = source === undefined ? undefined : inside(source, context, "commands")
if (source !== undefined && file === undefined) continue
addCommand(id, file === undefined ? content! : readFileSync(file, "utf8"), file, spec, commands)
}
return
}
for (const target of pathList(value, context, "commands")) {
if (isDirectory(target)) {
loadCommandsFrom(target, target, context, commands, skills)
continue
}
addCommand(
`${context.name}:${path.basename(target).replace(/\.md$/i, "")}`,
readFileSync(target, "utf8"),
target,
{},
commands,
)
}
}
function addCommand(
id: string,
text: string,
file: string | undefined,
overrides: Record<string, unknown>,
commands: Map<string, CommandDef>,
) {
if (commands.has(id)) return
const markdown = parseMarkdown(text)
const description = asString(overrides.description) ?? asString(markdown.data.description)
const argumentHint = asString(overrides.argumentHint) ?? hintText(markdown.data["argument-hint"])
const model = asString(overrides.model) ?? asString(markdown.data.model)
const allowedTools = asStringList(overrides.allowedTools) ?? asStringList(markdown.data["allowed-tools"])
commands.set(id, {
id,
...(description === undefined ? {} : { description }),
...(argumentHint === undefined ? {} : { argumentHint }),
...(model === undefined ? {} : { model }),
...(allowedTools === undefined ? {} : { allowedTools }),
body: markdown.body,
...(file === undefined ? {} : { path: file }),
frontmatter: markdown.data,
})
}
/** YAML reads the common `argument-hint: [file]` as a list; put the brackets back. */
function hintText(value: unknown): string | undefined {
return Array.isArray(value) ? `[${value.map(String).join(", ")}]` : asString(value)
}
function loadAgentsFrom(directory: string, base: string, context: Context, agents: Map<string, AgentDef>) {
for (const file of markdownFiles(directory)) {
const segments = path
.relative(base, path.dirname(file))
.split(path.sep)
.filter((item) => item !== "")
addAgent(file, segments, context, agents)
}
}
function addAgent(file: string, segments: string[], context: Context, agents: Map<string, AgentDef>) {
if (!file.toLowerCase().endsWith(".md")) {
context.issues.push({ level: "error", message: "Agent files must be Markdown", path: file })
return
}
const markdown = parseMarkdown(readFileSync(file, "utf8"))
const leaf = asString(markdown.data.name) ?? path.basename(file).replace(/\.md$/i, "")
const id = [context.name, ...segments, leaf].join(":")
if (agents.has(id)) return
const description = asString(markdown.data.description)
const model = asString(markdown.data.model)
const tools = asStringList(markdown.data.tools)
const disallowedTools = asStringList(markdown.data.disallowedTools)
const color = asString(markdown.data.color)
agents.set(id, {
id,
...(description === undefined ? {} : { description }),
...(model === undefined ? {} : { model }),
...(tools === undefined ? {} : { tools }),
...(disallowedTools === undefined ? {} : { disallowedTools }),
...(color === undefined ? {} : { color }),
body: markdown.body,
path: file,
frontmatter: markdown.data,
})
}
function mergeHooks(target: HookConfig, source: HookConfig) {
for (const [event, matchers] of Object.entries(source)) target[event] = [...(target[event] ?? []), ...matchers]
}
/** A hooks file wraps the event map in a top-level `hooks` key. */
function readHooksFile(file: string, context: Context): HookConfig {
const value = readJson(file, context)
if (!isRecord(value) || !isRecord(value.hooks)) {
if (value !== undefined)
context.issues.push({
level: "error",
message: 'Hooks file must wrap its event map in a "hooks" key',
path: file,
})
return {}
}
return normalizeHooks(value.hooks, context, file)
}
function readDeclaredHooks(value: unknown, context: Context): HookConfig {
const result: HookConfig = {}
for (const item of Array.isArray(value) ? value : [value]) {
if (typeof item === "string") {
const file = inside(item, context, "hooks")
if (file) mergeHooks(result, readHooksFile(file, context))
continue
}
if (isRecord(item)) mergeHooks(result, normalizeHooks(isRecord(item.hooks) ? item.hooks : item, context))
}
return result
}
function normalizeHooks(events: Record<string, unknown>, context: Context, file?: string): HookConfig {
const result: HookConfig = {}
for (const [event, value] of Object.entries(events)) {
if (!Array.isArray(value)) continue
const matchers = value.flatMap((item): HookMatcher[] => {
if (!isRecord(item) || !Array.isArray(item.hooks)) return []
const hooks = item.hooks.filter((hook): hook is HookHandler => isRecord(hook) && typeof hook.type === "string")
if (hooks.length !== item.hooks.length)
context.issues.push({
level: "warning",
message: `${event} hook entries without a type were skipped`,
path: file,
})
const matcher = asString(item.matcher)
return hooks.length === 0 ? [] : [{ ...(matcher === undefined ? {} : { matcher }), hooks }]
})
if (matchers.length > 0) result[event] = matchers
}
return result
}
/** `.mcp.json` appears both wrapped in `mcpServers` and as a bare name-to-config map. */
function readMcpFile(file: string, context: Context): Record<string, McpServerDef> {
const value = readJson(file, context)
if (!isRecord(value)) return {}
return readMcpMap(isRecord(value.mcpServers) ? value.mcpServers : value, context)
}
function readDeclaredMcp(value: unknown, context: Context, unsupported: Unsupported[]): Record<string, McpServerDef> {
const result: Record<string, McpServerDef> = {}
for (const item of Array.isArray(value) ? value : [value]) {
if (typeof item === "string") {
if (/\.(mcpb|dxt)$/i.test(item)) {
unsupported.push({ kind: "mcp-bundle", detail: item })
continue
}
const file = inside(item, context, "mcpServers")
if (file) Object.assign(result, readMcpFile(file, context))
continue
}
if (isRecord(item)) Object.assign(result, readMcpMap(item, context))
}
return result
}
function readMcpMap(map: Record<string, unknown>, context: Context): Record<string, McpServerDef> {
const result: Record<string, McpServerDef> = {}
for (const [name, config] of Object.entries(map)) {
if (!isRecord(config)) continue
const command = asString(config.command)
const url = asString(config.url)
if (command !== undefined) {
result[name] = { type: "stdio", command, args: stringArray(config.args), env: stringRecord(config.env) }
continue
}
if (url !== undefined) {
const type = config.type === "sse" || config.type === "ws" ? config.type : "http"
result[name] = { type, url, headers: stringRecord(config.headers) }
continue
}
// Marketplaces ship connector placeholders with an empty url for the user to fill in.
// Claude Code drops those entries; the rest of the plugin still loads.
const placeholder = typeof config.url === "string" || typeof config.command === "string"
context.issues.push({
level: placeholder ? "warning" : "error",
message: placeholder
? `MCP server ${name} has an empty ${typeof config.url === "string" ? "url" : "command"} and was skipped`
: `MCP server ${name} has neither command nor url`,
})
}
return result
}
function readLspMap(value: unknown, context: Context): Record<string, LspServerDef> {
if (!isRecord(value)) return {}
const result: Record<string, LspServerDef> = {}
for (const [name, config] of Object.entries(value)) {
if (!isRecord(config)) continue
const command = asString(config.command)
if (command === undefined || !isRecord(config.extensionToLanguage)) {
context.issues.push({ level: "error", message: `LSP server ${name} needs command and extensionToLanguage` })
continue
}
result[name] = {
command,
args: stringArray(config.args),
extensionToLanguage: stringRecord(config.extensionToLanguage),
env: stringRecord(config.env),
...(isRecord(config.initializationOptions) ? { initializationOptions: config.initializationOptions } : {}),
}
}
return result
}
function stringArray(value: unknown): string[] {
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []
}
function stringRecord(value: unknown): Record<string, string> {
if (!isRecord(value)) return {}
return Object.fromEntries(
Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
)
}
function collectUnsupported(root: string, manifest: PluginManifest, unsupported: Unsupported[]) {
const experimental = isRecord(manifest.experimental) ? manifest.experimental : {}
const present = (key: string, location: string) =>
manifest[key] !== undefined || experimental[key] !== undefined || existsSync(path.join(root, location))
const kinds: [kind: string, key: string, location: string][] = [
["output-styles", "outputStyles", "output-styles"],
["monitors", "monitors", path.join("monitors", "monitors.json")],
["themes", "themes", "themes"],
["workflows", "workflows", "workflows"],
["settings", "settings", "settings.json"],
]
for (const [kind, key, location] of kinds)
if (present(key, location)) unsupported.push({ kind, detail: `${kind} have no opencode equivalent` })
if (manifest.channels !== undefined)
unsupported.push({ kind: "channels", detail: "message channels are not translated" })
if (manifest.types !== undefined) unsupported.push({ kind: "mods", detail: "Claude Code mods are not translated" })
}
+162
View File
@@ -0,0 +1,162 @@
/** A problem found while reading a plugin or marketplace. Readers collect these instead of throwing. */
export interface Issue {
level: "error" | "warning"
message: string
path?: string
}
export interface SkillDef {
/** Namespaced as Claude Code does: `<plugin>:<skill directory name>`. */
id: string
/** Frontmatter `name` when present, otherwise the directory name. Display only. */
displayName: string
description?: string
/** Absolute path of SKILL.md. Relative references in the body resolve against its directory. */
path: string
body: string
frontmatter: Record<string, unknown>
disableModelInvocation: boolean
}
export interface CommandDef {
/** `<plugin>:<name>`, with subdirectories as further `:` segments. */
id: string
description?: string
argumentHint?: string
model?: string
allowedTools?: string[]
body: string
/** Absolute source file. Absent for inline `content` commands. */
path?: string
frontmatter: Record<string, unknown>
}
export interface AgentDef {
id: string
description?: string
model?: string
tools?: string[]
disallowedTools?: string[]
color?: string
body: string
path: string
frontmatter: Record<string, unknown>
}
export interface CommandHook {
type: "command"
command: string
args?: string[]
timeout?: number
shell?: string
async?: boolean
if?: string
}
export interface HttpHook {
type: "http"
url: string
headers?: Record<string, string>
timeout?: number
async?: boolean
if?: string
}
/** Hook types the first release reads but does not run: `prompt`, `agent`, `mcp_tool`. */
export interface OtherHook {
type: string
[key: string]: unknown
}
export type HookHandler = CommandHook | HttpHook | OtherHook
export interface HookMatcher {
matcher?: string
hooks: HookHandler[]
}
export type HookConfig = Record<string, HookMatcher[]>
export type McpServerDef =
| { type: "stdio"; command: string; args: string[]; env: Record<string, string> }
| { type: "http" | "sse" | "ws"; url: string; headers: Record<string, string> }
export interface LspServerDef {
command: string
args: string[]
extensionToLanguage: Record<string, string>
env: Record<string, string>
initializationOptions?: Record<string, unknown>
}
export interface UserConfigOption {
type?: string
title?: string
description?: string
required?: boolean
default?: unknown
sensitive?: boolean
}
export interface PluginManifest {
name?: string
displayName?: string
version?: string
description?: string
defaultEnabled?: boolean
dependencies?: unknown
userConfig?: Record<string, UserConfigOption>
commands?: unknown
agents?: unknown
skills?: unknown
hooks?: unknown
mcpServers?: unknown
lspServers?: unknown
outputStyles?: unknown
[key: string]: unknown
}
/** A component kind that exists in the plugin but has no opencode translation. */
export interface Unsupported {
kind: string
detail: string
}
export interface LoadedPlugin {
name: string
root: string
version?: string
manifest: PluginManifest
skills: SkillDef[]
commands: CommandDef[]
agents: AgentDef[]
hooks: HookConfig
mcpServers: Record<string, McpServerDef>
lspServers: Record<string, LspServerDef>
/** Absolute path of `bin/` when the plugin ships one. */
binDir?: string
unsupported: Unsupported[]
issues: Issue[]
}
/**
* A relative path inside the marketplace, or an object whose `source` names the kind:
* `github` (repo), `url` (git url), `git-subdir` (url + path) or `npm` (package).
* The fields are untrusted JSON, so `resolveSource` validates them.
*/
export type PluginSource = string | { source: string; [key: string]: unknown }
/** One `plugins[]` entry of marketplace.json. Carries the same component keys a manifest can. */
export interface MarketplaceEntry extends PluginManifest {
name: string
source: PluginSource
strict?: boolean
category?: string
}
export interface Marketplace {
name: string
owner?: { name?: string; email?: string; url?: string }
metadata?: { pluginRoot?: string; version?: string; description?: string }
plugins: MarketplaceEntry[]
}
+40
View File
@@ -0,0 +1,40 @@
import { execFile } from "node:child_process"
import { mkdirSync } from "node:fs"
export function git(args: string[], cwd?: string): Promise<string> {
return new Promise((resolve, reject) => {
execFile(
"git",
args,
// Never block on a credential prompt: a private or missing repository must fail, not hang.
{ cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, maxBuffer: 64 * 1024 * 1024 },
(error, stdout, stderr) => {
if (error) reject(new Error(`git ${args.join(" ")} failed: ${stderr.trim() || error.message}`))
else resolve(stdout.trim())
},
)
})
}
export interface FetchOptions {
url: string
destination: string
ref?: string
sha?: string
/** Check out only this subdirectory. */
subdir?: string
}
/** Shallow-fetches one commit into `destination` and returns its SHA. A `sha` wins over a `ref`. */
export async function fetchRepo(options: FetchOptions): Promise<string> {
mkdirSync(options.destination, { recursive: true })
const cwd = options.destination
await git(["init", "-q"], cwd)
await git(["remote", "add", "origin", options.url], cwd)
if (options.subdir)
await git(["sparse-checkout", "set", "--no-cone", `/${options.subdir.replace(/^\/+|\/+$/g, "")}/`], cwd)
const target = options.sha ?? options.ref ?? "HEAD"
await git(["fetch", "-q", "--depth", "1", ...(options.subdir ? ["--filter=blob:none"] : []), "origin", target], cwd)
await git(["checkout", "-q", "FETCH_HEAD"], cwd)
return git(["rev-parse", "HEAD"], cwd)
}
+212
View File
@@ -0,0 +1,212 @@
import { cpSync, existsSync, mkdirSync, mkdtempSync, renameSync, rmSync } from "node:fs"
import path from "node:path"
import { resolveSource } from "../spec/marketplace.ts"
import { loadPlugin, readManifest } from "../spec/plugin.ts"
import { asString } from "../spec/frontmatter.ts"
import type { LoadedPlugin, MarketplaceEntry } from "../spec/types.ts"
import { fetchRepo } from "./git.ts"
import { isValidIdPart, loadKnownMarketplace } from "./marketplaces.ts"
import { dataDirectory, type StorePaths } from "./paths.ts"
import {
readInstalled,
readKnownMarketplaces,
readSettings,
writeInstalled,
writeSettings,
type InstalledRecord,
} from "./state.ts"
export interface PluginRef {
plugin: string
marketplace: string
}
export function formatId(ref: PluginRef) {
return `${ref.plugin}@${ref.marketplace}`
}
/** Resolves `plugin@marketplace`, or a bare plugin name that exactly one known marketplace lists. */
export function resolveRef(paths: StorePaths, input: string): PluginRef {
const at = input.lastIndexOf("@")
if (at > 0) return { plugin: input.slice(0, at), marketplace: input.slice(at + 1) }
const matches = Object.keys(readKnownMarketplaces(paths)).filter((name) =>
loadKnownMarketplace(paths, name).read.marketplace!.plugins.some((entry) => entry.name === input),
)
if (matches.length === 1) return { plugin: input, marketplace: matches[0]! }
if (matches.length === 0) throw new Error(`No known marketplace lists a plugin named ${input}`)
throw new Error(`${input} is in several marketplaces (${matches.join(", ")}). Use ${input}@<marketplace>.`)
}
export async function installPlugin(paths: StorePaths, ref: PluginRef): Promise<InstalledRecord> {
const { known, read } = loadKnownMarketplace(paths, ref.marketplace)
const marketplace = read.marketplace!
const entry = marketplace.plugins.find((item) => item.name === ref.plugin)
if (!entry) throw new Error(`Marketplace ${ref.marketplace} has no plugin named ${ref.plugin}`)
// The name becomes a directory in the cache, so it must not be able to leave it.
if (!isValidIdPart(ref.plugin))
throw new Error(
`Cannot install plugin "${ref.plugin}": use letters, digits, ".", "_" and "-", starting with a letter or digit.`,
)
if (typeof entry.source === "string" && known.source.source === "url")
throw new Error(
`Cannot install ${formatId(ref)}: a marketplace added by URL has no files for the relative source ${entry.source}`,
)
const source = resolveSource(entry.source, marketplace, read.root)
if (source.kind === "unsupported") throw new Error(`Cannot install ${formatId(ref)}: ${source.reason}`)
if (source.kind === "npm")
throw new Error(`Cannot install ${formatId(ref)}: npm plugin sources are not supported yet`)
const now = new Date().toISOString()
const inPlace = source.kind === "local" && (known.source.source === "directory" || known.source.source === "file")
let record: InstalledRecord
if (inPlace && source.kind === "local") {
if (!existsSync(source.directory)) throw new Error(`Plugin source not found: ${source.directory}`)
record = {
scope: "user",
installPath: source.directory,
version: versionOf(source.directory, entry, undefined),
installedAt: now,
lastUpdated: now,
entry,
...(source.isMarketplaceRoot ? { isMarketplaceRoot: true } : {}),
}
} else {
const base = path.join(paths.cache, ref.marketplace, ref.plugin)
mkdirSync(base, { recursive: true })
const staging = mkdtempSync(path.join(base, ".staging-"))
try {
let sha = known.sha
let content = staging
if (source.kind === "local") {
if (!existsSync(source.directory)) throw new Error(`Plugin source not found: ${source.directory}`)
cpSync(source.directory, staging, { recursive: true, filter: (item) => path.basename(item) !== ".git" })
} else {
sha = await fetchRepo({ ...source, destination: staging })
if (source.subdir) content = path.join(staging, source.subdir)
if (!existsSync(content)) throw new Error(`Repository ${source.url} has no directory ${source.subdir}`)
}
const version = versionOf(content, entry, sha)
const installPath = path.join(base, version)
rmSync(installPath, { recursive: true, force: true })
if (content === staging) renameSync(staging, installPath)
else cpSync(content, installPath, { recursive: true })
rmSync(path.join(installPath, ".git"), { recursive: true, force: true })
record = {
scope: "user",
installPath,
version,
installedAt: now,
lastUpdated: now,
...(sha === undefined ? {} : { gitCommitSha: sha }),
entry,
...(source.kind === "local" && source.isMarketplaceRoot ? { isMarketplaceRoot: true } : {}),
}
} finally {
rmSync(staging, { recursive: true, force: true })
}
}
const id = formatId(ref)
const installed = readInstalled(paths)
const previous = installed.plugins[id]?.[0]
installed.plugins[id] = [{ ...record, installedAt: previous?.installedAt ?? record.installedAt }]
writeInstalled(paths, installed)
const settings = readSettings(paths)
// An existing choice survives reinstalls and updates, as it does in Claude Code.
if (settings.enabledPlugins[id] === undefined) {
const manifest = readManifest(record.installPath, [])
settings.enabledPlugins[id] = (entry.defaultEnabled ?? manifest?.defaultEnabled) !== false
writeSettings(paths, settings)
}
return record
}
/** Manifest version wins over the entry's; without either, the commit identifies the version. */
function versionOf(directory: string, entry: MarketplaceEntry, sha: string | undefined) {
const version =
asString(readManifest(directory, [])?.version) ?? asString(entry.version) ?? sha?.slice(0, 12) ?? "unknown"
return version.replace(/[^A-Za-z0-9._+-]/g, "-")
}
export function uninstallPlugin(paths: StorePaths, ref: PluginRef, options: { keepData?: boolean } = {}) {
const id = formatId(ref)
const installed = readInstalled(paths)
const records = installed.plugins[id]
if (!records) throw new Error(`${id} is not installed`)
for (const record of records) {
// Only delete what the store owns. In-place installs point at the user's own directory.
if (isInside(paths.cache, record.installPath)) rmSync(record.installPath, { recursive: true, force: true })
}
delete installed.plugins[id]
writeInstalled(paths, installed)
const settings = readSettings(paths)
delete settings.enabledPlugins[id]
delete settings.pluginConfigs[id]
writeSettings(paths, settings)
if (!options.keepData) rmSync(dataDirectory(paths, id), { recursive: true, force: true })
}
function isInside(parent: string, child: string) {
const offset = path.relative(parent, child)
return offset !== "" && !offset.startsWith("..") && !path.isAbsolute(offset)
}
export function setEnabled(paths: StorePaths, ref: PluginRef, enabled: boolean) {
const id = formatId(ref)
if (!readInstalled(paths).plugins[id]) throw new Error(`${id} is not installed`)
const settings = readSettings(paths)
settings.enabledPlugins[id] = enabled
writeSettings(paths, settings)
}
export interface InstalledPlugin {
id: string
record: InstalledRecord
enabled: boolean
}
export function listInstalled(paths: StorePaths): InstalledPlugin[] {
const settings = readSettings(paths)
return Object.entries(readInstalled(paths).plugins).flatMap(([id, records]) =>
records[0] ? [{ id, record: records[0], enabled: settings.enabledPlugins[id] !== false }] : [],
)
}
/** A plugin the runtime should serve: loaded from disk, with the paths and options its variables resolve to. */
export interface ActivePlugin {
id: string
plugin: LoadedPlugin
dataDir: string
options: Record<string, unknown>
}
export interface ActiveOptions {
/** Extra plugin directories to load directly, like `claude --plugin-dir`. */
pluginDirs?: readonly string[]
}
export function loadActivePlugins(paths: StorePaths, options: ActiveOptions = {}): ActivePlugin[] {
const settings = readSettings(paths)
const fromStore = listInstalled(paths)
.filter((item) => item.enabled && existsSync(item.record.installPath))
.map((item): ActivePlugin => ({
id: item.id,
plugin: loadPlugin({
root: item.record.installPath,
...(item.record.entry === undefined ? {} : { entry: item.record.entry }),
...(item.record.isMarketplaceRoot ? { entryIsMarketplaceRoot: true } : {}),
}),
dataDir: dataDirectory(paths, item.id),
options: settings.pluginConfigs[item.id]?.options ?? {},
}))
const fromDirs = (options.pluginDirs ?? [])
.filter((directory) => existsSync(directory))
.map((directory): ActivePlugin => {
const plugin = loadPlugin({ root: directory })
const id = `${plugin.name}@inline`
return { id, plugin, dataDir: dataDirectory(paths, id), options: settings.pluginConfigs[id]?.options ?? {} }
})
// A directory passed explicitly overrides an installed plugin of the same name.
const names = new Set(fromDirs.map((item) => item.plugin.name))
return [...fromStore.filter((item) => !names.has(item.plugin.name)), ...fromDirs]
}
+216
View File
@@ -0,0 +1,216 @@
import { existsSync, mkdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"
import path from "node:path"
import { readMarketplace, type MarketplaceRead } from "../spec/marketplace.ts"
import { fetchRepo } from "./git.ts"
import type { StorePaths } from "./paths.ts"
import {
readKnownMarketplaces,
writeKnownMarketplaces,
type KnownMarketplace,
type MarketplaceOrigin,
} from "./state.ts"
/**
* Accepts what `claude plugin marketplace add` accepts:
* - a path to a marketplace directory or to its marketplace.json
* - `owner/repo`, optionally `@ref` or `#ref`
* - a git URL: `user@host:path`, or http(s) ending in `.git`, containing `/_git/`, or naming a
* github.com or gitlab.com repository. `#ref` pins a ref.
* - any other http(s) URL, taken as a direct link to a marketplace.json file
*/
export function parseMarketplaceInput(input: string, cwd: string): MarketplaceOrigin {
const local = path.resolve(cwd, input)
if (existsSync(local))
return statSync(local).isFile() ? { source: "file", path: local } : { source: "directory", path: local }
const shorthand = /^([A-Za-z0-9][\w.-]*\/[\w.-]+?)(?:\.git)?(?:[@#]([\w./-]+))?$/.exec(input)
if (shorthand) return { source: "github", repo: shorthand[1]!, ...(shorthand[2] ? { ref: shorthand[2] } : {}) }
const hash = input.lastIndexOf("#")
const url = hash === -1 ? input : input.slice(0, hash)
const ref = hash === -1 ? {} : { ref: input.slice(hash + 1) }
if (/^[\w.-]+@[\w.-]+:/.test(url) || /^(ssh|git|file):\/\//.test(url)) return { source: "git", url, ...ref }
if (/^https?:\/\//.test(url)) {
const isGit =
/\.git$/.test(url) ||
url.includes("/_git/") ||
/^https?:\/\/(www\.)?(github|gitlab)\.com\/[^/]+\/[^/]+\/?$/.test(url)
return isGit ? { source: "git", url, ...ref } : { source: "url", url: input }
}
throw new Error(
`Not a marketplace source: ${input}. Use owner/repo, a git URL, a marketplace.json URL, or a local path.`,
)
}
/** Names Claude Code reserves for Anthropic's own marketplaces, per the marketplace reference. */
const RESERVED_NAMES = new Set([
"claude-code-marketplace",
"claude-code-plugins",
"claude-plugins-official",
"anthropic-marketplace",
"anthropic-plugins",
"agent-skills",
"anthropic-agent-skills",
"life-sciences",
"knowledge-work-plugins",
"claude-for-legal",
"claude-for-financial-services",
"financial-services-plugins",
"first-party-plugins",
"claude-tag-plugins",
"claude-community",
"claude-plugins-community",
"healthcare",
"anthropic-plugin-directory",
"claude-plugin-directory",
])
/**
* Names no marketplace may use: the ones that label plugins which did not come from a
* marketplace, package-manager names, and members every JavaScript object has. The last group
* matters here too, because the store keys plain objects by marketplace name.
*/
const FORBIDDEN_NAMES = new Set([
"inline",
"builtin",
"skills-dir",
"synced",
"claude-plugin-test",
"npm",
"pip",
"uv",
"cargo",
"github",
"gh",
"constructor",
"hasownproperty",
"isprototypeof",
"propertyisenumerable",
"tolocalestring",
"tostring",
"valueof",
"__proto__",
])
/** Letters, digits, `.`, `_` and `-`, starting with a letter or digit. Also what keeps store paths contained. */
const ID_PART = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
export function isValidIdPart(name: string) {
return ID_PART.test(name) && !name.includes("..")
}
/**
* Applies Claude Code's marketplace naming rules. A reserved name is only accepted from the
* anthropics GitHub organization, so another repository cannot pass itself off as official.
*/
export function assertNameAllowed(name: string, origin: MarketplaceOrigin) {
if (!isValidIdPart(name))
throw new Error(
`Cannot use marketplace name "${name}": use letters, digits, ".", "_" and "-", starting with a letter or digit.`,
)
const lower = name.toLowerCase()
if (FORBIDDEN_NAMES.has(lower) || lower.startsWith("claudeai-"))
throw new Error(`The marketplace name "${name}" is reserved and cannot be registered.`)
const official =
(origin.source === "github" && /^anthropics\//i.test(origin.repo)) ||
(origin.source === "git" && /^(https:\/\/github\.com\/|git@github\.com:)anthropics\//i.test(origin.url))
if (official) return
// Another spelling of a reserved name counts as that name: `claude.code.plugins`.
const normalized = lower.replace(/\.$/, "").replace(/[^a-z0-9_]+/g, "-")
if (RESERVED_NAMES.has(normalized))
throw new Error(
`The marketplace name '${name}' is reserved for official Anthropic marketplaces and is only accepted from the anthropics GitHub organization.`,
)
if (/official/.test(lower) && /claude|anthropic/.test(lower))
throw new Error(`Marketplace name "${name}" impersonates an official Anthropic/Claude marketplace.`)
}
export interface AddResult {
name: string
known: KnownMarketplace
read: MarketplaceRead
}
export async function addMarketplace(paths: StorePaths, origin: MarketplaceOrigin): Promise<AddResult> {
if (origin.source === "directory" || origin.source === "file") {
// Local marketplaces are used in place, so edits to them show up without a re-add.
const read = requireMarketplace(readMarketplace(origin.path))
assertNameAllowed(read.marketplace!.name, origin)
return register(paths, read, {
source: origin,
installLocation: origin.path,
lastUpdated: new Date().toISOString(),
})
}
mkdirSync(paths.marketplaces, { recursive: true })
const staging = path.join(paths.marketplaces, `.staging-${process.pid}-${Date.now()}`)
try {
const sha =
origin.source === "url" ? await downloadManifest(origin.url, staging) : await cloneMarketplace(origin, staging)
const name = requireMarketplace(readMarketplace(staging)).marketplace!.name
assertNameAllowed(name, origin)
const destination = path.join(paths.marketplaces, name)
rmSync(destination, { recursive: true, force: true })
renameSync(staging, destination)
return register(paths, requireMarketplace(readMarketplace(destination)), {
source: origin,
installLocation: destination,
lastUpdated: new Date().toISOString(),
...(sha === undefined ? {} : { sha }),
})
} finally {
rmSync(staging, { recursive: true, force: true })
}
}
function cloneMarketplace(origin: Extract<MarketplaceOrigin, { source: "github" | "git" }>, destination: string) {
const url = origin.source === "github" ? `https://github.com/${origin.repo}.git` : origin.url
return fetchRepo({ url, destination, ...(origin.ref === undefined ? {} : { ref: origin.ref }) })
}
/**
* A `url` marketplace is only the manifest file. Its plugins must use object sources, because
* there is no repository for relative paths to resolve against.
*/
async function downloadManifest(url: string, destination: string): Promise<undefined> {
const response = await fetch(url, { signal: AbortSignal.timeout(30_000) })
if (!response.ok) throw new Error(`Could not download ${url}: HTTP ${response.status}`)
mkdirSync(path.join(destination, ".claude-plugin"), { recursive: true })
writeFileSync(path.join(destination, ".claude-plugin", "marketplace.json"), await response.text())
return undefined
}
function requireMarketplace(read: MarketplaceRead): MarketplaceRead {
if (read.marketplace) return read
throw new Error(read.issues.map((issue) => issue.message).join("; ") || "Not a marketplace")
}
function register(paths: StorePaths, read: MarketplaceRead, known: KnownMarketplace): AddResult {
const name = read.marketplace!.name
writeKnownMarketplaces(paths, { ...readKnownMarketplaces(paths), [name]: known })
return { name, known, read }
}
/** Re-fetches a git-backed marketplace. Local ones are always current. */
export function updateMarketplace(paths: StorePaths, name: string): Promise<AddResult> {
const known = readKnownMarketplaces(paths)[name]
if (!known) throw new Error(`Unknown marketplace: ${name}`)
return addMarketplace(paths, known.source)
}
export function removeMarketplace(paths: StorePaths, name: string) {
const all = readKnownMarketplaces(paths)
const known = all[name]
if (!known) throw new Error(`Unknown marketplace: ${name}`)
if (known.source.source !== "directory" && known.source.source !== "file")
rmSync(known.installLocation, { recursive: true, force: true })
delete all[name]
writeKnownMarketplaces(paths, all)
}
export function loadKnownMarketplace(
paths: StorePaths,
name: string,
): { known: KnownMarketplace; read: MarketplaceRead } {
const known = readKnownMarketplaces(paths)[name]
if (!known) throw new Error(`Unknown marketplace: ${name}. Add it with: c4o marketplace add <source>`)
return { known, read: requireMarketplace(readMarketplace(known.installLocation)) }
}
+36
View File
@@ -0,0 +1,36 @@
import { homedir } from "node:os"
import path from "node:path"
export interface StorePaths {
root: string
knownMarketplaces: string
installed: string
settings: string
/** Cloned marketplace repositories, one directory per marketplace name. */
marketplaces: string
/** Installed plugin versions: `<marketplace>/<plugin>/<version>/`. */
cache: string
/** Persistent per-plugin data, exposed to plugins as `${CLAUDE_PLUGIN_DATA}`. */
data: string
}
/** The store mirrors the layout of Claude Code's `~/.claude/plugins`, under opencode's data directory. */
export function storePaths(env: Record<string, string | undefined> = process.env): StorePaths {
const root =
env.C4O_HOME ??
path.join(env.XDG_DATA_HOME ?? path.join(env.HOME ?? homedir(), ".local", "share"), "opencode", "claude-plugins")
return {
root,
knownMarketplaces: path.join(root, "known_marketplaces.json"),
installed: path.join(root, "installed_plugins.json"),
settings: path.join(root, "settings.json"),
marketplaces: path.join(root, "marketplaces"),
cache: path.join(root, "cache"),
data: path.join(root, "data"),
}
}
/** Claude Code's rule for data directory names: anything outside `[A-Za-z0-9_-]` becomes `-`. */
export function dataDirectory(paths: StorePaths, pluginId: string) {
return path.join(paths.data, pluginId.replace(/[^A-Za-z0-9_-]/g, "-"))
}
+89
View File
@@ -0,0 +1,89 @@
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
import path from "node:path"
import { isRecord } from "../spec/frontmatter.ts"
import type { MarketplaceEntry } from "../spec/types.ts"
import type { StorePaths } from "./paths.ts"
export type MarketplaceOrigin =
| { source: "github"; repo: string; ref?: string }
| { source: "git"; url: string; ref?: string }
| { source: "url"; url: string }
| { source: "directory"; path: string }
| { source: "file"; path: string }
export interface KnownMarketplace {
source: MarketplaceOrigin
/** Directory or marketplace.json path that `readMarketplace` accepts. */
installLocation: string
lastUpdated: string
sha?: string
}
export interface InstalledRecord {
scope: "user"
installPath: string
version: string
installedAt: string
lastUpdated: string
gitCommitSha?: string
/** Snapshot of the marketplace entry, which can supply or extend the plugin's manifest. */
entry?: MarketplaceEntry
isMarketplaceRoot?: boolean
}
export interface InstalledFile {
version: 2
plugins: Record<string, InstalledRecord[]>
}
export interface Settings {
enabledPlugins: Record<string, boolean>
/** Values for each plugin's `userConfig` options. */
pluginConfigs: Record<string, { options: Record<string, unknown> }>
}
function readJson(file: string): unknown {
if (!existsSync(file)) return undefined
return JSON.parse(readFileSync(file, "utf8"))
}
/** Writes through a temporary file so a reader never sees a half-written state file. */
function writeJson(file: string, value: unknown) {
mkdirSync(path.dirname(file), { recursive: true })
const temporary = `${file}.${process.pid}.tmp`
writeFileSync(temporary, JSON.stringify(value, null, 2) + "\n")
renameSync(temporary, file)
}
export function readKnownMarketplaces(paths: StorePaths): Record<string, KnownMarketplace> {
const value = readJson(paths.knownMarketplaces)
return isRecord(value) ? (value as Record<string, KnownMarketplace>) : {}
}
export function writeKnownMarketplaces(paths: StorePaths, value: Record<string, KnownMarketplace>) {
writeJson(paths.knownMarketplaces, value)
}
export function readInstalled(paths: StorePaths): InstalledFile {
const value = readJson(paths.installed)
if (isRecord(value) && isRecord(value.plugins)) return value as unknown as InstalledFile
return { version: 2, plugins: {} }
}
export function writeInstalled(paths: StorePaths, value: InstalledFile) {
writeJson(paths.installed, value)
}
export function readSettings(paths: StorePaths): Settings {
const value = readJson(paths.settings)
return {
enabledPlugins:
isRecord(value) && isRecord(value.enabledPlugins) ? (value.enabledPlugins as Record<string, boolean>) : {},
pluginConfigs:
isRecord(value) && isRecord(value.pluginConfigs) ? (value.pluginConfigs as Settings["pluginConfigs"]) : {},
}
}
export function writeSettings(paths: StorePaths, value: Settings) {
writeJson(paths.settings, value)
}
+134
View File
@@ -0,0 +1,134 @@
import { execFile } from "node:child_process"
import { asStringList } from "../spec/frontmatter.ts"
import type { CommandDef } from "../spec/types.ts"
import { pluginEnvironment, substitute, type VarContext } from "./vars.ts"
/** Splits an argument string the way a shell would, honoring single and double quotes. */
export function parseArguments(input: string): string[] {
const result: string[] = []
let current = ""
let quote: string | undefined
let started = false
for (const char of input) {
if (quote) {
if (char === quote) quote = undefined
else current += char
continue
}
if (char === '"' || char === "'") {
quote = char
started = true
continue
}
if (/\s/.test(char)) {
if (started || current !== "") result.push(current)
current = ""
started = false
continue
}
current += char
}
if (started || current !== "") result.push(current)
return result
}
/**
* Fills `$ARGUMENTS`, `$ARGUMENTS[N]`, `$N` (both 0-based) and named arguments declared in the
* `arguments` frontmatter. A template with no placeholder gets the arguments appended.
*
* One deliberate difference from Claude Code: when the command is invoked with no arguments,
* `$N` and named placeholders are left alone. Command bodies often embed shell such as
* `awk '{print $1}'`, and blanking those would corrupt the instructions.
*/
export function substituteArguments(template: string, input: string, names: string[] = []): string {
const args = parseArguments(input)
const named = new Map(names.map((name, index) => [name, index]))
const positional = args.length === 0 ? "" : "|\\$(\\d+)(?!\\w)"
const namePattern =
names.length === 0 || args.length === 0 ? "" : `|\\$(${names.map(escapeRegExp).join("|")})(?![\\[\\w])`
// One pass, so argument text that itself contains a placeholder is never expanded again.
const pattern = new RegExp(`\\$ARGUMENTS\\[(\\d+)\\]|\\$ARGUMENTS${positional}${namePattern}`, "g")
let found = false
const result = template.replace(pattern, (whole, indexed?: string, shorthand?: string, name?: string) => {
found = true
if (whole === "$ARGUMENTS") return input
const index =
indexed !== undefined ? Number(indexed) : shorthand !== undefined ? Number(shorthand) : named.get(name!)!
return args[index] ?? ""
})
return found || input.trim() === "" ? result : `${result}\n\nARGUMENTS: ${input}`
}
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}
interface Interpolation {
start: number
end: number
source: string
}
/**
* Finds the two documented forms of shell interpolation: a fenced code block whose info string
* is `!`, and inline `` !`command` `` at the start of the text or after whitespace.
*/
function findInterpolations(text: string): Interpolation[] {
const found: Interpolation[] = []
for (const match of text.matchAll(/^[ \t]*```![ \t]*\r?\n([\s\S]*?)\r?\n[ \t]*```[ \t]*$/gm))
found.push({ start: match.index, end: match.index + match[0].length, source: match[1]! })
const fenced = [...found]
for (const match of text.matchAll(/(^|\s)!`([^`\r\n]+)`/g)) {
const start = match.index + match[1]!.length
if (fenced.some((block) => start >= block.start && start < block.end)) continue
found.push({ start, end: match.index + match[0].length, source: match[2]! })
}
return found.toSorted((a, b) => a.start - b.start)
}
export interface RenderOptions {
cwd: string
env: Record<string, string | undefined>
/** Runs one shell interpolation. Replaceable so tests need no real shell. */
run?: (command: string, options: { cwd: string; env: Record<string, string | undefined> }) => Promise<string>
}
/**
* Renders a command body into the prompt text: path variables, then arguments, then shell
* interpolation, whose output replaces the placeholder as it does in Claude Code.
*/
export async function renderCommand(
command: CommandDef,
input: string,
context: VarContext,
options: RenderOptions,
): Promise<string> {
const names = asStringList(command.frontmatter.arguments) ?? []
const argumentNames = names.length === 1 ? names[0]!.split(/\s+/).filter(Boolean) : names
const text = substituteArguments(substitute(command.body, context), input, argumentNames).trim()
const interpolations = findInterpolations(text)
if (interpolations.length === 0) return text
const run = options.run ?? runShell
const env = { ...options.env, ...pluginEnvironment(context) }
const outputs = await Promise.all(interpolations.map((item) => run(item.source, { cwd: options.cwd, env })))
let result = ""
let cursor = 0
for (const [index, item] of interpolations.entries()) {
result += text.slice(cursor, item.start) + outputs[index]
cursor = item.end
}
return result + text.slice(cursor)
}
function runShell(command: string, options: { cwd: string; env: Record<string, string | undefined> }): Promise<string> {
return new Promise((resolve) => {
execFile(
"bash",
["-c", command],
{ cwd: options.cwd, env: options.env as NodeJS.ProcessEnv, timeout: 30_000, maxBuffer: 4 * 1024 * 1024 },
// A failing interpolation still yields text for the model, as it does in Claude Code.
(error, stdout, stderr) =>
resolve((stdout + stderr).trimEnd() || (error ? `[command failed: ${error.message}]` : "")),
)
})
}
+100
View File
@@ -0,0 +1,100 @@
import type { ActivePlugin } from "../store/installs.ts"
import type { AgentDef, LspServerDef, McpServerDef, SkillDef } from "../spec/types.ts"
import { agentPermissions, type PermissionRule } from "./tools.ts"
import { pluginEnvironment, substitute, substituteWithEnv, type VarContext } from "./vars.ts"
export function varContext(active: ActivePlugin, projectDir: string): VarContext {
return { pluginRoot: active.plugin.root, pluginData: active.dataDir, projectDir, userConfig: active.options }
}
/** Shape of opencode's `Skill.Info`. */
export interface OpencodeSkill {
id: string
name: string
description?: string
autoinvoke?: boolean
path: string
content: string
}
export function toSkill(skill: SkillDef, context: VarContext): OpencodeSkill {
return {
id: skill.id,
name: skill.id,
...(skill.description === undefined ? {} : { description: skill.description }),
...(skill.disableModelInvocation ? { autoinvoke: false } : {}),
path: skill.path,
content: substitute(skill.body, context),
}
}
export interface OpencodeAgent {
id: string
description?: string
system: string
mode: "subagent"
color?: string
permissions: PermissionRule[]
}
/**
* Claude model aliases (`sonnet`, `opus`, `inherit`) have no provider-neutral meaning in opencode,
* so translated agents inherit the session's model.
*/
export function toAgent(agent: AgentDef, context: VarContext): OpencodeAgent {
return {
id: agent.id,
...(agent.description === undefined ? {} : { description: agent.description }),
system: substitute(agent.body, context).trim(),
mode: "subagent",
...(agent.color === undefined ? {} : { color: agent.color }),
permissions: agentPermissions(agent.tools, agent.disallowedTools),
}
}
export type OpencodeMcp =
| { type: "local"; command: string[]; environment: Record<string, string> }
| { type: "remote"; url: string; headers?: Record<string, string> }
/**
* opencode derives tool names from the server name, and model providers restrict tool names to
* `[A-Za-z0-9_-]`, so Claude's `plugin:<plugin>:<server>` form cannot be used as is.
*/
export function mcpServerName(plugin: string, server: string) {
const clean = (value: string) => value.replace(/[^A-Za-z0-9_-]/g, "-")
return plugin === server ? clean(server) : `${clean(plugin)}_${clean(server)}`
}
export function toMcp(server: McpServerDef, context: VarContext, env: Record<string, string | undefined>): OpencodeMcp {
const expand = (value: string) => substituteWithEnv(value, context, env)
const expandAll = (record: Record<string, string>) =>
Object.fromEntries(Object.entries(record).map(([key, value]) => [key, expand(value)]))
if (server.type === "stdio") {
return {
type: "local",
command: [expand(server.command), ...server.args.map(expand)],
environment: { ...pluginEnvironment(context), ...expandAll(server.env) },
}
}
const headers = expandAll(server.headers)
return { type: "remote", url: expand(server.url), ...(Object.keys(headers).length === 0 ? {} : { headers }) }
}
/** Shape of an entry in opencode's `lsp` config block. */
export interface OpencodeLsp {
command: string[]
extensions: string[]
env?: Record<string, string>
initialization?: Record<string, unknown>
}
export function toLsp(server: LspServerDef, context: VarContext, env: Record<string, string | undefined>): OpencodeLsp {
const expand = (value: string) => substituteWithEnv(value, context, env)
const serverEnv = Object.fromEntries(Object.entries(server.env).map(([key, value]) => [key, expand(value)]))
return {
command: [expand(server.command), ...server.args.map(expand)],
extensions: Object.keys(server.extensionToLanguage),
...(Object.keys(serverEnv).length === 0 ? {} : { env: serverEnv }),
...(server.initializationOptions === undefined ? {} : { initialization: server.initializationOptions }),
}
}
+130
View File
@@ -0,0 +1,130 @@
import type { ActivePlugin } from "../store/installs.ts"
import type { Issue } from "../spec/types.ts"
import { mcpServerName } from "./components.ts"
export type Support = "supported" | "lossy" | "unsupported"
/**
* How each Claude Code hook event maps onto opencode v2. Events not listed have no opencode
* surface. The notes are shown to users by `c4o inspect`.
*/
export const HOOK_EVENTS: Record<string, { support: Support; note: string }> = {
PreToolUse: {
support: "supported",
note: "runs before the tool; can block the call, rewrite its input and add context. A request to ask the user blocks instead",
},
PostToolUse: {
support: "supported",
note: "runs after a successful tool call; feedback is appended to the tool result",
},
PostToolUseFailure: {
support: "lossy",
note: "runs after a failed tool call; its feedback is not delivered to the model",
},
UserPromptSubmit: {
support: "lossy",
note: "runs when a prompt is submitted; context is appended to the prompt, and a blocked prompt is rejected with a generic error",
},
SessionStart: {
support: "lossy",
note: "runs before a session's first model request; context is added to the system prompt. Reports startup or resume, never clear or compact",
},
Stop: {
support: "lossy",
note: "runs when the session finishes a turn; a block continues the session with the reason as the next prompt",
},
SubagentStop: { support: "lossy", note: "runs when a subagent session finishes; it cannot be made to continue" },
SessionEnd: { support: "lossy", note: "runs when a session is deleted, not when the client exits" },
}
export interface HookReport {
event: string
handlers: number
support: Support
note: string
}
export interface PluginReport {
id: string
name: string
version?: string
root: string
skills: string[]
commands: string[]
agents: string[]
mcpServers: { name: string; opencodeName: string; type: string }[]
lspServers: string[]
hooks: HookReport[]
unsupported: { kind: string; detail: string }[]
issues: Issue[]
}
/** What a plugin contributes to opencode, and what could not be translated. */
export function buildReport(active: ActivePlugin): PluginReport {
const plugin = active.plugin
const hooks = Object.entries(plugin.hooks).map(([event, matchers]): HookReport => {
const handlers = matchers.flatMap((matcher) => matcher.hooks)
const runnable = handlers.filter((handler) => handler.type === "command" || handler.type === "http").length
const known = HOOK_EVENTS[event]
if (!known) return { event, handlers: handlers.length, support: "unsupported", note: "no opencode equivalent" }
if (runnable < handlers.length)
return {
event,
handlers: handlers.length,
support: "lossy",
note: `${known.note}; only command and http handlers run`,
}
return { event, handlers: handlers.length, ...known }
})
const unsupported = [...plugin.unsupported]
if (Object.keys(plugin.lspServers).length > 0)
unsupported.push({ kind: "lsp", detail: "LSP servers must be copied into the opencode lsp config" })
return {
id: active.id,
name: plugin.name,
...(plugin.version === undefined ? {} : { version: plugin.version }),
root: plugin.root,
skills: plugin.skills.map((skill) => skill.id),
commands: plugin.commands.map((command) => command.id),
agents: plugin.agents.map((agent) => agent.id),
mcpServers: Object.entries(plugin.mcpServers).map(([name, server]) => ({
name,
opencodeName: mcpServerName(plugin.name, name),
type: server.type,
})),
lspServers: Object.keys(plugin.lspServers),
hooks,
unsupported,
issues: plugin.issues,
}
}
export function formatReport(report: PluginReport): string {
const lines = [`${report.id}${report.version ? ` (${report.version})` : ""}`, ` root: ${report.root}`]
const section = (title: string, items: string[]) => {
if (items.length > 0) lines.push(` ${title} (${items.length}):`, ...items.map((item) => ` ${item}`))
}
section("skills", report.skills)
section(
"commands",
report.commands.map((command) => `/${command}`),
)
section("agents", report.agents)
section(
"mcp servers",
report.mcpServers.map((server) => `${server.opencodeName} (${server.type})`),
)
section(
"hooks",
report.hooks.map((hook) => `${hook.event} x${hook.handlers}: ${hook.support} - ${hook.note}`),
)
section(
"not translated",
report.unsupported.map((item) => `${item.kind}: ${item.detail}`),
)
section(
"issues",
report.issues.map((issue) => `${issue.level}: ${issue.message}`),
)
return lines.join("\n")
}
+106
View File
@@ -0,0 +1,106 @@
/**
* Claude Code and opencode name their built-in tools and tool inputs differently.
* Hooks and agent tool lists are written in Claude's vocabulary, so both directions are needed.
*/
/**
* opencode permission action for each Claude tool an agent may list in `tools`.
* Tools absent here grant nothing: `BashOutput` and `KillShell` only manage existing shells,
* and mapping them to `shell` would let a read-only agent run commands.
*/
const ACTIONS: Record<string, string[]> = {
Read: ["read"],
NotebookRead: ["read"],
LS: ["read", "glob"],
Glob: ["glob"],
Grep: ["grep"],
Edit: ["edit"],
Write: ["edit"],
MultiEdit: ["edit"],
NotebookEdit: ["edit"],
Bash: ["shell"],
WebFetch: ["webfetch"],
WebSearch: ["websearch"],
Task: ["subagent"],
Agent: ["subagent"],
Skill: ["skill"],
AskUserQuestion: ["question"],
}
/** Strips a permission-rule suffix such as `Bash(git add:*)` down to the tool name. */
export function toolName(rule: string) {
const paren = rule.indexOf("(")
return (paren === -1 ? rule : rule.slice(0, paren)).trim()
}
export interface PermissionRule {
action: string
resource: string
effect: "allow" | "deny" | "ask"
}
/**
* Turns an agent's `tools` allowlist and `disallowedTools` denylist into opencode permission rules.
* opencode applies the last matching rule, so the deny-all comes first.
*/
export function agentPermissions(tools: string[] | undefined, disallowed: string[] | undefined): PermissionRule[] {
const rules: PermissionRule[] = []
const actions = (names: string[]) => [...new Set(names.flatMap((name) => ACTIONS[toolName(name)] ?? []))]
if (tools !== undefined && !tools.includes("*")) {
rules.push({ action: "*", resource: "*", effect: "deny" })
for (const action of actions(tools)) rules.push({ action, resource: "*", effect: "allow" })
}
for (const action of actions(disallowed ?? [])) rules.push({ action, resource: "*", effect: "deny" })
return rules
}
export interface ClaudeToolCall {
name: string
input: Record<string, unknown>
}
type Input = Record<string, unknown>
const pick = (input: Input, mapping: Record<string, string>): Input =>
Object.fromEntries(
Object.entries(mapping).flatMap(([from, to]): [string, unknown][] =>
input[from] === undefined ? [] : [[to, input[from]]],
),
)
const FIELDS: Record<string, { claude: string; fields: Record<string, string> }> = {
shell: { claude: "Bash", fields: { command: "command", timeout: "timeout", background: "run_in_background" } },
read: { claude: "Read", fields: { path: "file_path", offset: "offset", limit: "limit" } },
write: { claude: "Write", fields: { path: "file_path", content: "content" } },
edit: {
claude: "Edit",
fields: { path: "file_path", oldString: "old_string", newString: "new_string", replaceAll: "replace_all" },
},
glob: { claude: "Glob", fields: { pattern: "pattern", path: "path" } },
grep: { claude: "Grep", fields: { pattern: "pattern", path: "path", include: "glob" } },
webfetch: { claude: "WebFetch", fields: { url: "url", prompt: "prompt" } },
websearch: { claude: "WebSearch", fields: { query: "query" } },
subagent: { claude: "Task", fields: { agent: "subagent_type", description: "description", prompt: "prompt" } },
skill: { claude: "Skill", fields: { id: "skill" } },
question: { claude: "AskUserQuestion", fields: { questions: "questions" } },
}
/**
* Describes an opencode tool call the way a Claude Code hook expects to see it.
* Tools with no Claude counterpart keep their own name and input, so matchers can still target them.
*/
export function toClaudeToolCall(tool: string, input: unknown): ClaudeToolCall {
const source: Input = typeof input === "object" && input !== null ? (input as Input) : {}
const mapping = FIELDS[tool]
if (!mapping) return { name: tool, input: source }
return { name: mapping.claude, input: pick(source, mapping.fields) }
}
/** Applies a hook's `updatedInput`, written in Claude's field names, back onto the opencode input. */
export function applyUpdatedInput(tool: string, original: unknown, updated: Record<string, unknown>): unknown {
const mapping = FIELDS[tool]
const source: Input = typeof original === "object" && original !== null ? (original as Input) : {}
if (!mapping) return { ...source, ...updated }
const reverse = Object.fromEntries(Object.entries(mapping.fields).map(([from, to]) => [to, from]))
return { ...source, ...pick(updated, reverse) }
}
+49
View File
@@ -0,0 +1,49 @@
export interface VarContext {
/** `${CLAUDE_PLUGIN_ROOT}`: the installed plugin directory. */
pluginRoot: string
/** `${CLAUDE_PLUGIN_DATA}`: persistent per-plugin data directory. */
pluginData: string
/** `${CLAUDE_PROJECT_DIR}`: the project the session runs in. */
projectDir: string
/** Saved `userConfig` values, referenced as `${user_config.KEY}`. */
userConfig: Record<string, unknown>
}
const PATTERN =
/\$\{(CLAUDE_PLUGIN_ROOT|CLAUDE_PLUGIN_DATA|CLAUDE_PROJECT_DIR|user_config\.([A-Za-z_][A-Za-z0-9_]*))\}/g
/** Substitutes the plugin path variables and `user_config` references Claude Code resolves inline. */
export function substitute(text: string, context: VarContext): string {
return text.replace(PATTERN, (whole, name: string, key: string | undefined) => {
if (name === "CLAUDE_PLUGIN_ROOT") return context.pluginRoot
if (name === "CLAUDE_PLUGIN_DATA") return context.pluginData
if (name === "CLAUDE_PROJECT_DIR") return context.projectDir
const value = key === undefined ? undefined : context.userConfig[key]
return value === undefined || value === null ? whole : String(value)
})
}
const ENV_PATTERN = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g
/**
* Like `substitute`, and also expands `${VAR}` and `${VAR:-default}` from the environment,
* which MCP and LSP server definitions use for credentials.
*/
export function substituteWithEnv(text: string, context: VarContext, env: Record<string, string | undefined>): string {
return substitute(text, context).replace(ENV_PATTERN, (whole, name: string, fallback: string | undefined) => {
return env[name] ?? fallback ?? whole
})
}
/** Environment exported to hook and MCP processes. */
export function pluginEnvironment(context: VarContext): Record<string, string> {
const options = Object.entries(context.userConfig).flatMap(([key, value]): [string, string][] =>
value === undefined || value === null ? [] : [[`CLAUDE_PLUGIN_OPTION_${key.toUpperCase()}`, String(value)]],
)
return {
CLAUDE_PLUGIN_ROOT: context.pluginRoot,
CLAUDE_PLUGIN_DATA: context.pluginData,
CLAUDE_PROJECT_DIR: context.projectDir,
...Object.fromEntries(options),
}
}
+97
View File
@@ -0,0 +1,97 @@
import { expect, test } from "bun:test"
import { parseArguments, renderCommand, substituteArguments } from "../src/translate/commands.ts"
import type { CommandDef } from "../src/spec/types.ts"
const context = {
pluginRoot: "/plugins/demo",
pluginData: "/data/demo",
projectDir: "/project",
userConfig: { region: "eu" },
}
const command = (body: string, frontmatter: Record<string, unknown> = {}): CommandDef => ({
id: "demo:cmd",
body,
frontmatter,
})
test("arguments split like a shell, honoring quotes", () => {
expect(parseArguments(`one "two words" 'three more' four`)).toEqual(["one", "two words", "three more", "four"])
expect(parseArguments(`a "" b`)).toEqual(["a", "", "b"])
expect(parseArguments(" ")).toEqual([])
})
test("$ARGUMENTS takes the whole string, indexed forms are 0-based", () => {
expect(substituteArguments("All: $ARGUMENTS", "fix the bug")).toBe("All: fix the bug")
expect(substituteArguments("$0 then $1 and $ARGUMENTS[2]", "a b c")).toBe("a then b and c")
expect(substituteArguments("missing [$5]", "a")).toBe("missing []")
})
test("named arguments come from frontmatter order", () => {
expect(substituteArguments("Deploy $service to $env", "api prod", ["service", "env"])).toBe("Deploy api to prod")
})
test("arguments are appended when the template has no placeholder", () => {
expect(substituteArguments("Review the code.", "src/app.ts")).toBe("Review the code.\n\nARGUMENTS: src/app.ts")
expect(substituteArguments("Review the code.", "")).toBe("Review the code.")
})
test("substituted text is not expanded a second time", () => {
expect(substituteArguments("first=$0 all=$ARGUMENTS", "$1 real")).toBe("first=$1 all=$1 real")
})
test("with no arguments, shell snippets that use $1 survive and $ARGUMENTS is blanked", () => {
const template = "Request: $ARGUMENTS\nRun: awk '{print $1}'"
expect(substituteArguments(template, "")).toBe("Request: \nRun: awk '{print $1}'")
})
test("render substitutes plugin paths and user config", async () => {
const text = await renderCommand(
command('Run "${CLAUDE_PLUGIN_ROOT}/bin/tool" in ${CLAUDE_PROJECT_DIR} for ${user_config.region}'),
"",
context,
{ cwd: "/project", env: {} },
)
expect(text).toBe('Run "/plugins/demo/bin/tool" in /project for eu')
})
test("render replaces inline and fenced shell interpolation with command output", async () => {
const seen: string[] = []
const text = await renderCommand(
command('Status: !`git status`\n\n```!\n"${CLAUDE_PLUGIN_ROOT}/setup.sh" $ARGUMENTS\n```\n\nDone.'),
"--fast",
context,
{
cwd: "/project",
env: {},
run: async (source, options) => {
seen.push(source)
expect(options.env.CLAUDE_PLUGIN_ROOT).toBe("/plugins/demo")
return `<${source}>`
},
},
)
expect(seen).toEqual(["git status", '"/plugins/demo/setup.sh" --fast'])
expect(text).toBe('Status: <git status>\n\n<"/plugins/demo/setup.sh" --fast>\n\nDone.')
})
test("an exclamation mark glued to a word is not shell interpolation", async () => {
const text = await renderCommand(command("Wow!`not a command`"), "", context, {
cwd: "/project",
env: {},
run: async () => "RAN",
})
expect(text).toBe("Wow!`not a command`")
})
test("named arguments accept the space-separated frontmatter form", async () => {
const text = await renderCommand(
command("Fix $issue on $branch", { arguments: "issue branch" }),
"123 main",
context,
{
cwd: "/project",
env: {},
},
)
expect(text).toBe("Fix 123 on main")
})
+31
View File
@@ -0,0 +1,31 @@
import { expect, test } from "bun:test"
import { asStringList, parseMarkdown } from "../src/spec/frontmatter.ts"
test("splits frontmatter from the body", () => {
const result = parseMarkdown("---\nname: demo\ndescription: A demo\n---\n\n# Title\n")
expect(result.data).toEqual({ name: "demo", description: "A demo" })
expect(result.body).toBe("\n# Title\n")
})
test("a file with no frontmatter is all body", () => {
expect(parseMarkdown("# Just text\n")).toEqual({ data: {}, body: "# Just text\n" })
})
test("empty frontmatter yields no data", () => {
expect(parseMarkdown("---\n---\nbody")).toEqual({ data: {}, body: "body" })
})
test("falls back to line parsing when the YAML is invalid", () => {
// An unquoted value containing ": " is not valid YAML, but plugin authors write it anyway.
const result = parseMarkdown("---\ndescription: Use when: the user asks\nmodel: sonnet\n---\nbody")
expect(result.data).toEqual({ description: "Use when: the user asks", model: "sonnet" })
})
test("tool lists split on commas outside parentheses", () => {
expect(asStringList("Bash(git add:*, git status:*), Read, Grep")).toEqual([
"Bash(git add:*, git status:*)",
"Read",
"Grep",
])
expect(asStringList(["Read", "Edit"])).toEqual(["Read", "Edit"])
})
+20
View File
@@ -0,0 +1,20 @@
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import path from "node:path"
/** Creates a temporary directory holding the given files. Keys are relative paths; objects are written as JSON. */
export function tree(files: Record<string, string | object>): string {
const root = mkdtempSync(path.join(tmpdir(), "c4o-test-"))
write(root, files)
return root
}
export function write(root: string, files: Record<string, string | object>) {
for (const [relative, content] of Object.entries(files)) {
const target = path.join(root, relative)
mkdirSync(path.dirname(target), { recursive: true })
writeFileSync(target, typeof content === "string" ? content : JSON.stringify(content, null, 2))
}
}
export const skill = (description: string, body = "Skill body.") => `---\ndescription: ${description}\n---\n\n${body}\n`
+236
View File
@@ -0,0 +1,236 @@
import { expect, test } from "bun:test"
import { readFileSync } from "node:fs"
import path from "node:path"
import { loadPlugin } from "../src/spec/plugin.ts"
import { conditionMatches, interpret, matcherMatches, runHooks } from "../src/runtime/hooks/engine.ts"
import { transcriptLines } from "../src/runtime/hooks/transcript.ts"
import type { ActivePlugin } from "../src/store/installs.ts"
import { tree } from "./helpers.ts"
/** A plugin whose hooks are small shell scripts, loaded the same way an installed plugin is. */
function pluginWithHooks(hooks: Record<string, unknown>, scripts: Record<string, string> = {}): ActivePlugin {
const root = tree({ ".claude-plugin/plugin.json": { name: "guard" }, "hooks/hooks.json": { hooks }, ...scripts })
return {
id: "guard@test",
plugin: loadPlugin({ root }),
dataDir: path.join(root, ".data"),
options: { level: "strict" },
}
}
const command = (text: string, extra: Record<string, unknown> = {}) => ({
hooks: [{ type: "command", command: text, ...extra }],
})
const session = (cwd: string) => ({ sessionID: "ses_1", cwd, transcriptPath: "/tmp/t.jsonl" })
test("matchers: absent, empty and * match everything; otherwise a whole-name regex", () => {
expect(matcherMatches(undefined, "Bash")).toBe(true)
expect(matcherMatches("", "Bash")).toBe(true)
expect(matcherMatches("*", "Bash")).toBe(true)
expect(matcherMatches("Edit|Write", "Write")).toBe(true)
expect(matcherMatches("Edit|Write", "MultiEdit")).toBe(false)
expect(matcherMatches("mcp__.*", "mcp__plugin_x_y__search")).toBe(true)
expect(matcherMatches("Notebook.*", "NotebookEdit")).toBe(true)
})
test("if conditions narrow by command prefix or file pattern", () => {
const bash = { name: "Bash", input: { command: "git commit -m x" } }
expect(conditionMatches("Bash(git commit:*)", bash)).toBe(true)
expect(conditionMatches("Bash(git push:*)", bash)).toBe(false)
expect(conditionMatches("Edit(*.ts)", bash)).toBe(false)
expect(conditionMatches("Edit(*.ts)", { name: "Edit", input: { file_path: "/src/app.ts" } })).toBe(true)
expect(conditionMatches("Edit(*.ts)", { name: "Edit", input: { file_path: "/src/app.py" } })).toBe(false)
})
test("output protocol: JSON decisions, context and updated input", () => {
expect(interpret('{"decision":"block","reason":"no"}', "PostToolUse")).toEqual({ blocked: "no" })
expect(interpret('{"continue":false,"stopReason":"halt"}', "PreToolUse")).toEqual({ blocked: "halt" })
expect(
interpret('{"hookSpecificOutput":{"permissionDecision":"deny","permissionDecisionReason":"unsafe"}}', "PreToolUse"),
).toEqual({ blocked: "unsafe" })
expect(
interpret('{"hookSpecificOutput":{"additionalContext":"note","updatedInput":{"command":"ls"}}}', "PreToolUse"),
).toEqual({
context: "note",
updatedInput: { command: "ls" },
})
expect(interpret('{"hookSpecificOutput":{"permissionDecision":"allow"}}', "PreToolUse")).toEqual({})
})
test("systemMessage explains a block that gives no reason, and otherwise reaches the model as a note", () => {
// hookify denies this way: the rule's message is only in systemMessage.
expect(
interpret('{"hookSpecificOutput":{"permissionDecision":"deny"},"systemMessage":"Dangerous rm"}', "PreToolUse"),
).toEqual({
blocked: "Dangerous rm",
})
// ralph-loop blocks with the next prompt as the reason; its status line must not replace it.
expect(interpret('{"decision":"block","reason":"Build it","systemMessage":"iteration 2"}', "Stop")).toEqual({
blocked: "Build it",
})
expect(interpret('{"systemMessage":"console.log left in code"}', "PostToolUse")).toEqual({
context: "Hook message: console.log left in code",
})
})
test("a hook that asks for confirmation fails closed", () => {
const result = interpret(
'{"hookSpecificOutput":{"permissionDecision":"ask","permissionDecisionReason":"risky"}}',
"PreToolUse",
)
expect(result.blocked).toContain("requires user confirmation")
expect(result.blocked).toContain("risky")
})
test("plain stdout is context only for UserPromptSubmit and SessionStart", () => {
expect(interpret("remember the style guide\n", "SessionStart")).toEqual({ context: "remember the style guide" })
expect(interpret("remember the style guide\n", "UserPromptSubmit")).toEqual({ context: "remember the style guide" })
expect(interpret("formatted 3 files\n", "PostToolUse")).toEqual({})
})
test("exit code 2 blocks with stderr as the reason; other failures do not block", async () => {
const blocking = pluginWithHooks({ PreToolUse: [command("echo 'rm is not allowed' >&2; exit 2")] })
expect(
await runHooks([blocking], { event: "PreToolUse", subject: "Bash", payload: {} }, session(blocking.plugin.root)),
).toMatchObject({
blocked: "rm is not allowed",
ran: 1,
})
const failing = pluginWithHooks({ PreToolUse: [command("echo oops >&2; exit 1")] })
const outcome = await runHooks(
[failing],
{ event: "PreToolUse", subject: "Bash", payload: {} },
session(failing.plugin.root),
)
expect(outcome.blocked).toBeUndefined()
})
test("handlers receive the event as JSON on stdin and the plugin's variables in their environment", async () => {
const plugin = pluginWithHooks(
{ PreToolUse: [command('bash "${CLAUDE_PLUGIN_ROOT}/record.sh"')] },
{
"record.sh":
'cat > "$CLAUDE_PLUGIN_ROOT/stdin.json"\nenv | grep ^CLAUDE_ | sort > "$CLAUDE_PLUGIN_ROOT/env.txt"\n',
},
)
const root = plugin.plugin.root
await runHooks(
[plugin],
{ event: "PreToolUse", subject: "Bash", payload: { tool_name: "Bash", tool_input: { command: "ls" } } },
session(root),
)
expect(JSON.parse(readFileSync(path.join(root, "stdin.json"), "utf8"))).toEqual({
session_id: "ses_1",
transcript_path: "/tmp/t.jsonl",
cwd: root,
permission_mode: "default",
hook_event_name: "PreToolUse",
tool_name: "Bash",
tool_input: { command: "ls" },
})
const env = readFileSync(path.join(root, "env.txt"), "utf8")
expect(env).toContain(`CLAUDE_PLUGIN_ROOT=${root}`)
expect(env).toContain(`CLAUDE_PROJECT_DIR=${root}`)
expect(env).toContain("CLAUDE_PLUGIN_OPTION_LEVEL=strict")
})
test("only handlers whose matcher and condition fit the call run", async () => {
const plugin = pluginWithHooks({
PreToolUse: [
{ matcher: "Edit|Write", ...command("echo edits >&2; exit 2") },
{ matcher: "Bash", ...command("echo commits >&2; exit 2", { if: "Bash(git commit:*)" }) },
],
})
const run = (name: string, input: Record<string, unknown>) =>
runHooks(
[plugin],
{ event: "PreToolUse", subject: name, toolCall: { name, input }, payload: {} },
session(plugin.plugin.root),
)
expect((await run("Read", {})).ran).toBe(0)
expect((await run("Write", {})).blocked).toBe("edits")
expect((await run("Bash", { command: "git status" })).ran).toBe(0)
expect((await run("Bash", { command: "git commit -m x" })).blocked).toBe("commits")
})
test("results from several plugins merge, and any block wins", async () => {
const adviser = pluginWithHooks({ UserPromptSubmit: [command("echo be concise")] })
const blocker = pluginWithHooks({
UserPromptSubmit: [command(`echo '{"decision":"block","reason":"secrets in prompt"}'`)],
})
const outcome = await runHooks(
[adviser, blocker],
{ event: "UserPromptSubmit", payload: { prompt: "x" } },
session(adviser.plugin.root),
)
expect(outcome).toEqual({ blocked: "secrets in prompt", context: ["be concise"], ran: 2 })
})
test("exec-form handlers pass arguments without a shell", async () => {
const plugin = pluginWithHooks({
SessionStart: [
{ hooks: [{ type: "command", command: "printf", args: ["%s", "root=${CLAUDE_PLUGIN_ROOT}; $(echo not-run)"] }] },
],
})
const outcome = await runHooks(
[plugin],
{ event: "SessionStart", subject: "startup", payload: {} },
session(plugin.plugin.root),
)
expect(outcome.context).toEqual([`root=${plugin.plugin.root}; $(echo not-run)`])
})
test("a handler that outlives its timeout is killed and does not block", async () => {
const plugin = pluginWithHooks({ PreToolUse: [command("sleep 5; exit 2", { timeout: 0.2 })] })
const started = Date.now()
const outcome = await runHooks(
[plugin],
{ event: "PreToolUse", subject: "Bash", payload: {} },
session(plugin.plugin.root),
)
expect(outcome.blocked).toBeUndefined()
expect(Date.now() - started).toBeLessThan(3000)
})
test("async handlers run in the background and cannot block", async () => {
const plugin = pluginWithHooks({ PostToolUse: [command("exit 2", { async: true })] })
const outcome = await runHooks(
[plugin],
{ event: "PostToolUse", subject: "Bash", payload: {} },
session(plugin.plugin.root),
)
expect(outcome).toEqual({ context: [], ran: 0 })
})
test("transcripts use Claude Code's JSONL shape, one content block per line", () => {
const lines = transcriptLines(
[
{ type: "user", text: "fix the bug" },
{
type: "assistant",
content: [
{ type: "reasoning", text: "thinking" },
{ type: "text", text: "Looking." },
{
type: "tool",
id: "call_1",
name: "shell",
state: { status: "completed", input: { command: "ls" }, output: "a.ts" },
},
{ type: "text", text: "<promise>DONE</promise>" },
],
},
],
"ses_1",
"/project",
).map((line) => JSON.parse(line))
expect(lines.map((line) => [line.type, line.message.content])).toEqual([
["user", "fix the bug"],
["assistant", [{ type: "text", text: "Looking." }]],
["assistant", [{ type: "tool_use", id: "call_1", name: "Bash", input: { command: "ls" } }]],
["user", [{ type: "tool_result", tool_use_id: "call_1", content: "a.ts" }]],
["assistant", [{ type: "text", text: "<promise>DONE</promise>" }]],
])
// Hooks such as ralph-loop grep for this exact, unspaced form.
expect(JSON.stringify(lines[1])).toContain('"role":"assistant"')
})
+131
View File
@@ -0,0 +1,131 @@
import { expect, test } from "bun:test"
import path from "node:path"
import { readMarketplace, resolveSource } from "../src/spec/marketplace.ts"
import { parseMarketplaceInput } from "../src/store/marketplaces.ts"
import type { Marketplace } from "../src/spec/types.ts"
import { tree } from "./helpers.ts"
const marketplace: Marketplace = { name: "m", plugins: [] }
test("reads a marketplace from its repository root or from the file itself", () => {
const root = tree({
".claude-plugin/marketplace.json": {
name: "tools",
owner: { name: "Me" },
plugins: [{ name: "a", source: "./a" }, { name: "bad" }],
},
})
const fromRoot = readMarketplace(root)
expect(fromRoot.marketplace?.name).toBe("tools")
expect(fromRoot.marketplace?.plugins.map((entry) => entry.name)).toEqual(["a"])
expect(fromRoot.issues.map((issue) => issue.message)).toEqual(["plugins[1] needs a name and a source"])
const fromFile = readMarketplace(path.join(root, ".claude-plugin", "marketplace.json"))
expect(fromFile.root).toBe(root)
})
test("a directory without marketplace.json is reported", () => {
expect(readMarketplace(tree({})).issues[0]!.message).toBe("No .claude-plugin/marketplace.json found")
})
test("relative sources resolve inside the marketplace", () => {
expect(resolveSource("./plugins/a", marketplace, "/m")).toEqual({
kind: "local",
directory: "/m/plugins/a",
isMarketplaceRoot: false,
})
expect(resolveSource("./", marketplace, "/m")).toEqual({ kind: "local", directory: "/m", isMarketplaceRoot: true })
expect(resolveSource("a", { ...marketplace, metadata: { pluginRoot: "./plugins" } }, "/m")).toMatchObject({
directory: "/m/plugins/a",
})
expect(resolveSource("../outside", marketplace, "/m").kind).toBe("unsupported")
})
test("object sources map to git or npm fetches", () => {
expect(resolveSource({ source: "github", repo: "obra/superpowers", ref: "main" }, marketplace, "/m")).toEqual({
kind: "git",
url: "https://github.com/obra/superpowers.git",
ref: "main",
})
expect(resolveSource({ source: "url", url: "https://example.com/x.git", sha: "abc" }, marketplace, "/m")).toEqual({
kind: "git",
url: "https://example.com/x.git",
sha: "abc",
})
expect(resolveSource({ source: "git-subdir", url: "owner/mono", path: "plugins/x" }, marketplace, "/m")).toEqual({
kind: "git",
url: "https://github.com/owner/mono.git",
subdir: "plugins/x",
})
expect(resolveSource({ source: "npm", package: "@scope/plugin", version: "^1" }, marketplace, "/m")).toEqual({
kind: "npm",
package: "@scope/plugin",
version: "^1",
})
expect(resolveSource({ source: "pip", package: "x" }, marketplace, "/m").kind).toBe("unsupported")
})
test("marketplace add accepts shorthand, URLs and local paths", () => {
const local = tree({ ".claude-plugin/marketplace.json": { name: "m", plugins: [] } })
expect(parseMarketplaceInput(local, "/")).toEqual({ source: "directory", path: local })
expect(parseMarketplaceInput("anthropics/claude-plugins-official", "/nowhere")).toEqual({
source: "github",
repo: "anthropics/claude-plugins-official",
})
expect(parseMarketplaceInput("owner/repo@v2", "/nowhere")).toEqual({
source: "github",
repo: "owner/repo",
ref: "v2",
})
expect(parseMarketplaceInput("https://gitlab.com/team/plugins.git#main", "/nowhere")).toEqual({
source: "git",
url: "https://gitlab.com/team/plugins.git",
ref: "main",
})
expect(parseMarketplaceInput("[email protected]:owner/repo.git", "/nowhere")).toEqual({
source: "git",
url: "[email protected]:owner/repo.git",
})
expect(() => parseMarketplaceInput("./does-not-exist", "/nowhere")).toThrow("Not a marketplace source")
})
test("reserved marketplace names are only accepted from the anthropics GitHub organization", async () => {
const { addMarketplace } = await import("../src/store/marketplaces.ts")
const { storePaths } = await import("../src/store/paths.ts")
const impostor = tree({ ".claude-plugin/marketplace.json": { name: "claude-plugins-official", plugins: [] } })
expect(addMarketplace(storePaths({ C4O_HOME: tree({}) }), { source: "directory", path: impostor })).rejects.toThrow(
"reserved for official Anthropic marketplaces",
)
})
test("http(s) URLs are git repositories when they look like one, otherwise a marketplace.json link", () => {
expect(parseMarketplaceInput("https://github.com/acme/tools", "/nowhere")).toEqual({
source: "git",
url: "https://github.com/acme/tools",
})
expect(parseMarketplaceInput("https://dev.azure.com/org/proj/_git/plugins", "/nowhere")).toEqual({
source: "git",
url: "https://dev.azure.com/org/proj/_git/plugins",
})
expect(parseMarketplaceInput("https://example.com/catalog/marketplace.json", "/nowhere")).toEqual({
source: "url",
url: "https://example.com/catalog/marketplace.json",
})
})
test("marketplace names follow Claude Code's rules", async () => {
const { assertNameAllowed } = await import("../src/store/marketplaces.ts")
const local = { source: "directory", path: "/x" } as const
const official = { source: "github", repo: "anthropics/skills" } as const
expect(() => assertNameAllowed("my-tools", local)).not.toThrow()
// Names become directories, so anything that could leave the store is refused.
for (const name of ["../escape", "a/b", ".hidden", "has space", "a..b", ""])
expect(() => assertNameAllowed(name, local)).toThrow("Cannot use marketplace name")
// Reserved for Anthropic, including other spellings, unless it really comes from anthropics.
for (const name of ["anthropic-agent-skills", "claude.code.plugins", "knowledge-work-plugins"])
expect(() => assertNameAllowed(name, local)).toThrow("reserved for official Anthropic marketplaces")
expect(() => assertNameAllowed("anthropic-agent-skills", official)).not.toThrow()
expect(() => assertNameAllowed("official-claude-plugins", local)).toThrow("impersonates")
// Names with a meaning of their own, and members of every JavaScript object.
for (const name of ["inline", "builtin", "NPM", "constructor", "toString", "claudeai-team"])
expect(() => assertNameAllowed(name, local)).toThrow("is reserved and cannot be registered")
})
+248
View File
@@ -0,0 +1,248 @@
import { expect, test } from "bun:test"
import path from "node:path"
import { loadPlugin } from "../src/spec/plugin.ts"
import type { MarketplaceEntry } from "../src/spec/types.ts"
import { skill, tree } from "./helpers.ts"
const errors = (plugin: ReturnType<typeof loadPlugin>) => plugin.issues.filter((issue) => issue.level === "error")
test("standard layout: every default location is discovered and namespaced", () => {
const root = tree({
".claude-plugin/plugin.json": { name: "deploy-tools", version: "1.2.0" },
"skills/deploy/SKILL.md": skill("Deploys the app"),
"commands/status.md": "---\ndescription: Show status\nargument-hint: [env]\n---\nStatus of $ARGUMENTS",
"commands/ops/rollback.md": "Roll back.",
"agents/reviewer.md": "---\nname: reviewer\ndescription: Reviews\ntools: Read, Grep\n---\nYou review.",
"agents/qa/tester.md": "---\ndescription: Tests\n---\nYou test.",
"hooks/hooks.json": {
hooks: { PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "check.sh" }] }] },
},
".mcp.json": { mcpServers: { api: { command: "node", args: ["server.js"], env: { TOKEN: "x" } } } },
".lsp.json": { go: { command: "gopls", args: ["serve"], extensionToLanguage: { ".go": "go" } } },
"bin/deploy-tool": "#!/bin/sh\n",
})
const plugin = loadPlugin({ root })
expect(errors(plugin)).toEqual([])
expect(plugin.name).toBe("deploy-tools")
expect(plugin.version).toBe("1.2.0")
expect(plugin.skills.map((item) => item.id)).toEqual(["deploy-tools:deploy"])
expect(plugin.commands.map((item) => item.id)).toEqual(["deploy-tools:ops:rollback", "deploy-tools:status"])
expect(plugin.commands.find((item) => item.id === "deploy-tools:status")).toMatchObject({
description: "Show status",
argumentHint: "[env]",
body: "Status of $ARGUMENTS",
})
expect(plugin.agents.map((item) => item.id)).toEqual(["deploy-tools:qa:tester", "deploy-tools:reviewer"])
expect(plugin.agents.find((item) => item.id === "deploy-tools:reviewer")?.tools).toEqual(["Read", "Grep"])
expect(plugin.hooks.PreToolUse).toEqual([{ matcher: "Bash", hooks: [{ type: "command", command: "check.sh" }] }])
expect(plugin.mcpServers.api).toEqual({ type: "stdio", command: "node", args: ["server.js"], env: { TOKEN: "x" } })
expect(plugin.lspServers.go).toMatchObject({ command: "gopls", extensionToLanguage: { ".go": "go" } })
expect(plugin.binDir).toBe(path.join(root, "bin"))
})
test("without a manifest the directory name is the plugin name", () => {
const root = tree({ "skills/hello/SKILL.md": skill("Says hello") })
const plugin = loadPlugin({ root })
expect(plugin.name).toBe(path.basename(root))
expect(plugin.skills[0]!.id).toBe(`${path.basename(root)}:hello`)
})
test("skill ids come from the directory, display names from frontmatter", () => {
const root = tree({
".claude-plugin/plugin.json": { name: "p" },
"skills/dir-name/SKILL.md": "---\nname: Pretty Name\ndescription: d\ndisable-model-invocation: true\n---\nbody",
})
const [item] = loadPlugin({ root }).skills
expect(item).toMatchObject({ id: "p:dir-name", displayName: "Pretty Name", disableModelInvocation: true })
})
test("manifest commands and agents replace the default scan; skills add to it", () => {
const root = tree({
".claude-plugin/plugin.json": {
name: "p",
commands: ["./extra/one.md"],
agents: ["./custom/helper.md"],
skills: ["./more-skills/"],
},
"commands/ignored.md": "ignored",
"extra/one.md": "one",
"agents/ignored.md": "---\ndescription: x\n---\nignored",
"custom/helper.md": "---\ndescription: x\n---\nhelper",
"skills/a/SKILL.md": skill("a"),
"more-skills/b/SKILL.md": skill("b"),
})
const plugin = loadPlugin({ root })
expect(plugin.commands.map((item) => item.id)).toEqual(["p:one"])
expect(plugin.agents.map((item) => item.id)).toEqual(["p:helper"])
expect(plugin.skills.map((item) => item.id)).toEqual(["p:a", "p:b"])
})
test("the commands object map supports source files and inline content", () => {
const root = tree({
".claude-plugin/plugin.json": {
name: "p",
commands: {
status: { source: "./c/status.md", description: "Override", argumentHint: "[env]" },
about: { content: "Explain this plugin." },
broken: { source: "./c/status.md", content: "both" },
},
},
"c/status.md": "---\ndescription: From file\n---\nStatus.",
})
const plugin = loadPlugin({ root })
expect(plugin.commands.map((item) => [item.id, item.description, item.body])).toEqual([
["p:status", "Override", "Status."],
["p:about", undefined, "Explain this plugin."],
])
expect(errors(plugin).map((issue) => issue.message)).toEqual([
"Command p:broken must set exactly one of source or content",
])
})
test("a root SKILL.md with no skills directory loads as a single skill", () => {
const root = tree({ ".claude-plugin/plugin.json": { name: "solo" }, "SKILL.md": skill("The only skill") })
expect(loadPlugin({ root }).skills.map((item) => item.id)).toEqual(["solo:solo"])
})
test("a skills path that holds SKILL.md directly is one skill", () => {
const root = tree({
".claude-plugin/plugin.json": { name: "p", skills: ["./tools/pdf"] },
"tools/pdf/SKILL.md": skill("PDF"),
})
expect(loadPlugin({ root }).skills.map((item) => item.id)).toEqual(["p:pdf"])
})
test("manifest hooks merge with hooks/hooks.json, as files or inline", () => {
const hook = (command: string) => ({ hooks: [{ type: "command", command }] })
const root = tree({
".claude-plugin/plugin.json": { name: "p", hooks: ["./config/extra.json", { PostToolUse: [hook("inline.sh")] }] },
"hooks/hooks.json": { hooks: { PreToolUse: [hook("default.sh")] } },
"config/extra.json": { hooks: { PreToolUse: [hook("extra.sh")] } },
})
const plugin = loadPlugin({ root })
expect(
plugin.hooks.PreToolUse!.flatMap((matcher) => matcher.hooks.map((item) => (item as { command: string }).command)),
).toEqual(["default.sh", "extra.sh"])
expect(plugin.hooks.PostToolUse).toHaveLength(1)
})
test("a hooks file without the wrapper key is rejected", () => {
const root = tree({ "hooks/hooks.json": { PreToolUse: [] } })
expect(errors(loadPlugin({ root })).map((issue) => issue.message)).toEqual([
'Hooks file must wrap its event map in a "hooks" key',
])
})
test(".mcp.json is accepted bare or wrapped, and remote servers default to http", () => {
const bare = tree({ ".mcp.json": { playwright: { command: "npx", args: ["@playwright/mcp@latest"] } } })
expect(loadPlugin({ root: bare }).mcpServers.playwright).toEqual({
type: "stdio",
command: "npx",
args: ["@playwright/mcp@latest"],
env: {},
})
const wrapped = tree({
".mcp.json": {
mcpServers: {
github: { type: "http", url: "https://example.com/mcp", headers: { Authorization: "Bearer ${TOKEN}" } },
},
},
})
expect(loadPlugin({ root: wrapped }).mcpServers.github).toEqual({
type: "http",
url: "https://example.com/mcp",
headers: { Authorization: "Bearer ${TOKEN}" },
})
})
test("paths that escape the plugin root or do not exist are errors", () => {
const root = tree({ ".claude-plugin/plugin.json": { name: "p", skills: ["../outside"], agents: ["./missing.md"] } })
expect(errors(loadPlugin({ root })).map((issue) => issue.message)).toEqual([
"skills path escapes plugin directory: ../outside",
"agents path not found: ./missing.md",
])
})
test("with no plugin.json the marketplace entry is the manifest", () => {
const entry: MarketplaceEntry = {
name: "typescript-lsp",
source: "./plugins/typescript-lsp",
version: "1.0.0",
strict: false,
lspServers: {
typescript: {
command: "typescript-language-server",
args: ["--stdio"],
extensionToLanguage: { ".ts": "typescript" },
},
},
}
const plugin = loadPlugin({ root: tree({ "README.md": "docs" }), entry })
expect(plugin.name).toBe("typescript-lsp")
expect(plugin.version).toBe("1.0.0")
expect(Object.keys(plugin.lspServers)).toEqual(["typescript"])
})
test("a strict entry adds components to an existing manifest", () => {
const root = tree({
".claude-plugin/plugin.json": { name: "p" },
"skills/a/SKILL.md": skill("a"),
"extra/b/SKILL.md": skill("b"),
})
const plugin = loadPlugin({ root, entry: { name: "p", source: "./p", skills: ["./extra"] } })
expect(plugin.skills.map((item) => item.id)).toEqual(["p:a", "p:b"])
})
test("a non-strict entry that declares components conflicts with an existing manifest", () => {
const root = tree({ ".claude-plugin/plugin.json": { name: "p" }, "skills/a/SKILL.md": skill("a") })
const plugin = loadPlugin({ root, entry: { name: "p", source: "./p", strict: false, skills: ["./skills"] } })
expect(errors(plugin).map((issue) => issue.message)).toEqual(["Plugin p has conflicting manifests"])
})
test("an entry at the marketplace root that lists skills loads only those", () => {
const root = tree({
"skills/pdf/SKILL.md": skill("pdf"),
"skills/xlsx/SKILL.md": skill("xlsx"),
"skills/docx/SKILL.md": skill("docx"),
})
const plugin = loadPlugin({
root,
entry: { name: "document-skills", source: "./", strict: false, skills: ["./skills/pdf", "./skills/xlsx"] },
entryIsMarketplaceRoot: true,
})
expect(plugin.skills.map((item) => item.id)).toEqual(["document-skills:pdf", "document-skills:xlsx"])
})
test("components with no opencode equivalent are reported, not dropped silently", () => {
const root = tree({
".claude-plugin/plugin.json": { name: "p", channels: [{ server: "x" }], mcpServers: "./bundle.mcpb" },
"output-styles/terse.md": "terse",
"monitors/monitors.json": [],
})
expect(
loadPlugin({ root })
.unsupported.map((item) => item.kind)
.toSorted(),
).toEqual(["channels", "mcp-bundle", "monitors", "output-styles"])
})
test("an MCP placeholder with an empty url is skipped with a warning, not an error", () => {
const root = tree({
".mcp.json": {
mcpServers: { gmail: { type: "http", url: "" }, real: { type: "http", url: "https://x.test/mcp" } },
},
})
const plugin = loadPlugin({ root })
expect(Object.keys(plugin.mcpServers)).toEqual(["real"])
expect(plugin.issues).toEqual([{ level: "warning", message: "MCP server gmail has an empty url and was skipped" }])
})
test("entry mcpServers and lspServers apply only when the entry is the manifest", () => {
const servers = { mcpServers: { extra: { command: "node", args: ["x.js"] } } }
const withManifest = tree({ ".claude-plugin/plugin.json": { name: "p" } })
expect(loadPlugin({ root: withManifest, entry: { name: "p", source: "./p", ...servers } }).mcpServers).toEqual({})
const bare = tree({ "README.md": "no manifest" })
expect(Object.keys(loadPlugin({ root: bare, entry: { name: "p", source: "./p", ...servers } }).mcpServers)).toEqual([
"extra",
])
})
+208
View File
@@ -0,0 +1,208 @@
import { expect, test } from "bun:test"
import { execFileSync } from "node:child_process"
import { existsSync } from "node:fs"
import path from "node:path"
import {
installPlugin,
listInstalled,
loadActivePlugins,
resolveRef,
setEnabled,
uninstallPlugin,
} from "../src/store/installs.ts"
import { addMarketplace, removeMarketplace } from "../src/store/marketplaces.ts"
import { storePaths } from "../src/store/paths.ts"
import { readKnownMarketplaces } from "../src/store/state.ts"
import { skill, tree } from "./helpers.ts"
const store = () => storePaths({ C4O_HOME: tree({}) })
/** Turns a directory into a git repository and returns a file:// URL plus its HEAD commit. */
function gitRepo(files: Record<string, string | object>) {
const directory = tree(files)
const git = (...args: string[]) =>
execFileSync("git", ["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false", ...args], {
cwd: directory,
})
.toString()
.trim()
git("init", "-q", "-b", "main")
git("add", "-A")
git("commit", "-q", "-m", "init")
return { directory, url: `file://${directory}`, sha: git("rev-parse", "HEAD") }
}
const localMarketplace = () =>
tree({
".claude-plugin/marketplace.json": {
name: "local-tools",
plugins: [
{ name: "greeter", source: "./plugins/greeter" },
{ name: "quiet", source: "./plugins/quiet", defaultEnabled: false },
],
},
"plugins/greeter/.claude-plugin/plugin.json": { name: "greeter", version: "2.0.0" },
"plugins/greeter/skills/hello/SKILL.md": skill("Says hello"),
"plugins/quiet/skills/shh/SKILL.md": skill("Says nothing"),
})
test("a local marketplace is used in place and its plugins load from their own directory", async () => {
const paths = store()
const root = localMarketplace()
const added = await addMarketplace(paths, { source: "directory", path: root })
expect(added.name).toBe("local-tools")
const record = await installPlugin(paths, resolveRef(paths, "greeter"))
expect(record.installPath).toBe(path.join(root, "plugins", "greeter"))
expect(record.version).toBe("2.0.0")
expect(loadActivePlugins(paths).map((item) => [item.id, item.plugin.skills.map((entry) => entry.id)])).toEqual([
["greeter@local-tools", ["greeter:hello"]],
])
})
test("disable hides a plugin from the runtime, enable brings it back, uninstall forgets it", async () => {
const paths = store()
await addMarketplace(paths, { source: "directory", path: localMarketplace() })
const ref = resolveRef(paths, "greeter@local-tools")
await installPlugin(paths, ref)
setEnabled(paths, ref, false)
expect(loadActivePlugins(paths)).toEqual([])
expect(listInstalled(paths).map((item) => item.enabled)).toEqual([false])
setEnabled(paths, ref, true)
expect(loadActivePlugins(paths)).toHaveLength(1)
uninstallPlugin(paths, ref)
expect(listInstalled(paths)).toEqual([])
// The marketplace's own directory is the user's, so uninstall must leave it alone.
expect(existsSync(readKnownMarketplaces(paths)["local-tools"]!.installLocation)).toBe(true)
})
test("defaultEnabled: false installs a plugin disabled", async () => {
const paths = store()
await addMarketplace(paths, { source: "directory", path: localMarketplace() })
await installPlugin(paths, resolveRef(paths, "quiet"))
expect(listInstalled(paths).map((item) => [item.id, item.enabled])).toEqual([["quiet@local-tools", false]])
})
test("a git marketplace is cloned, and its relative plugins are copied into the cache", async () => {
const paths = store()
const repo = gitRepo({
".claude-plugin/marketplace.json": {
name: "remote-tools",
plugins: [{ name: "greeter", source: "./plugins/greeter" }],
},
"plugins/greeter/skills/hello/SKILL.md": skill("Says hello"),
})
const added = await addMarketplace(paths, { source: "git", url: repo.url })
expect(added.known.sha).toBe(repo.sha)
expect(added.known.installLocation).toBe(path.join(paths.marketplaces, "remote-tools"))
const record = await installPlugin(paths, resolveRef(paths, "greeter"))
// No version anywhere, so the commit identifies it.
expect(record.version).toBe(repo.sha.slice(0, 12))
expect(record.installPath).toBe(path.join(paths.cache, "remote-tools", "greeter", repo.sha.slice(0, 12)))
expect(existsSync(path.join(record.installPath, "skills", "hello", "SKILL.md"))).toBe(true)
uninstallPlugin(paths, resolveRef(paths, "greeter"))
expect(existsSync(record.installPath)).toBe(false)
removeMarketplace(paths, "remote-tools")
expect(existsSync(added.known.installLocation)).toBe(false)
})
test("plugins with external git sources are fetched at their pinned commit, including subdirectories", async () => {
const paths = store()
const whole = gitRepo({
".claude-plugin/plugin.json": { name: "whole", version: "3.1.0" },
"skills/a/SKILL.md": skill("a"),
})
const mono = gitRepo({ "packages/part/skills/b/SKILL.md": skill("b"), "packages/other/README.md": "unrelated" })
const marketplace = tree({
".claude-plugin/marketplace.json": {
name: "mixed",
plugins: [
{ name: "whole", source: { source: "url", url: whole.url, sha: whole.sha } },
{ name: "part", source: { source: "git-subdir", url: mono.url, path: "packages/part", sha: mono.sha } },
],
},
})
await addMarketplace(paths, { source: "directory", path: marketplace })
const wholeRecord = await installPlugin(paths, resolveRef(paths, "whole"))
expect(wholeRecord.version).toBe("3.1.0")
expect(wholeRecord.gitCommitSha).toBe(whole.sha)
expect(existsSync(path.join(wholeRecord.installPath, ".git"))).toBe(false)
const partRecord = await installPlugin(paths, resolveRef(paths, "part"))
expect(existsSync(path.join(partRecord.installPath, "skills", "b", "SKILL.md"))).toBe(true)
expect(existsSync(path.join(partRecord.installPath, "packages"))).toBe(false)
expect(
loadActivePlugins(paths)
.flatMap((item) => item.plugin.skills.map((entry) => entry.id))
.toSorted(),
).toEqual(["part:b", "whole:a"])
})
test("plugin directories passed directly load alongside the store and win on name clashes", async () => {
const paths = store()
await addMarketplace(paths, { source: "directory", path: localMarketplace() })
await installPlugin(paths, resolveRef(paths, "greeter"))
const override = tree({
".claude-plugin/plugin.json": { name: "greeter" },
"skills/dev/SKILL.md": skill("Dev build"),
})
expect(
loadActivePlugins(paths, { pluginDirs: [override] }).map((item) => [item.id, item.plugin.skills[0]!.id]),
).toEqual([["greeter@inline", "greeter:dev"]])
})
test("unknown plugins and ambiguous names are explained", async () => {
const paths = store()
await addMarketplace(paths, { source: "directory", path: localMarketplace() })
expect(() => resolveRef(paths, "nope")).toThrow("No known marketplace lists a plugin named nope")
expect(installPlugin(paths, { plugin: "nope", marketplace: "local-tools" })).rejects.toThrow(
"has no plugin named nope",
)
expect(installPlugin(paths, { plugin: "x", marketplace: "missing" })).rejects.toThrow("Unknown marketplace: missing")
})
test("a marketplace added by URL is just the manifest, so only object sources install", async () => {
const paths = store()
const plugin = gitRepo({
".claude-plugin/plugin.json": { name: "remote", version: "1.0.0" },
"skills/a/SKILL.md": skill("a"),
})
const manifest = {
name: "hosted",
plugins: [
{ name: "remote", source: { source: "url", url: plugin.url, sha: plugin.sha } },
{ name: "relative", source: "./plugins/relative" },
],
}
const server = Bun.serve({ port: 0, fetch: () => Response.json(manifest) })
try {
const added = await addMarketplace(paths, {
source: "url",
url: `http://127.0.0.1:${server.port}/marketplace.json`,
})
expect(added.name).toBe("hosted")
expect((await installPlugin(paths, resolveRef(paths, "remote"))).version).toBe("1.0.0")
expect(installPlugin(paths, resolveRef(paths, "relative"))).rejects.toThrow("has no files for the relative source")
} finally {
server.stop(true)
}
})
test("a plugin whose name could escape the cache directory is refused", async () => {
const paths = store()
const marketplace = tree({
".claude-plugin/marketplace.json": { name: "sketchy", plugins: [{ name: "../../outside", source: "./p" }] },
"p/skills/a/SKILL.md": skill("a"),
})
await addMarketplace(paths, { source: "directory", path: marketplace })
expect(installPlugin(paths, { plugin: "../../outside", marketplace: "sketchy" })).rejects.toThrow(
"Cannot install plugin",
)
})
+63
View File
@@ -0,0 +1,63 @@
import { expect, test } from "bun:test"
import { agentPermissions, applyUpdatedInput, toClaudeToolCall } from "../src/translate/tools.ts"
test("a tools allowlist becomes deny-all followed by specific allows", () => {
expect(agentPermissions(["Glob", "Grep", "Read", "WebFetch"], undefined)).toEqual([
{ action: "*", resource: "*", effect: "deny" },
{ action: "glob", resource: "*", effect: "allow" },
{ action: "grep", resource: "*", effect: "allow" },
{ action: "read", resource: "*", effect: "allow" },
{ action: "webfetch", resource: "*", effect: "allow" },
])
})
test("an agent with no tools field keeps opencode's defaults", () => {
expect(agentPermissions(undefined, undefined)).toEqual([])
})
test("shell-management tools do not grant shell access", () => {
const rules = agentPermissions(["Read", "BashOutput", "KillShell"], undefined)
expect(rules.some((rule) => rule.action === "shell")).toBe(false)
})
test("permission-rule suffixes are ignored when mapping tool names", () => {
expect(agentPermissions(["Bash(git status:*)"], undefined)).toContainEqual({
action: "shell",
resource: "*",
effect: "allow",
})
})
test("disallowedTools add denies after the allows", () => {
expect(agentPermissions(undefined, ["Write", "Edit"])).toEqual([{ action: "edit", resource: "*", effect: "deny" }])
})
test("opencode tool calls are described in Claude Code's vocabulary", () => {
expect(toClaudeToolCall("shell", { command: "ls", workdir: "/x" })).toEqual({
name: "Bash",
input: { command: "ls" },
})
expect(toClaudeToolCall("edit", { path: "a.ts", oldString: "x", newString: "y", replaceAll: true })).toEqual({
name: "Edit",
input: { file_path: "a.ts", old_string: "x", new_string: "y", replace_all: true },
})
expect(toClaudeToolCall("write", { path: "a.ts", content: "hi" })).toEqual({
name: "Write",
input: { file_path: "a.ts", content: "hi" },
})
})
test("tools without a Claude counterpart keep their own name and input", () => {
expect(toClaudeToolCall("context7_resolve", { q: 1 })).toEqual({ name: "context7_resolve", input: { q: 1 } })
})
test("a hook's updatedInput maps back onto opencode field names", () => {
expect(applyUpdatedInput("shell", { command: "rm -rf /", workdir: "/x" }, { command: "echo safe" })).toEqual({
command: "echo safe",
workdir: "/x",
})
expect(applyUpdatedInput("write", { path: "a", content: "b" }, { file_path: "c" })).toEqual({
path: "c",
content: "b",
})
})
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2023"],
"types": ["bun"],
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"exactOptionalPropertyTypes": false,
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src", "test", "e2e"]
}