Przeglądaj źródła

feat(core): add skill registry and file agent loading (#30617)

Dax 2 miesięcy temu
rodzic
commit
889e0f9545

+ 41 - 8
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=="],

+ 1 - 0
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",

+ 40 - 45
packages/core/src/config.ts

@@ -98,30 +98,22 @@ export class Info extends Schema.Class<Info>("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<Loaded>("Config.Loaded")({
-  source: Source,
+export class Document extends Schema.Class<Document>("Config.Document")({
+  type: Schema.Literal("document"),
+  path: Schema.String.pipe(Schema.optional),
   info: Info,
 }) {}
 
+export class Directory extends Schema.Class<Directory>("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<AbsolutePath[]>
-  /** Loads location config files from lowest to highest priority. */
-  readonly get: () => Effect.Effect<Loaded[]>
+  /** Returns location config documents and supplemental directories from lowest to highest priority. */
+  readonly entries: () => Effect.Effect<Entry[]>
 }
 
 export class Service extends Context.Service<Service, Interface>()("@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
       }),
     })

+ 36 - 0
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"))
+}

+ 79 - 18
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<AgentV2.ID, PermissionV2.Ruleset>()
+      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 })
+}

+ 1 - 1
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) {

+ 39 - 0
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)),
+          }),
+        )
+      }
+    })
+  }),
+})

+ 3 - 1
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)]),

+ 2 - 0
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<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
   lookup: (ref: Location.Ref) => {
@@ -39,6 +40,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
       FileSystem.locationLayer,
       Watcher.locationLayer,
       Pty.locationLayer,
+      SkillV2.locationLayer,
     ).pipe(Layer.provideMerge(location), Layer.fresh)
   },
   idleTimeToLive: "60 minutes",

+ 15 - 0
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<void>()
 
     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),
 )

+ 6 - 1
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,

+ 155 - 0
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<DirectorySource>("SkillV2.DirectorySource")({
+  type: Schema.Literal("directory"),
+  path: AbsolutePath,
+}) {}
+
+export class UrlSource extends Schema.Class<UrlSource>("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<Info>("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<Data, Editor>["transform"]
+  readonly sources: () => Effect.Effect<Source[]>
+  readonly list: () => Effect.Effect<Info[]>
+  readonly forAgent: (agent: AgentV2.ID) => Effect.Effect<Info[]>
+}
+
+export class Service extends Context.Service<Service, Interface>()("@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<Data, Editor>({
+      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<string, Info[]>()
+    const list = Effect.fn("SkillV2.list")(function* () {
+      const skills = new Map<string, Info>()
+      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),
+)

+ 99 - 0
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<IndexSkill>("SkillDiscovery.IndexSkill")({
+  name: Schema.String,
+  files: Schema.Array(Schema.String),
+}) {}
+
+class Index extends Schema.Class<Index>("SkillDiscovery.Index")({
+  skills: Schema.Array(IndexSkill),
+}) {}
+
+export interface Interface {
+  readonly pull: (url: string) => Effect.Effect<AbsolutePath[]>
+}
+
+export class Service extends Context.Service<Service, Interface>()("@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),
+)

+ 1 - 1
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 }),

+ 115 - 20
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" })
+        }),
+      ),
+    ),
+  )
 })

+ 29 - 16
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, {

+ 7 - 8
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" },

+ 67 - 0
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/" }),
+      ])
+    }),
+  )
+
+})

+ 1 - 2
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([]),
   }),
 )
 

+ 3 - 3
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 })),
       ),

+ 130 - 0
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<string, AbsolutePath[]>()
+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([])
+        }),
+      ),
+    ),
+  )
+})

+ 11 - 65
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 = /(?<![\w`])@(\.?[^\s`,.]*(?:\.[^\s`,.]+)*)/g
 export const SHELL_REGEX = /!`([^`]+)`/g
@@ -15,75 +15,21 @@ export function shell(template: string) {
 
 // other coding agents like claude code allow invalid yaml in their
 // frontmatter, we need to fallback to a more permissive parser for those cases
-export function fallbackSanitization(content: string): string {
-  const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
-  if (!match) return content
-
-  const frontmatter = match[1]
-  const lines = frontmatter.split(/\r?\n/)
-  const result: string[] = []
-
-  for (const line of lines) {
-    // skip comments and empty lines
-    if (line.trim().startsWith("#") || line.trim() === "") {
-      result.push(line)
-      continue
-    }
-
-    // skip lines that are continuations (indented)
-    if (line.match(/^\s+/)) {
-      result.push(line)
-      continue
-    }
-
-    // match key: value pattern
-    const kvMatch = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.*)$/)
-    if (!kvMatch) {
-      result.push(line)
-      continue
-    }
-
-    const key = kvMatch[1]
-    const value = kvMatch[2].trim()
-
-    // skip if value is empty, already quoted, or uses block scalar
-    if (value === "" || value === ">" || 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 },
+    )
   }
 }