From 889e0f9545149b4f317ccbcca8038f838d19d5d3 Mon Sep 17 00:00:00 2001 From: Dax Date: Wed, 3 Jun 2026 16:58:34 -0400 Subject: [PATCH] feat(core): add skill registry and file agent loading (#30617) --- bun.lock | 49 +++++- packages/core/package.json | 1 + packages/core/src/config.ts | 85 +++++----- packages/core/src/config/markdown.ts | 36 ++++ packages/core/src/config/plugin/agent.ts | 97 +++++++++-- packages/core/src/config/plugin/provider.ts | 2 +- packages/core/src/config/plugin/skill.ts | 39 +++++ packages/core/src/filesystem/watcher.ts | 4 +- packages/core/src/location-layer.ts | 2 + packages/core/src/plugin/boot.ts | 15 ++ packages/core/src/project-reference.ts | 7 +- packages/core/src/skill.ts | 155 ++++++++++++++++++ packages/core/src/skill/discovery.ts | 99 +++++++++++ packages/core/src/v1/config/migrate.ts | 2 +- packages/core/test/config/agent.test.ts | 135 ++++++++++++--- packages/core/test/config/config.test.ts | 45 +++-- packages/core/test/config/provider.test.ts | 15 +- packages/core/test/config/skill.test.ts | 67 ++++++++ packages/core/test/filesystem/watcher.test.ts | 3 +- packages/core/test/project-reference.test.ts | 6 +- packages/core/test/skill.test.ts | 130 +++++++++++++++ packages/opencode/src/config/markdown.ts | 76 ++------- 22 files changed, 881 insertions(+), 189 deletions(-) create mode 100644 packages/core/src/config/markdown.ts create mode 100644 packages/core/src/config/plugin/skill.ts create mode 100644 packages/core/src/skill.ts create mode 100644 packages/core/src/skill/discovery.ts create mode 100644 packages/core/test/config/skill.test.ts create mode 100644 packages/core/test/skill.test.ts diff --git a/bun.lock b/bun.lock index 83193bd70..2bb58f67d 100644 --- a/bun.lock +++ b/bun.lock @@ -280,6 +280,7 @@ "gitlab-ai-provider": "6.8.0", "glob": "13.0.5", "google-auth-library": "10.5.0", + "gray-matter": "4.0.3", "ignore": "7.0.5", "immer": "11.1.4", "jsonc-parser": "3.3.1", @@ -2732,7 +2733,7 @@ "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], - "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], @@ -3790,7 +3791,7 @@ "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], "jsbi": ["jsbi@4.3.2", "", {}, "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew=="], @@ -5338,6 +5339,8 @@ "@astrojs/markdown-remark/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.6.1", "", {}, "sha512-l5Pqf6uZu31aG+3Lv8nl/3s4DbUzdlxTWDof4pEpto6GUJNhhCbelVi9dEyurOVyqaelwmS9oSyOWOENSfgo9A=="], + "@astrojs/markdown-remark/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "@astrojs/mdx/@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.11", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.6", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.21.0", "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-hcaxX/5aC6lQgHeGh1i+aauvSwIT6cfyFjKWvExYSxUhZZBBdvCliOtu06gbQyhbe0pGJNoNmqNlQZ5zYUuIyQ=="], "@astrojs/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], @@ -5348,6 +5351,8 @@ "@astrojs/solid-js/vite-plugin-solid": ["vite-plugin-solid@2.11.12", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-FgjPcx2OwX9h6f28jli7A4bG7PP3te8uyakE5iqsmpq3Jqi1TWLgSroC9N6cMfGRU2zXsl4Q6ISvTr2VL0QHpA=="], + "@astrojs/starlight/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "@aws-crypto/crc32/@aws-sdk/types": ["@aws-sdk/types@3.973.7", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-reXRwoJ6CfChoqAsBszUYajAF8Z2LRE+CRcKocvFSMpIiLOtYU3aJ9trmn6VVPAzbbY5LXF+FfmUslbXk1SYFg=="], "@aws-crypto/crc32c/@aws-sdk/types": ["@aws-sdk/types@3.973.7", "", { "dependencies": { "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-reXRwoJ6CfChoqAsBszUYajAF8Z2LRE+CRcKocvFSMpIiLOtYU3aJ9trmn6VVPAzbbY5LXF+FfmUslbXk1SYFg=="], @@ -5612,6 +5617,8 @@ "@gitlab/opencode-gitlab-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], + "@hey-api/json-schema-ref-parser/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "@hey-api/openapi-ts/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], "@hey-api/openapi-ts/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], @@ -5876,18 +5883,24 @@ "app-builder-lib/hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="], + "app-builder-lib/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "app-builder-lib/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], "archiver-utils/glob": ["glob@10.5.0", "", { "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=="], "archiver-utils/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + "argparse/sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], + "astro/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.6.1", "", {}, "sha512-l5Pqf6uZu31aG+3Lv8nl/3s4DbUzdlxTWDof4pEpto6GUJNhhCbelVi9dEyurOVyqaelwmS9oSyOWOENSfgo9A=="], "astro/common-ancestor-path": ["common-ancestor-path@1.0.1", "", {}, "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w=="], "astro/diff": ["diff@5.2.2", "", {}, "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A=="], + "astro/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="], "astro/vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="], @@ -5906,6 +5919,8 @@ "builder-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "builder-util/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "c12/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], "c12/dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], @@ -5936,6 +5951,8 @@ "dmg-builder/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + "dmg-builder/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "dmg-license/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], @@ -5958,6 +5975,8 @@ "electron-publish/mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="], + "electron-updater/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "electron-winstaller/fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], "encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], @@ -6000,8 +6019,6 @@ "globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], - "gray-matter/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], - "happy-dom/ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], "html-minifier-terser/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="], @@ -6286,12 +6303,18 @@ "@astrojs/check/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "@astrojs/markdown-remark/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "@astrojs/mdx/@astrojs/markdown-remark/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.7.6", "", {}, "sha512-GOle7smBWKfMSP8osUIGOlB5kaHdQLV3foCsf+5Q9Wsuu+C6Fs3Ez/ttXmhjZ1HkSgsogcM1RXSjjOVieHq16Q=="], "@astrojs/mdx/@astrojs/markdown-remark/@astrojs/prism": ["@astrojs/prism@3.3.0", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ=="], + "@astrojs/mdx/@astrojs/markdown-remark/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "@astrojs/mdx/@astrojs/markdown-remark/shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="], + "@astrojs/starlight/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], @@ -6550,6 +6573,8 @@ "@gitlab/opencode-gitlab-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], + "@hey-api/json-schema-ref-parser/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "@jsx-email/cli/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.19.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA=="], "@jsx-email/cli/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.19.12", "", { "os": "android", "cpu": "arm" }, "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w=="], @@ -6810,6 +6835,8 @@ "app-builder-lib/hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + "app-builder-lib/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "app-builder-lib/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], "archiver-utils/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], @@ -6818,6 +6845,8 @@ "archiver-utils/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + "astro/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "astro/unstorage/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], "astro/unstorage/h3": ["h3@1.15.11", "", { "dependencies": { "cookie-es": "^1.2.3", "crossws": "^0.3.5", "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg=="], @@ -6832,6 +6861,8 @@ "body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + "builder-util/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], @@ -6840,6 +6871,8 @@ "dir-compare/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "dmg-builder/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "dmg-license/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "editorconfig/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], @@ -6848,6 +6881,8 @@ "electron-builder/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "electron-updater/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "electron-winstaller/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], "esbuild-plugin-copy/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], @@ -6860,8 +6895,6 @@ "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - "gray-matter/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], - "iconv-corefoundation/cli-truncate/slice-ansi": ["slice-ansi@3.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ=="], "iconv-corefoundation/cli-truncate/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -7006,6 +7039,8 @@ "@astrojs/check/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "@astrojs/mdx/@astrojs/markdown-remark/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "@astrojs/mdx/@astrojs/markdown-remark/shiki/@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="], "@astrojs/mdx/@astrojs/markdown-remark/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA=="], @@ -7226,8 +7261,6 @@ "filelist/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "gray-matter/js-yaml/argparse/sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], - "iconv-corefoundation/cli-truncate/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "iconv-corefoundation/cli-truncate/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], diff --git a/packages/core/package.json b/packages/core/package.json index eb4148e55..09b40f96d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -95,6 +95,7 @@ "gitlab-ai-provider": "6.8.0", "glob": "13.0.5", "google-auth-library": "10.5.0", + "gray-matter": "4.0.3", "immer": "11.1.4", "ignore": "7.0.5", "jsonc-parser": "3.3.1", diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index b64bff637..7e5502100 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -98,30 +98,22 @@ export class Info extends Schema.Class("Config.Info")({ providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional), }) {} -export const FileSource = Schema.Struct({ - type: Schema.Literal("file"), - path: Schema.String, -}).annotate({ identifier: "Config.FileSource" }) -export type FileSource = typeof FileSource.Type - -export const MemorySource = Schema.Struct({ - type: Schema.Literal("memory"), -}).annotate({ identifier: "Config.MemorySource" }) -export type MemorySource = typeof MemorySource.Type - -export const Source = Schema.Union([FileSource, MemorySource]).pipe(Schema.toTaggedUnion("type")) -export type Source = typeof Source.Type - -export class Loaded extends Schema.Class("Config.Loaded")({ - source: Source, +export class Document extends Schema.Class("Config.Document")({ + type: Schema.Literal("document"), + path: Schema.String.pipe(Schema.optional), info: Info, }) {} +export class Directory extends Schema.Class("Config.Directory")({ + type: Schema.Literal("directory"), + path: AbsolutePath, +}) {} + +export type Entry = Document | Directory + export interface Interface { - /** Returns supplemental config directories from lowest to highest priority. */ - readonly directories: () => Effect.Effect - /** Loads location config files from lowest to highest priority. */ - readonly get: () => Effect.Effect + /** Returns location config documents and supplemental directories from lowest to highest priority. */ + readonly entries: () => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/Config") {} @@ -160,39 +152,40 @@ export const layer = Layer.effect( ), ) if (!info) return - return new Loaded({ source: { type: "file", path: filepath }, info }) + return new Document({ type: "document", path: filepath, info }) }) const loadDirectory = Effect.fnUntraced(function* (directory: AbsolutePath) { - return yield* Effect.forEach(names, (file) => loadFile(path.join(directory, file))).pipe( - Effect.map((configs) => configs.filter((config): config is Loaded => config !== undefined)), - ) + return [ + ...(yield* Effect.forEach(names, (file) => loadFile(path.join(directory, file))).pipe( + Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)), + )), + new Directory({ type: "directory", path: directory }), + ] }) const globalDirectory = AbsolutePath.make(global.config) const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config) // Read configuration once when this location opens. Later calls reuse these // values until the location is reopened. - const directories = locationIsGlobal - ? [globalDirectory] - : [ - globalDirectory, - ...(yield* fs - .up({ targets: [".opencode"], start: location.directory, stop: location.project.directory }) - .pipe(Effect.orDie)) - .toReversed() - .map((directory) => AbsolutePath.make(directory)), - ] + const discovered = locationIsGlobal + ? [] + : yield* fs + .up({ targets: [".opencode", ...names.toReversed()], start: location.directory, stop: location.project.directory }) + .pipe(Effect.orDie) + const directories = [ + globalDirectory, + ...discovered + .filter((item) => path.basename(item) === ".opencode") + .toReversed() + .map((directory) => AbsolutePath.make(directory)), + ] // A config closer to the opened directory should win over one higher up. // Search starts nearby, so reverse the results before applying them. - const directPaths = locationIsGlobal - ? [] - : (yield* fs - .up({ targets: names.toReversed(), start: location.directory, stop: location.project.directory }) - .pipe(Effect.orDie)).toReversed() + const directPaths = discovered.filter((item) => path.basename(item) !== ".opencode").toReversed() const direct = yield* Effect.forEach(directPaths, loadFile).pipe( Effect.orDie, - Effect.map((configs) => configs.filter((config): config is Loaded => config !== undefined)), + Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)), ) const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie) // Apply general settings first and more specific settings last: @@ -200,13 +193,15 @@ export const layer = Layer.effect( const configs = [...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()] // Rules use the opposite order so a user-global rule can override a // repository rule. Statement order inside each file stays unchanged. - yield* policy.load(configs.toReversed().flatMap((config) => config.info.experimental?.policies ?? [])) + yield* policy.load( + configs + .filter((config): config is Document => config.type === "document") + .toReversed() + .flatMap((config) => config.info.experimental?.policies ?? []), + ) return Service.of({ - directories: Effect.fn("Config.directories")(function* () { - return directories - }), - get: Effect.fn("Config.get")(function* () { + entries: Effect.fn("Config.entries")(function* () { return configs }), }) diff --git a/packages/core/src/config/markdown.ts b/packages/core/src/config/markdown.ts new file mode 100644 index 000000000..e1d74e649 --- /dev/null +++ b/packages/core/src/config/markdown.ts @@ -0,0 +1,36 @@ +export * as ConfigMarkdown from "./markdown" + +import matter from "gray-matter" +export function parse(content: string) { + try { + return matter(content) + } catch { + return matter(sanitize(content)) + } +} + +export function parseOption(content: string) { + try { + return parse(content) + } catch { + return undefined + } +} + +// Other coding agents accept unquoted colons in frontmatter values. Retry +// those values as YAML block scalars so existing config files keep working. +export function sanitize(content: string) { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/) + if (!match) return content + const frontmatter = match[1] + const result = frontmatter.split(/\r?\n/).flatMap((line) => { + if (line.trim().startsWith("#") || line.trim() === "" || /^\s+/.test(line)) return [line] + const entry = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.*)$/) + if (!entry) return [line] + const value = entry[2].trim() + if (value === "" || value === ">" || value === "|" || value.startsWith('"') || value.startsWith("'")) return [line] + if (!value.includes(":")) return [line] + return [`${entry[1]}: |-`, ` ${value}`] + }) + return content.replace(frontmatter, () => result.join("\n")) +} diff --git a/packages/core/src/config/plugin/agent.ts b/packages/core/src/config/plugin/agent.ts index ffe4209f3..cf10684fa 100644 --- a/packages/core/src/config/plugin/agent.ts +++ b/packages/core/src/config/plugin/agent.ts @@ -1,32 +1,64 @@ export * as ConfigAgentPlugin from "./agent" -import { Effect } from "effect" +import path from "path" +import { Effect, Option, Schema } from "effect" import { AgentV2 } from "../../agent" import { Config } from "../../config" +import { ConfigAgent } from "../agent" +import { ConfigMarkdown } from "../markdown" +import { FSUtil } from "../../fs-util" import { ModelV2 } from "../../model" -import { PermissionV2 } from "../../permission" import { PluginV2 } from "../../plugin" +import { ConfigAgentV1 } from "../../v1/config/agent" +import { ConfigMigrateV1 } from "../../v1/config/migrate" + +const legacySources = [ + { pattern: "{agent,agents}/**/*.md", primary: false }, + { pattern: "{mode,modes}/*.md", primary: true }, +] as const +const decodeAgent = Schema.decodeUnknownOption(ConfigAgent.Info) +const decodeLegacyAgent = Schema.decodeUnknownOption(ConfigAgentV1.Info) +const decodeConfig = Schema.decodeUnknownOption(Config.Info) +const agentKeys = new Set(["model", "variant", "request", "system", "description", "mode", "hidden", "color", "steps", "disabled", "permissions"]) export const Plugin = PluginV2.define({ id: PluginV2.ID.make("config-agent"), effect: Effect.gen(function* () { const agent = yield* AgentV2.Service const config = yield* Config.Service - const files = yield* config.get() + const fs = yield* FSUtil.Service + const documents = yield* Effect.forEach(yield* config.entries(), (entry) => { + if (entry.type === "document") return Effect.succeed([entry]) + return Effect.gen(function* () { + const files = yield* discover(fs, entry.path) + return yield* Effect.forEach(files, (file) => + fs.readFileStringSafe(file.filepath).pipe( + Effect.map((content) => content && decode(file, content)), + Effect.catch(() => Effect.succeed(undefined)), + ), + ).pipe( + Effect.map((documents) => documents.filter((document): document is Config.Document => document !== undefined)), + ) + }) + }).pipe(Effect.map((documents) => documents.flat())) yield* agent.update((editor) => { - const permissions = new Map() + const global = documents.flatMap((document) => document.info.permissions ?? []) + for (const current of editor.list()) { + editor.update(current.id, (agent) => agent.permissions.push(...global)) + } - for (const file of files) { - for (const [id, item] of Object.entries(file.info.agents ?? {})) { + for (const document of documents) { + for (const [id, item] of Object.entries(document.info.agents ?? {})) { const agentID = AgentV2.ID.make(id) if (item.disabled) { editor.remove(agentID) - permissions.delete(agentID) continue } + const exists = editor.get(agentID) !== undefined editor.update(agentID, (agent) => { + if (!exists) agent.permissions.push(...global) if (item.model !== undefined) { const model = ModelV2.parse(item.model) agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant } @@ -44,20 +76,49 @@ export const Plugin = PluginV2.define({ if (item.hidden !== undefined) agent.hidden = item.hidden if (item.color !== undefined) agent.color = item.color if (item.steps !== undefined) agent.steps = item.steps + if (item.permissions !== undefined) agent.permissions.push(...item.permissions) }) - - if (item.permissions !== undefined) { - permissions.set(agentID, [...(permissions.get(agentID) ?? []), ...item.permissions]) - } } } - - const global = files.flatMap((file) => file.info.permissions ?? []) - for (const current of editor.list()) { - editor.update(current.id, (agent) => { - agent.permissions.push(...global, ...(permissions.get(current.id) ?? [])) - }) - } }) }), }) + +function discover(fs: FSUtil.Interface, directory: string) { + return Effect.forEach(legacySources, (source) => + fs + .glob(source.pattern, { cwd: directory, absolute: true, dot: true, symlink: true }) + .pipe(Effect.map((files) => files.toSorted().map((filepath) => ({ directory, filepath, primary: source.primary })))), + ).pipe( + Effect.map((files) => files.flat()), + Effect.catch(() => Effect.succeed([])), + ) +} + +function decode(file: { directory: string; filepath: string; primary: boolean }, content: string) { + const markdown = ConfigMarkdown.parseOption(content) + if (!markdown) return + const name = path + .relative(file.directory, file.filepath) + .replaceAll("\\", "/") + .replace(/^(agent|agents|mode|modes)\//, "") + .replace(/\.md$/, "") + const body = markdown.content.trim() + const legacy = Object.keys(markdown.data).some((key) => !agentKeys.has(key)) + const agent = Option.getOrUndefined( + legacy + ? Option.map( + decodeLegacyAgent({ name, ...markdown.data, prompt: body }, { errors: "all", propertyOrder: "original" }), + ConfigMigrateV1.migrateAgent, + ) + : decodeAgent({ ...markdown.data, system: body }, { errors: "all", propertyOrder: "original" }), + ) + if (!agent) return + const info = Option.getOrUndefined( + decodeConfig({ + agents: { [name]: file.primary ? { ...agent, mode: "primary" } : agent }, + }), + ) + if (!info) return + return new Config.Document({ type: "document", path: file.filepath, info }) +} diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index 6127c877f..75afe9325 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -13,7 +13,7 @@ export const Plugin = PluginV2.define({ const catalog = yield* Catalog.Service const config = yield* Config.Service const transform = yield* catalog.transform() - const files = yield* config.get() + const files = (yield* config.entries()).filter((entry): entry is Config.Document => entry.type === "document") yield* transform((catalog) => { for (const file of files) { diff --git a/packages/core/src/config/plugin/skill.ts b/packages/core/src/config/plugin/skill.ts new file mode 100644 index 000000000..315e08387 --- /dev/null +++ b/packages/core/src/config/plugin/skill.ts @@ -0,0 +1,39 @@ +export * as ConfigSkillPlugin from "./skill" + +import path from "path" +import { Effect } from "effect" +import { Config } from "../../config" +import { Global } from "../../global" +import { Location } from "../../location" +import { PluginV2 } from "../../plugin" +import { AbsolutePath } from "../../schema" +import { SkillV2 } from "../../skill" + +export const Plugin = PluginV2.define({ + id: PluginV2.ID.make("config-skill"), + effect: Effect.gen(function* () { + const config = yield* Config.Service + const global = yield* Global.Service + const location = yield* Location.Service + const skill = yield* SkillV2.Service + const transform = yield* skill.transform() + const entries = yield* config.entries() + const items = entries.flatMap((entry) => (entry.type === "document" ? entry.info.skills ?? [] : [])) + + yield* transform((editor) => { + for (const item of items) { + if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) { + editor.source(new SkillV2.UrlSource({ type: "url", url: item })) + continue + } + const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item + editor.source( + new SkillV2.DirectorySource({ + type: "directory", + path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)), + }), + ) + } + }) + }), +}) diff --git a/packages/core/src/filesystem/watcher.ts b/packages/core/src/filesystem/watcher.ts index 3e68e8520..c5d8f95a2 100644 --- a/packages/core/src/filesystem/watcher.ts +++ b/packages/core/src/filesystem/watcher.ts @@ -110,7 +110,9 @@ export const layer = Layer.effect( ) } - const config = (yield* (yield* Config.Service).get()).flatMap((item) => item.info.watcher?.ignore ?? []) + const config = (yield* (yield* Config.Service).entries()) + .filter((entry): entry is Config.Document => entry.type === "document") + .flatMap((item) => item.info.watcher?.ignore ?? []) if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) { yield* Effect.forkScoped( subscribe(location.directory, [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)]), diff --git a/packages/core/src/location-layer.ts b/packages/core/src/location-layer.ts index 161961a1e..c6f3cada8 100644 --- a/packages/core/src/location-layer.ts +++ b/packages/core/src/location-layer.ts @@ -22,6 +22,7 @@ import { Watcher } from "./filesystem/watcher" import { ProjectReference } from "./project-reference" import { RepositoryCache } from "./repository-cache" import { Pty } from "./pty" +import { SkillV2 } from "./skill" export class LocationServiceMap extends LayerMap.Service()("@opencode/example/LocationServiceMap", { lookup: (ref: Location.Ref) => { @@ -39,6 +40,7 @@ export class LocationServiceMap extends LayerMap.Service()(" FileSystem.locationLayer, Watcher.locationLayer, Pty.locationLayer, + SkillV2.locationLayer, ).pipe(Layer.provideMerge(location), Layer.fresh) }, idleTimeToLive: "60 minutes", diff --git a/packages/core/src/plugin/boot.ts b/packages/core/src/plugin/boot.ts index 98004600e..554547fc8 100644 --- a/packages/core/src/plugin/boot.ts +++ b/packages/core/src/plugin/boot.ts @@ -6,7 +6,10 @@ import { AgentV2 } from "../agent" import { Catalog } from "../catalog" import { Config } from "../config" import { ConfigAgentPlugin } from "../config/plugin/agent" +import { ConfigSkillPlugin } from "../config/plugin/skill" import { EventV2 } from "../event" +import { FSUtil } from "../fs-util" +import { Global } from "../global" import { Location } from "../location" import { ModelsDev } from "../models-dev" import { Npm } from "../npm" @@ -17,6 +20,7 @@ import { ConfigProviderPlugin } from "../config/plugin/provider" import { EnvPlugin } from "./env" import { ModelsDevPlugin } from "./models-dev" import { ProviderPlugins } from "./provider" +import { SkillV2 } from "../skill" type Plugin = { id: PluginV2.ID @@ -26,10 +30,13 @@ type Plugin = { | AgentV2.Service | Npm.Service | EventV2.Service + | FSUtil.Service + | Global.Service | Location.Service | PluginV2.Service | Config.Service | ModelsDev.Service + | SkillV2.Service > } @@ -51,6 +58,9 @@ export const layer = Layer.effect( const modelsDev = yield* ModelsDev.Service const npm = yield* Npm.Service const events = yield* EventV2.Service + const fs = yield* FSUtil.Service + const global = yield* Global.Service + const skill = yield* SkillV2.Service const done = yield* Deferred.make() const add = Effect.fn("PluginBoot.add")(function* (input: Plugin) { @@ -65,6 +75,9 @@ export const layer = Layer.effect( Effect.provideService(ModelsDev.Service, modelsDev), Effect.provideService(Npm.Service, npm), Effect.provideService(EventV2.Service, events), + Effect.provideService(FSUtil.Service, fs), + Effect.provideService(Global.Service, global), + Effect.provideService(SkillV2.Service, skill), Effect.provideService(PluginV2.Service, plugin), ), }) @@ -80,6 +93,7 @@ export const layer = Layer.effect( yield* add(ModelsDevPlugin) yield* add(ConfigProviderPlugin.Plugin) yield* add(ConfigAgentPlugin.Plugin) + yield* add(ConfigSkillPlugin.Plugin) }).pipe(Effect.withSpan("PluginBoot.boot")) yield* boot.pipe( @@ -98,4 +112,5 @@ export const locationLayer = layer.pipe( Layer.provideMerge(Catalog.locationLayer), Layer.provideMerge(Config.locationLayer), Layer.provideMerge(AgentV2.locationLayer), + Layer.provideMerge(SkillV2.locationLayer), ) diff --git a/packages/core/src/project-reference.ts b/packages/core/src/project-reference.ts index ac7eb6d8c..513b83b56 100644 --- a/packages/core/src/project-reference.ts +++ b/packages/core/src/project-reference.ts @@ -71,7 +71,12 @@ export const layer = Layer.effect( const cache = yield* RepositoryCache.Service const references = resolveAll({ references: ConfigReference.normalize( - Object.assign({}, ...(yield* config.get()).map((document) => document.info.references ?? {})), + Object.assign( + {}, + ...(yield* config.entries()) + .filter((entry): entry is Config.Document => entry.type === "document") + .map((document) => document.info.references ?? {}), + ), ), directory: location.project.directory, home: global.home, diff --git a/packages/core/src/skill.ts b/packages/core/src/skill.ts new file mode 100644 index 000000000..de670ece1 --- /dev/null +++ b/packages/core/src/skill.ts @@ -0,0 +1,155 @@ +export * as SkillV2 from "./skill" + +import path from "path" +import { Context, Effect, Layer, Schema } from "effect" +import { castDraft } from "immer" +import { AgentV2 } from "./agent" +import { ConfigMarkdown } from "./config/markdown" +import { FSUtil } from "./fs-util" +import { PermissionV2 } from "./permission" +import { AbsolutePath, withStatics } from "./schema" +import { SkillDiscovery } from "./skill/discovery" +import { State } from "./state" + +export class DirectorySource extends Schema.Class("SkillV2.DirectorySource")({ + type: Schema.Literal("directory"), + path: AbsolutePath, +}) {} + +export class UrlSource extends Schema.Class("SkillV2.UrlSource")({ + type: Schema.Literal("url"), + url: Schema.String, +}) {} + +export const Source = Schema.Union([DirectorySource, UrlSource]).pipe( + Schema.toTaggedUnion("type"), + withStatics(() => ({ + equals: (a: DirectorySource | UrlSource, b: DirectorySource | UrlSource) => { + if (a.type !== b.type) return false + if (a.type === "directory" && b.type === "directory") return a.path === b.path + if (a.type === "url" && b.type === "url") return a.url === b.url + return false + }, + key: (source: DirectorySource | UrlSource) => + source.type === "directory" ? `directory:${source.path}` : `url:${source.url}`, + })), +) +export type Source = typeof Source.Type + +export class Info extends Schema.Class("SkillV2.Info")({ + name: Schema.String, + description: Schema.String.pipe(Schema.optional), + slash: Schema.Boolean.pipe(Schema.optional), + location: AbsolutePath, + content: Schema.String, +}) {} + +const Frontmatter = Schema.Struct({ + name: Schema.String.pipe(Schema.optional), + description: Schema.String.pipe(Schema.optional), + slash: Schema.Boolean.pipe(Schema.optional), +}) +const decodeFrontmatter = Schema.decodeUnknownOption(Frontmatter) + +export type Data = { + sources: Source[] +} + +export type Editor = { + source: (source: Source) => void + list: () => readonly Source[] +} + +export interface Interface { + readonly transform: State.Interface["transform"] + readonly sources: () => Effect.Effect + readonly list: () => Effect.Effect + readonly forAgent: (agent: AgentV2.ID) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/Skill") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const agent = yield* AgentV2.Service + const discovery = yield* SkillDiscovery.Service + const fs = yield* FSUtil.Service + + const state = State.create({ + initial: () => ({ sources: [] }), + editor: (draft) => ({ + source: (source) => { + if (draft.sources.some((item) => Source.equals(item, source))) return + draft.sources.push(castDraft(source)) + }, + list: () => draft.sources as Source[], + }), + }) + + const load = Effect.fn("SkillV2.load")(function* (source: Source) { + const skills: Info[] = [] + const directories = source.type === "directory" ? [source.path] : yield* discovery.pull(source.url) + for (const directory of directories) { + const files = yield* fs + .glob("{*.md,**/SKILL.md}", { cwd: directory, absolute: true, include: "file", symlink: true, dot: true }) + .pipe(Effect.catch(() => Effect.succeed([] as string[]))) + for (const filepath of files.toSorted()) { + const content = yield* fs.readFileStringSafe(filepath).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!content) continue + const markdown = ConfigMarkdown.parseOption(content) + if (!markdown) continue + const frontmatter = decodeFrontmatter(markdown.data).valueOrUndefined + if (!frontmatter) continue + const name = + frontmatter.name !== undefined + ? frontmatter.name + : path.dirname(filepath) === directory + ? path.basename(filepath, ".md") + : undefined + if (!name) continue + skills.push(new Info({ + name, + description: frontmatter.description, + slash: frontmatter.slash, + location: AbsolutePath.make(filepath), + content: markdown.content, + })) + } + } + return skills + }) + + const cache = new Map() + const list = Effect.fn("SkillV2.list")(function* () { + const skills = new Map() + for (const source of state.get().sources) { + const key = Source.key(source) + const loaded = cache.get(key) ?? (yield* load(source)) + cache.set(key, loaded) + for (const skill of loaded) skills.set(skill.name, skill) + } + return Array.from(skills.values()) + }) + + return Service.of({ + transform: state.transform, + sources: Effect.fn("SkillV2.sources")(function* () { + return state.get().sources + }), + list, + forAgent: Effect.fn("SkillV2.forAgent")(function* (id) { + const current = yield* agent.get(id) + if (!current) return [] + return (yield* list()).filter( + (skill) => PermissionV2.evaluate("skill", skill.name, current.permissions).effect !== "deny", + ) + }), + }) + }), +) + +export const locationLayer = layer.pipe( + Layer.provide(SkillDiscovery.defaultLayer), + Layer.provideMerge(AgentV2.locationLayer), +) diff --git a/packages/core/src/skill/discovery.ts b/packages/core/src/skill/discovery.ts new file mode 100644 index 000000000..fa8c94548 --- /dev/null +++ b/packages/core/src/skill/discovery.ts @@ -0,0 +1,99 @@ +export * as SkillDiscovery from "./discovery" + +import path from "path" +import { Context, Effect, Layer, Schedule, Schema } from "effect" +import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { FSUtil } from "../fs-util" +import { Global } from "../global" +import { AbsolutePath } from "../schema" +import * as Log from "../util/log" + +const skillConcurrency = 4 +const fileConcurrency = 8 + +class IndexSkill extends Schema.Class("SkillDiscovery.IndexSkill")({ + name: Schema.String, + files: Schema.Array(Schema.String), +}) {} + +class Index extends Schema.Class("SkillDiscovery.Index")({ + skills: Schema.Array(IndexSkill), +}) {} + +export interface Interface { + readonly pull: (url: string) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/SkillDiscovery") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const global = yield* Global.Service + const log = Log.create({ service: "skill-discovery" }) + const http = (yield* HttpClient.HttpClient).pipe( + HttpClient.retryTransient({ + retryOn: "errors-and-responses", + times: 2, + schedule: Schedule.exponential(200).pipe(Schedule.jittered), + }), + HttpClient.filterStatusOk, + ) + + const download = Effect.fn("SkillDiscovery.download")(function* (url: string, destination: string) { + if (yield* fs.exists(destination).pipe(Effect.orDie)) return + yield* HttpClientRequest.get(url).pipe( + http.execute, + Effect.flatMap((response) => response.arrayBuffer), + Effect.flatMap((body) => fs.writeWithDirs(destination, new Uint8Array(body))), + Effect.catch((error) => Effect.sync(() => log.error("failed to download skill file", { url, error }))), + ) + }) + + return Service.of({ + pull: Effect.fn("SkillDiscovery.pull")(function* (url) { + const base = url.endsWith("/") ? url : `${url}/` + const index = new URL("index.json", base).href + const data = yield* HttpClientRequest.get(index).pipe( + HttpClientRequest.acceptJson, + http.execute, + Effect.flatMap(HttpClientResponse.schemaBodyJson(Index)), + Effect.catch((error) => { + log.error("failed to fetch skill index", { url: index, error }) + return Effect.succeed(undefined) + }), + ) + if (!data) return [] + + return yield* Effect.forEach( + data.skills.filter((skill) => { + if (skill.files.includes("SKILL.md") || skill.files.includes(`${skill.name}.md`)) return true + log.warn("skill entry missing Markdown definition", { url: index, skill: skill.name }) + return false + }), + (skill) => + Effect.gen(function* () { + const root = path.join(global.cache, "skills", Bun.hash(base).toString(16), skill.name) + yield* Effect.forEach( + skill.files, + (file) => download(new URL(file, `${base}${skill.name}/`).href, path.join(root, file)), + { concurrency: fileConcurrency, discard: true }, + ) + return (yield* fs.exists(path.join(root, "SKILL.md")).pipe(Effect.orDie)) || + (yield* fs.exists(path.join(root, `${skill.name}.md`)).pipe(Effect.orDie)) + ? [AbsolutePath.make(root)] + : [] + }), + { concurrency: skillConcurrency }, + ).pipe(Effect.map((directories) => directories.flat())) + }), + }) + }), +) + +export const defaultLayer = layer.pipe( + Layer.provide(FetchHttpClient.layer), + Layer.provide(FSUtil.defaultLayer), + Layer.provide(Global.defaultLayer), +) diff --git a/packages/core/src/v1/config/migrate.ts b/packages/core/src/v1/config/migrate.ts index 7c254b86e..9b123ecd1 100644 --- a/packages/core/src/v1/config/migrate.ts +++ b/packages/core/src/v1/config/migrate.ts @@ -103,7 +103,7 @@ function agents(info: typeof ConfigV1.Info.Type) { return Object.fromEntries(entries.flatMap(([name, agent]) => (agent ? [[name, migrateAgent(agent)]] : []))) } -function migrateAgent(info: ConfigAgentV1.Info) { +export function migrateAgent(info: ConfigAgentV1.Info) { const body = { ...info.options, ...(info.temperature === undefined ? {} : { temperature: info.temperature }), diff --git a/packages/core/test/config/agent.test.ts b/packages/core/test/config/agent.test.ts index f88d60790..79e872f74 100644 --- a/packages/core/test/config/agent.test.ts +++ b/packages/core/test/config/agent.test.ts @@ -1,16 +1,21 @@ import { describe, expect } from "bun:test" -import { Effect, Schema } from "effect" +import fs from "fs/promises" +import path from "path" +import { Effect, Layer, Schema } from "effect" import { AgentV2 } from "@opencode-ai/core/agent" import { Config } from "@opencode-ai/core/config" import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent" +import { FSUtil } from "@opencode-ai/core/fs-util" import { PermissionV2 } from "@opencode-ai/core/permission" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { tmpdir } from "../fixture/tmpdir" import { testEffect } from "../lib/effect" -const it = testEffect(AgentV2.locationLayer) +const it = testEffect(Layer.mergeAll(AgentV2.locationLayer, FSUtil.defaultLayer)) const decode = Schema.decodeUnknownSync(Config.Info) describe("ConfigAgentPlugin.Plugin", () => { - it.effect("applies global permissions between built-in and agent-specific permissions", () => + it.effect("applies all global permissions before agent-specific permissions", () => Effect.gen(function* () { const agents = yield* AgentV2.Service const build = AgentV2.ID.make("build") @@ -24,11 +29,10 @@ describe("ConfigAgentPlugin.Plugin", () => { ) const config = Config.Service.of({ - directories: () => Effect.succeed([]), - get: () => + entries: () => Effect.succeed([ - new Config.Loaded({ - source: { type: "memory" }, + new Config.Document({ + type: "document", info: decode({ permissions: [{ action: "bash", resource: "*", effect: "ask" }], agents: { @@ -39,18 +43,25 @@ describe("ConfigAgentPlugin.Plugin", () => { model: "openrouter/openai/gpt-5", description: "Review changes", mode: "subagent", - permissions: [{ action: "edit", resource: "*", effect: "deny" }], + permissions: [ + { action: "edit", resource: "*", effect: "deny" }, + { action: "read", resource: "*", effect: "deny" }, + ], }, removed: { description: "Removed later" }, }, }), }), - new Config.Loaded({ - source: { type: "memory" }, + new Config.Document({ + type: "document", info: decode({ + permissions: [{ action: "read", resource: "*", effect: "allow" }], agents: { reviewer: { variant: "high", hidden: true }, removed: { disabled: true }, + late: { + permissions: [{ action: "edit", resource: "*", effect: "allow" }], + }, }, }), }), @@ -67,6 +78,7 @@ describe("ConfigAgentPlugin.Plugin", () => { expect(buildAgent.permissions).toEqual([ { action: "bash", resource: "*", effect: "allow" }, { action: "bash", resource: "*", effect: "ask" }, + { action: "read", resource: "*", effect: "allow" }, { action: "bash", resource: "git *", effect: "allow" }, ]) expect(PermissionV2.evaluate("bash", "git status", buildAgent.permissions).effect).toBe("allow") @@ -82,7 +94,15 @@ describe("ConfigAgentPlugin.Plugin", () => { }) expect(reviewer.permissions).toEqual([ { action: "bash", resource: "*", effect: "ask" }, + { action: "read", resource: "*", effect: "allow" }, { action: "edit", resource: "*", effect: "deny" }, + { action: "read", resource: "*", effect: "deny" }, + ]) + expect(PermissionV2.evaluate("read", "README.md", reviewer.permissions).effect).toBe("deny") + expect((yield* agents.get(AgentV2.ID.make("late")))?.permissions).toEqual([ + { action: "bash", resource: "*", effect: "ask" }, + { action: "read", resource: "*", effect: "allow" }, + { action: "edit", resource: "*", effect: "allow" }, ]) expect(yield* agents.get(AgentV2.ID.make("removed"))).toBeUndefined() }), @@ -92,11 +112,10 @@ describe("ConfigAgentPlugin.Plugin", () => { Effect.gen(function* () { const agents = yield* AgentV2.Service const config = Config.Service.of({ - directories: () => Effect.succeed([]), - get: () => + entries: () => Effect.succeed([ - new Config.Loaded({ - source: { type: "memory" }, + new Config.Document({ + type: "document", info: decode({ agents: { reviewer: { @@ -115,8 +134,8 @@ describe("ConfigAgentPlugin.Plugin", () => { }, }), }), - new Config.Loaded({ - source: { type: "memory" }, + new Config.Document({ + type: "document", info: decode({ agents: { reviewer: { @@ -162,11 +181,10 @@ describe("ConfigAgentPlugin.Plugin", () => { yield* defaults((editor) => editor.update(build, () => {})) const config = Config.Service.of({ - directories: () => Effect.succeed([]), - get: () => + entries: () => Effect.succeed([ - new Config.Loaded({ - source: { type: "memory" }, + new Config.Document({ + type: "document", info: decode({ agents: { build: { disabled: true } } }), }), ]), @@ -180,4 +198,81 @@ describe("ConfigAgentPlugin.Plugin", () => { expect(yield* agents.get(build)).toBeUndefined() }), ) + + it.live("loads legacy file-based agents from config directories", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + yield* Effect.promise(async () => { + await fs.mkdir(path.join(tmp.path, "agents", "team"), { recursive: true }) + await fs.mkdir(path.join(tmp.path, "modes"), { recursive: true }) + await fs.writeFile( + path.join(tmp.path, "agents", "reviewer.md"), + `--- +model: openrouter/openai/gpt-5 +description: Markdown description +temperature: 0.5 +tools: + write: false +--- +Review carefully.`, + ) + await fs.writeFile(path.join(tmp.path, "agents", "team", "helper.md"), "Help the team.") + await fs.writeFile( + path.join(tmp.path, "agents", "native.md"), + `--- +request: + headers: + x-agent: native + body: + effort: high +permissions: + - action: edit + resource: "*" + effect: deny +--- +Use native v2 fields.`, + ) + await fs.writeFile(path.join(tmp.path, "agents", "disabled.md"), "---\ndisabled: true\n---\nDisabled") + await fs.writeFile(path.join(tmp.path, "modes", "plan.md"), "Make a plan.") + }) + const agents = yield* AgentV2.Service + const config = Config.Service.of({ + entries: () => + Effect.succeed([ + new Config.Document({ + type: "document", + info: decode({ agents: { reviewer: { description: "JSON description" } } }), + }), + new Config.Directory({ type: "directory", path: AbsolutePath.make(tmp.path) }), + ]), + }) + + yield* ConfigAgentPlugin.Plugin.effect.pipe( + Effect.provideService(Config.Service, config), + Effect.provideService(AgentV2.Service, agents), + ) + + expect(yield* agents.get(AgentV2.ID.make("reviewer"))).toMatchObject({ + model: { providerID: "openrouter", id: "openai/gpt-5" }, + system: "Review carefully.", + description: "Markdown description", + request: { body: { temperature: 0.5 } }, + permissions: [{ action: "edit", resource: "*", effect: "deny" }], + }) + expect(yield* agents.get(AgentV2.ID.make("team/helper"))).toMatchObject({ system: "Help the team." }) + expect(yield* agents.get(AgentV2.ID.make("native"))).toMatchObject({ + system: "Use native v2 fields.", + request: { headers: { "x-agent": "native" }, body: { effort: "high" } }, + permissions: [{ action: "edit", resource: "*", effect: "deny" }], + }) + expect(yield* agents.get(AgentV2.ID.make("disabled"))).toBeUndefined() + expect(yield* agents.get(AgentV2.ID.make("plan"))).toMatchObject({ system: "Make a plan.", mode: "primary" }) + }), + ), + ), + ) }) diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 63aa75790..a4ec9312b 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -108,9 +108,9 @@ describe("Config", () => { Effect.flatMap((tmp) => Effect.gen(function* () { const config = yield* Config.Service - const documents = yield* config.get() + const entries = yield* config.entries() - expect(documents).toEqual([]) + expect(entries).toEqual([new Config.Directory({ type: "directory", path: AbsolutePath.make(path.join(tmp.path, "global")) })]) }).pipe(Effect.provide(testLayer(tmp.path))), ), ), @@ -145,21 +145,23 @@ describe("Config", () => { ) return yield* Effect.gen(function* () { const config = yield* Config.Service - const documents = yield* config.get() + const documents = (yield* config.entries()).filter((entry) => entry.type === "document") expect(documents).toHaveLength(3) - expect(documents.map((document) => document.source.type)).toEqual(["file", "file", "file"]) + expect(documents.map((document) => document.type)).toEqual(["document", "document", "document"]) expect(documents.map((document) => document.info.$schema)).toEqual(["base", "middle", "last"]) - expect(documents[0]).toBeInstanceOf(Config.Loaded) - expect(documents[0]?.source.type === "file" ? documents[0].source.path : undefined).toBe( - path.join(tmp.path, "config.json"), - ) + expect(documents[0]).toBeInstanceOf(Config.Document) + expect(documents[0]?.path).toBe(path.join(tmp.path, "config.json")) expect(documents[2]?.info.providers?.last).toBeInstanceOf(ConfigProvider.Info) yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "opencode.jsonc"), JSON.stringify({ $schema: "changed" })), ) - expect((yield* config.get()).map((document) => document.info.$schema)).toEqual(["base", "middle", "last"]) + expect((yield* config.entries()).filter((entry) => entry.type === "document").map((document) => document.info.$schema)).toEqual([ + "base", + "middle", + "last", + ]) }).pipe(Effect.provide(testLayer(tmp.path))) }), ), @@ -183,7 +185,7 @@ describe("Config", () => { return yield* Effect.gen(function* () { const config = yield* Config.Service - const documents = yield* config.get() + const documents = (yield* config.entries()).filter((entry) => entry.type === "document") expect(documents[0]?.info.$schema).toBeUndefined() expect(documents[0]?.info.shell).toBe("/bin/zsh") @@ -291,7 +293,7 @@ describe("Config", () => { return yield* Effect.gen(function* () { const config = yield* Config.Service - const documents = yield* config.get() + const documents = (yield* config.entries()).filter((entry) => entry.type === "document") expect(documents).toHaveLength(1) expect(documents[0]?.info.shell).toBe("/bin/bash") @@ -450,7 +452,7 @@ describe("Config", () => { return yield* Effect.gen(function* () { const config = yield* Config.Service - const documents = yield* config.get() + const documents = (yield* config.entries()).filter((entry) => entry.type === "document") expect(documents).toHaveLength(1) expect(documents[0]?.info).toBeInstanceOf(Config.Info) @@ -528,7 +530,7 @@ describe("Config", () => { ) return yield* Effect.gen(function* () { const config = yield* Config.Service - const documents = yield* config.get() + const documents = (yield* config.entries()).filter((entry) => entry.type === "document") expect(documents.map((document) => document.info.$schema)).toEqual(["base"]) }).pipe(Effect.provide(testLayer(tmp.path))) @@ -603,10 +605,10 @@ describe("Config", () => { return yield* Effect.gen(function* () { const config = yield* Config.Service - const directories = yield* config.directories() - const documents = yield* config.get() + const entries = yield* config.entries() + const documents = entries.filter((entry) => entry.type === "document") - expect(directories).toEqual([ + expect(entries.filter((entry) => entry.type === "directory").map((entry) => entry.path)).toEqual([ AbsolutePath.make(global), AbsolutePath.make(path.join(root, ".opencode")), AbsolutePath.make(path.join(directory, ".opencode")), @@ -619,6 +621,17 @@ describe("Config", () => { "root-dot", "directory-dot", ]) + expect(entries.map((entry) => (entry.type === "document" ? entry.info.$schema : entry.path))).toEqual([ + "global", + AbsolutePath.make(global), + "root", + "parent", + "directory", + "root-dot", + AbsolutePath.make(path.join(root, ".opencode")), + "directory-dot", + AbsolutePath.make(path.join(directory, ".opencode")), + ]) }).pipe( Effect.provide( testLayer(directory, global, root, { diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index 2c3f0357f..9a51881e7 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -25,11 +25,10 @@ describe("ConfigProviderPlugin.Plugin", () => { const providerID = ProviderV2.ID.make("custom") const modelID = ModelV2.ID.make("chat") const config = Config.Service.of({ - directories: () => Effect.succeed([]), - get: () => + entries: () => Effect.succeed([ - new Config.Loaded({ - source: { type: "memory" }, + new Config.Document({ + type: "document", info: decode({ providers: { custom: { @@ -57,8 +56,8 @@ describe("ConfigProviderPlugin.Plugin", () => { }, }), }), - new Config.Loaded({ - source: { type: "memory" }, + new Config.Document({ + type: "document", info: decode({ providers: { custom: { @@ -86,8 +85,8 @@ describe("ConfigProviderPlugin.Plugin", () => { }, }), }), - new Config.Loaded({ - source: { type: "memory" }, + new Config.Document({ + type: "document", info: decode({ providers: { custom: { name: "Renamed" }, diff --git a/packages/core/test/config/skill.test.ts b/packages/core/test/config/skill.test.ts new file mode 100644 index 000000000..2b63f9da1 --- /dev/null +++ b/packages/core/test/config/skill.test.ts @@ -0,0 +1,67 @@ +import path from "path" +import { describe, expect } from "bun:test" +import { Effect, Layer, Schema } from "effect" +import { Config } from "@opencode-ai/core/config" +import { ConfigSkillPlugin } from "@opencode-ai/core/config/plugin/skill" +import { Global } from "@opencode-ai/core/global" +import { Location } from "@opencode-ai/core/location" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SkillV2 } from "@opencode-ai/core/skill" +import { location } from "../fixture/location" +import { testEffect } from "../lib/effect" + +const it = testEffect(Layer.empty) +const decode = Schema.decodeUnknownSync(Config.Info) + +describe("ConfigSkillPlugin.Plugin", () => { + it.effect("registers configured skill directories and URLs", () => + Effect.gen(function* () { + const directory = AbsolutePath.make("/repo/packages/app") + const sources: SkillV2.Source[] = [] + const transform = Effect.fnUntraced(function* () { + return Effect.fnUntraced(function* (update: (editor: SkillV2.Editor) => void) { + update({ + source: (source) => sources.push(source), + list: () => sources, + }) + }) + }) + + yield* ConfigSkillPlugin.Plugin.effect.pipe( + Effect.provideService( + Config.Service, + Config.Service.of({ + entries: () => + Effect.succeed([ + new Config.Document({ + type: "document", + info: decode({ + skills: ["./skills", "~/shared-skills", "/opt/skills", "https://example.test/skills/"], + }), + }), + ]), + }), + ), + Effect.provideService(Global.Service, Global.Service.of(Global.make({ home: "/home/test" }))), + Effect.provideService(Location.Service, Location.Service.of(location({ directory }))), + Effect.provideService( + SkillV2.Service, + SkillV2.Service.of({ + transform, + sources: () => Effect.succeed(sources), + list: () => Effect.succeed([]), + forAgent: () => Effect.succeed([]), + }), + ), + ) + + expect(sources).toEqual([ + new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }), + new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join("/home/test", "shared-skills")) }), + new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make("/opt/skills") }), + new SkillV2.UrlSource({ type: "url", url: "https://example.test/skills/" }), + ]) + }), + ) + +}) diff --git a/packages/core/test/filesystem/watcher.test.ts b/packages/core/test/filesystem/watcher.test.ts index 1ca0f7d64..a189442e8 100644 --- a/packages/core/test/filesystem/watcher.test.ts +++ b/packages/core/test/filesystem/watcher.test.ts @@ -23,8 +23,7 @@ const it = testEffect(Layer.mergeAll(FSUtil.defaultLayer, EventV2.defaultLayer)) const configLayer = Layer.succeed( Config.Service, Config.Service.of({ - directories: () => Effect.succeed([]), - get: () => Effect.succeed([]), + entries: () => Effect.succeed([]), }), ) diff --git a/packages/core/test/project-reference.test.ts b/packages/core/test/project-reference.test.ts index a9c25e513..e5962f8ac 100644 --- a/packages/core/test/project-reference.test.ts +++ b/packages/core/test/project-reference.test.ts @@ -214,7 +214,7 @@ describe("ProjectReference", () => { }) function document(references: ConfigReference.Info) { - return new Config.Loaded({ source: { type: "memory" }, info: Schema.decodeUnknownSync(Config.Info)({ references }) }) + return new Config.Document({ type: "document", info: Schema.decodeUnknownSync(Config.Info)({ references }) }) } function result( @@ -237,7 +237,7 @@ function testLayer(input: { directory: string project: string repos: string - documents: Config.Loaded[] + documents: Config.Document[] ensure: RepositoryCache.Interface["ensure"] }) { return ProjectReference.layer.pipe( @@ -256,7 +256,7 @@ function testLayer(input: { ), Layer.succeed( Config.Service, - Config.Service.of({ directories: () => Effect.succeed([]), get: () => Effect.succeed(input.documents) }), + Config.Service.of({ entries: () => Effect.succeed(input.documents) }), ), Layer.succeed(RepositoryCache.Service, RepositoryCache.Service.of({ ensure: input.ensure })), ), diff --git a/packages/core/test/skill.test.ts b/packages/core/test/skill.test.ts new file mode 100644 index 000000000..ceadba065 --- /dev/null +++ b/packages/core/test/skill.test.ts @@ -0,0 +1,130 @@ +import fs from "fs/promises" +import path from "path" +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { AgentV2 } from "@opencode-ai/core/agent" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SkillV2 } from "@opencode-ai/core/skill" +import { SkillDiscovery } from "@opencode-ai/core/skill/discovery" +import { tmpdir } from "./fixture/tmpdir" +import { testEffect } from "./lib/effect" + +const urls = new Map() +let pulls = 0 +const discovery = Layer.succeed( + SkillDiscovery.Service, + SkillDiscovery.Service.of({ + pull: (url) => { + pulls++ + return Effect.succeed(urls.get(url) ?? []) + }, + }), +) +const it = testEffect( + SkillV2.layer.pipe( + Layer.provide(discovery), + Layer.provide(FSUtil.defaultLayer), + Layer.provideMerge(AgentV2.locationLayer), + ), +) + +function write(directory: string, name: string, description: string) { + return fs.writeFile( + path.join(directory, name, "SKILL.md"), + `--- +name: ${name} +description: ${description} +--- +# ${name}`, + ) +} + +describe("SkillV2", () => { + it.live("registers sources and resolves later source precedence", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + const first = path.join(tmp.path, "first") + const second = path.join(tmp.path, "second") + yield* Effect.promise(async () => { + await fs.mkdir(path.join(first, "review"), { recursive: true }) + await fs.mkdir(path.join(second, "review"), { recursive: true }) + await write(first, "review", "First") + await write(second, "review", "Second") + await fs.writeFile(path.join(first, "foo.md"), "---\nslash: true\n---\n# foo") + }) + + const skill = yield* SkillV2.Service + const register = yield* skill.transform() + yield* register((editor) => { + editor.source({ type: "directory", path: AbsolutePath.make(first) }) + editor.source({ type: "directory", path: AbsolutePath.make(first) }) + editor.source({ type: "directory", path: AbsolutePath.make(second) }) + expect(editor.list()).toEqual([ + { type: "directory", path: AbsolutePath.make(first) }, + { type: "directory", path: AbsolutePath.make(second) }, + ]) + }) + + expect(yield* skill.sources()).toEqual([ + { type: "directory", path: AbsolutePath.make(first) }, + { type: "directory", path: AbsolutePath.make(second) }, + ]) + expect(yield* skill.list()).toEqual([ + new SkillV2.Info({ + name: "foo", + slash: true, + location: AbsolutePath.make(path.join(first, "foo.md")), + content: "# foo", + }), + { + name: "review", + description: "Second", + location: AbsolutePath.make(path.join(second, "review", "SKILL.md")), + content: "# review", + }, + ]) + }), + ), + ), + ) + + it.live("loads URL sources and filters skills for agents", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + yield* Effect.promise(async () => { + await fs.mkdir(path.join(tmp.path, "deploy"), { recursive: true }) + await write(tmp.path, "deploy", "Deploy production") + }) + pulls = 0 + urls.set("https://example.test/skills/", [AbsolutePath.make(tmp.path)]) + + const agents = yield* AgentV2.Service + yield* agents.update((editor) => + editor.update(AgentV2.ID.make("reviewer"), (agent) => { + agent.permissions.push({ action: "skill", resource: "deploy", effect: "deny" }) + }), + ) + + const skill = yield* SkillV2.Service + const register = yield* skill.transform() + yield* register((editor) => editor.source({ type: "url", url: "https://example.test/skills/" })) + + expect((yield* skill.list()).map((item) => item.name)).toEqual(["deploy"]) + expect((yield* skill.list()).map((item) => item.name)).toEqual(["deploy"]) + expect(pulls).toBe(1) + expect(yield* skill.forAgent(AgentV2.ID.make("reviewer"))).toEqual([]) + expect(yield* skill.forAgent(AgentV2.ID.make("missing"))).toEqual([]) + }), + ), + ), + ) +}) diff --git a/packages/opencode/src/config/markdown.ts b/packages/opencode/src/config/markdown.ts index d2e7270eb..297db9cc8 100644 --- a/packages/opencode/src/config/markdown.ts +++ b/packages/opencode/src/config/markdown.ts @@ -1,6 +1,6 @@ -import matter from "gray-matter" import { Filesystem } from "@/util/filesystem" import { FrontmatterError } from "@opencode-ai/core/v1/config/error" +import { ConfigMarkdown as ConfigMarkdownCore } from "@opencode-ai/core/config/markdown" export const FILE_REGEX = /(?" || value === "|" || value.startsWith('"') || value.startsWith("'")) { - result.push(line) - continue - } - - // if value contains a colon, convert to block scalar - if (value.includes(":")) { - result.push(`${key}: |-`) - result.push(` ${value}`) - continue - } - - result.push(line) - } - - const processed = result.join("\n") - return content.replace(frontmatter, () => processed) -} +export const fallbackSanitization = ConfigMarkdownCore.sanitize export async function parse(filePath: string) { const template = await Filesystem.readText(filePath) try { - const md = matter(template) - return md - } catch { - try { - return matter(fallbackSanitization(template)) - } catch (err) { - throw new FrontmatterError( - { - path: filePath, - message: `${filePath}: Failed to parse YAML frontmatter: ${err instanceof Error ? err.message : String(err)}`, - }, - { cause: err }, - ) - } + return ConfigMarkdownCore.parse(template) + } catch (err) { + throw new FrontmatterError( + { + path: filePath, + message: `${filePath}: Failed to parse YAML frontmatter: ${err instanceof Error ? err.message : String(err)}`, + }, + { cause: err }, + ) } }