From 37588f067d65a5981f85c00bd0d53b2a2c12ad35 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sun, 25 Jan 2026 15:36:38 -0800 Subject: [PATCH 01/10] Initial impl --- bun.lock | 33 ++- cli/src/chat.tsx | 12 +- cli/src/commands/command-registry.ts | 73 +++++- cli/src/data/slash-commands.ts | 16 ++ cli/src/index.tsx | 4 + cli/src/utils/skill-registry.ts | 94 +++++++ common/src/constants/skills.ts | 60 +++++ .../initial-agents-dir/skills/README.md | 64 +++++ .../skills/example-skill/SKILL.md | 29 +++ common/src/tools/constants.ts | 2 + common/src/tools/list.ts | 2 + common/src/tools/params/tool/skill.ts | 56 +++++ common/src/types/skill.ts | 56 +++++ common/src/util/file.ts | 4 + common/src/util/skills.ts | 32 +++ packages/agent-runtime/src/run-agent-step.ts | 1 + .../agent-runtime/src/tools/handlers/list.ts | 2 + .../src/tools/handlers/tool/skill.ts | 53 ++++ packages/agent-runtime/src/tools/prompts.ts | 79 ++++-- sdk/package.json | 1 + sdk/src/index.ts | 4 + sdk/src/run-state.ts | 5 + sdk/src/skills/load-skills.ts | 237 ++++++++++++++++++ 23 files changed, 884 insertions(+), 35 deletions(-) create mode 100644 cli/src/utils/skill-registry.ts create mode 100644 common/src/constants/skills.ts create mode 100644 common/src/templates/initial-agents-dir/skills/README.md create mode 100644 common/src/templates/initial-agents-dir/skills/example-skill/SKILL.md create mode 100644 common/src/tools/params/tool/skill.ts create mode 100644 common/src/types/skill.ts create mode 100644 common/src/util/skills.ts create mode 100644 packages/agent-runtime/src/tools/handlers/tool/skill.ts create mode 100644 sdk/src/skills/load-skills.ts diff --git a/bun.lock b/bun.lock index c99b6f462a..e0d29a2434 100644 --- a/bun.lock +++ b/bun.lock @@ -199,6 +199,7 @@ "@vscode/tree-sitter-wasm": "0.1.4", "ai": "^5.0.52", "diff": "8.0.3", + "gray-matter": "^4.0.3", "ignore": "7.0.5", "micromatch": "^4.0.8", "web-tree-sitter": "0.25.6", @@ -1440,7 +1441,7 @@ "arg": ["arg@4.1.3", "", {}, "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA=="], - "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=="], @@ -2454,7 +2455,7 @@ "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], + "js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="], "jsbi": ["jsbi@4.3.2", "", {}, "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew=="], @@ -3636,6 +3637,8 @@ "@eslint/eslintrc/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "@eslint/eslintrc/js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], + "@iconify/utils/globals": ["globals@15.15.0", "", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="], "@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="], @@ -3650,8 +3653,6 @@ "@istanbuljs/load-nyc-config/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], - "@istanbuljs/load-nyc-config/js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="], - "@jest/console/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "@jest/core/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -3790,7 +3791,7 @@ "@unrs/resolver-binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], - "@yarnpkg/parsers/js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="], + "@zkochan/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "accepts/mime-types": ["mime-types@3.0.1", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA=="], @@ -3818,6 +3819,8 @@ "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "cosmiconfig/js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], + "cosmiconfig-typescript-loader/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "create-jest/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -3844,6 +3847,8 @@ "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "eslint/js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], + "eslint/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "eslint-config-next/@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.46.2", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/type-utils": "8.46.2", "@typescript-eslint/utils": "8.46.2", "@typescript-eslint/visitor-keys": "8.46.2", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.46.2", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-ZGBMToy857/NIPaaCucIUQgqueOiq7HeAKkhlvqVV4lm089zUFW6ikRySx2v+cAhKeUCPuWVHeimyk6Dw1iY3w=="], @@ -3880,8 +3885,6 @@ "finalhandler/statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], - "front-matter/js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="], - "gaxios/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], "gaxios/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], @@ -3896,8 +3899,6 @@ "globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], - "gray-matter/js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="], - "http-errors/statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], "image-q/@types/node": ["@types/node@16.9.1", "", {}, "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="], @@ -4228,6 +4229,8 @@ "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], + "@eslint/eslintrc/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "@inquirer/core/wrap-ansi/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=="], "@inquirer/core/wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -4238,8 +4241,6 @@ "@istanbuljs/load-nyc-config/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], - "@istanbuljs/load-nyc-config/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], - "@jest/console/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "@jest/core/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -4326,8 +4327,6 @@ "@unrs/resolver-binding-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], - "@yarnpkg/parsers/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], - "accepts/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], "app-path/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], @@ -4352,6 +4351,8 @@ "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "cosmiconfig/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "create-jest/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], @@ -4376,6 +4377,8 @@ "eslint/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "eslint/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "eslint/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "execa/npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], @@ -4384,16 +4387,12 @@ "filelist/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], - "front-matter/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], - "gaxios/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "gaxios/node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], "glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], - "gray-matter/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], - "jest-changed-files/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], "jest-changed-files/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index 4ecb763640..f32a6bf2a8 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -21,7 +21,7 @@ import { MessageWithAgents } from './components/message-with-agents' import { PendingBashMessage } from './components/pending-bash-message' import { StatusBar } from './components/status-bar' import { TopBanner } from './components/top-banner' -import { SLASH_COMMANDS } from './data/slash-commands' +import { getSlashCommandsWithSkills } from './data/slash-commands' import { useAgentValidation } from './hooks/use-agent-validation' import { useAskUserBridge } from './hooks/use-ask-user-bridge' import { useChatInput } from './hooks/use-chat-input' @@ -63,6 +63,7 @@ import { createDefaultChatKeyboardState, } from './utils/keyboard-actions' import { loadLocalAgents } from './utils/local-agent-registry' +import { getLoadedSkills } from './utils/skill-registry' import { getStatusIndicatorState, type AuthStatus, @@ -205,15 +206,20 @@ export const Chat = ({ const setInputMode = useChatStore((state) => state.setInputMode) const askUserState = useChatStore((state) => state.askUserState) + // Get loaded skills for slash commands + const loadedSkills = useMemo(() => getLoadedSkills(), []) + // Filter slash commands based on current ads state - only show the option that changes state + // Also merge in skill commands const filteredSlashCommands = useMemo(() => { const adsEnabled = getAdsEnabled() - return SLASH_COMMANDS.filter((cmd) => { + const allCommands = getSlashCommandsWithSkills(loadedSkills) + return allCommands.filter((cmd) => { if (cmd.id === 'ads:enable') return !adsEnabled if (cmd.id === 'ads:disable') return adsEnabled return true }) - }, [inputValue]) // Re-evaluate when input changes (user may have just toggled) + }, [inputValue, loadedSkills]) // Re-evaluate when input changes (user may have just toggled) const { slashContext, diff --git a/cli/src/commands/command-registry.ts b/cli/src/commands/command-registry.ts index 4c4efb555d..5901c9fedb 100644 --- a/cli/src/commands/command-registry.ts +++ b/cli/src/commands/command-registry.ts @@ -15,6 +15,7 @@ import { useLoginStore } from '../state/login-store' import { capturePendingAttachments } from '../utils/pending-attachments' import { AGENT_MODES } from '../utils/constants' import { getSystemMessage, getUserMessage } from '../utils/message-history' +import { getSkillByName } from '../utils/skill-registry' import type { MultilineInputHandle } from '../components/multiline-input' import type { InputValue, PendingAttachment } from '../state/chat-store' @@ -490,7 +491,77 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ export function findCommand(cmd: string): CommandDefinition | undefined { const lowerCmd = cmd.toLowerCase() - return COMMAND_REGISTRY.find( + + // First check the static command registry + const staticCommand = COMMAND_REGISTRY.find( (def) => def.name === lowerCmd || def.aliases.includes(lowerCmd), ) + if (staticCommand) { + return staticCommand + } + + // Check if this is a skill command + const skill = getSkillByName(lowerCmd) + if (skill) { + return createSkillCommand(skill.name) + } + + return undefined +} + +/** + * Creates a dynamic command definition for a skill. + * When invoked, the skill's content is sent to the agent. + */ +function createSkillCommand(skillName: string): CommandDefinition { + return defineCommandWithArgs({ + name: skillName, + handler: (params, args) => { + const skill = getSkillByName(skillName) + if (!skill) { + params.setMessages((prev) => [ + ...prev, + getUserMessage(params.inputValue.trim()), + getSystemMessage(`Skill not found: ${skillName}`), + ]) + params.saveToHistory(params.inputValue.trim()) + params.setInputValue({ text: '', cursorPosition: 0, lastEditDueToNav: false }) + return + } + + const trimmed = params.inputValue.trim() + params.saveToHistory(trimmed) + params.setInputValue({ text: '', cursorPosition: 0, lastEditDueToNav: false }) + + // Build the message content with skill context and optional user args + const skillContext = ` +${skill.content} +` + + const userPrompt = args.trim() + ? `${skillContext}\n\nUser request: ${args.trim()}` + : `${skillContext}\n\nPlease use this skill to help me.` + + // Check streaming/queue state + if ( + params.isStreaming || + params.streamMessageIdRef.current || + params.isChainInProgressRef.current + ) { + const pendingAttachments = capturePendingAttachments() + params.addToQueue(userPrompt, pendingAttachments) + params.setInputFocused(true) + params.inputRef.current?.focus() + return + } + + params.sendMessage({ + content: userPrompt, + agentMode: params.agentMode, + }) + setTimeout(() => { + params.scrollToLatest() + }, 0) + }, + }) } diff --git a/cli/src/data/slash-commands.ts b/cli/src/data/slash-commands.ts index 44fa8f18a5..52dd4ca3ba 100644 --- a/cli/src/data/slash-commands.ts +++ b/cli/src/data/slash-commands.ts @@ -1,3 +1,5 @@ +import type { SkillsMap } from '@codebuff/common/types/skill' + import { AGENT_MODES } from '../utils/constants' export interface SlashCommand { @@ -150,3 +152,17 @@ export const SLASHLESS_COMMAND_IDS = new Set( cmd.id.toLowerCase(), ), ) + +/** + * Returns SLASH_COMMANDS merged with skill commands. + * Skills become slash commands that users can invoke directly. + */ +export function getSlashCommandsWithSkills(skills: SkillsMap): SlashCommand[] { + const skillCommands: SlashCommand[] = Object.values(skills).map((skill) => ({ + id: skill.name, + label: skill.name, + description: skill.description, + })) + + return [...SLASH_COMMANDS, ...skillCommands] +} diff --git a/cli/src/index.tsx b/cli/src/index.tsx index 2bb75ca5a9..6b27358987 100644 --- a/cli/src/index.tsx +++ b/cli/src/index.tsx @@ -27,6 +27,7 @@ import { getAuthTokenDetails } from './utils/auth' import { resetCodebuffClient } from './utils/codebuff-client' import { getCliEnv } from './utils/env' import { initializeAgentRegistry } from './utils/local-agent-registry' +import { initializeSkillRegistry } from './utils/skill-registry' import { clearLogFile, logger } from './utils/logger' import { shouldShowProjectPicker } from './utils/project-picker' import { saveRecentProject } from './utils/recent-projects' @@ -190,6 +191,9 @@ async function main(): Promise { await initializeAgentRegistry() } + // Initialize skill registry (loads skills from .agents/skills) + await initializeSkillRegistry() + // Handle publish command before rendering the app if (isPublishCommand) { const publishIndex = process.argv.indexOf('publish') diff --git a/cli/src/utils/skill-registry.ts b/cli/src/utils/skill-registry.ts new file mode 100644 index 0000000000..8cc8e8480e --- /dev/null +++ b/cli/src/utils/skill-registry.ts @@ -0,0 +1,94 @@ +import { loadSkills as sdkLoadSkills } from '@codebuff/sdk' + +import { getProjectRoot } from '../project-files' +import { logger } from './logger' + +import type { SkillDefinition, SkillsMap } from '@codebuff/common/types/skill' + +// ============================================================================ +// Skills cache (loaded via SDK at startup) +// ============================================================================ + +let skillsCache: SkillsMap = {} + +/** + * Initialize the skill registry by loading skills via the SDK. + * This must be called at CLI startup. + * + * Skills are loaded from: + * - ~/.agents/skills/ (global) + * - {projectRoot}/.agents/skills/ (project, overrides global) + */ +export async function initializeSkillRegistry(): Promise { + const cwd = getProjectRoot() || process.cwd() + + try { + // Load skills from both global (~/.agents/skills) and project directories + // The SDK handles merging, with project skills overriding global ones + skillsCache = await sdkLoadSkills({ + cwd, + verbose: false, + }) + } catch (error) { + logger.warn({ error }, 'Failed to load skills') + skillsCache = {} + } +} + +// ============================================================================ +// Skills access +// ============================================================================ + +/** + * Get all loaded skills. + */ +export function getLoadedSkills(): SkillsMap { + return skillsCache +} + +/** + * Get a skill by name. + */ +export function getSkillByName(name: string): SkillDefinition | undefined { + return skillsCache[name] +} + +/** + * Get the number of loaded skills. + */ +export function getSkillCount(): number { + return Object.keys(skillsCache).length +} + +// ============================================================================ +// UI/Display utilities +// ============================================================================ + +/** + * Get a message describing loaded skills for display. + */ +export function getLoadedSkillsMessage(): string | null { + const skills = Object.values(skillsCache) + + if (skills.length === 0) { + return null + } + + const header = `Loaded ${skills.length} skill${skills.length === 1 ? '' : 's'}` + const skillList = skills + .map((skill) => ` - ${skill.name}: ${skill.description.slice(0, 60)}${skill.description.length > 60 ? '...' : ''}`) + .join('\n') + + return `${header}\n${skillList}` +} + +// ============================================================================ +// Testing utilities +// ============================================================================ + +/** + * Clear cached skills. Intended for test scenarios. + */ +export function __resetSkillRegistryForTests(): void { + skillsCache = {} +} diff --git a/common/src/constants/skills.ts b/common/src/constants/skills.ts new file mode 100644 index 0000000000..63b8d95a89 --- /dev/null +++ b/common/src/constants/skills.ts @@ -0,0 +1,60 @@ +/** + * Skills constants and validation rules. + * + * Skills are SKILL.md files with YAML frontmatter that define reusable + * instructions that agents can load on-demand via the skill tool. + */ + +/** + * The directory name where skills are stored (within .agents/). + */ +export const SKILLS_DIR_NAME = 'skills' + +/** + * The file name for skill definitions. + */ +export const SKILL_FILE_NAME = 'SKILL.md' + +/** + * Validation regex for skill names. + * - 1-64 characters + * - Lowercase alphanumeric with single hyphen separators + * - Cannot start or end with hyphen + * - No consecutive hyphens + */ +export const SKILL_NAME_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/ + +/** + * Maximum length for skill name. + */ +export const SKILL_NAME_MAX_LENGTH = 64 + +/** + * Maximum length for skill description. + */ +export const SKILL_DESCRIPTION_MAX_LENGTH = 1024 + +/** + * Validates a skill name according to the naming rules. + * @param name - The skill name to validate + * @returns true if valid, false otherwise + */ +export function isValidSkillName(name: string): boolean { + if (!name || name.length > SKILL_NAME_MAX_LENGTH) { + return false + } + return SKILL_NAME_REGEX.test(name) +} + +/** + * Validates a skill description according to length rules. + * @param description - The skill description to validate + * @returns true if valid, false otherwise + */ +export function isValidSkillDescription(description: string): boolean { + return ( + typeof description === 'string' && + description.length >= 1 && + description.length <= SKILL_DESCRIPTION_MAX_LENGTH + ) +} diff --git a/common/src/templates/initial-agents-dir/skills/README.md b/common/src/templates/initial-agents-dir/skills/README.md new file mode 100644 index 0000000000..48414203a4 --- /dev/null +++ b/common/src/templates/initial-agents-dir/skills/README.md @@ -0,0 +1,64 @@ +# Skills + +Skills are reusable instruction sets that agents can load on-demand via the `skill` tool. + +## Creating a Skill + +1. Create a directory with your skill name (lowercase alphanumeric with hyphens): + ``` + .agents/skills/my-skill/ + ``` + +2. Create a `SKILL.md` file with YAML frontmatter: + ```markdown + --- + name: my-skill + description: A short description of what this skill does + license: MIT + metadata: + category: development + --- + + # My Skill + + Instructions and content for the skill... + ``` + +## Frontmatter Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `name` | Yes | Skill name (1-64 chars, lowercase alphanumeric with hyphens, must match directory name) | +| `description` | Yes | Short description (1-1024 chars) used for agent discovery | +| `license` | No | License identifier (e.g., "MIT", "Apache-2.0") | +| `metadata` | No | Key-value pairs for additional categorization | + +## Name Validation + +Skill names must: +- Be 1-64 characters long +- Use only lowercase letters, numbers, and hyphens +- Not start or end with a hyphen +- Not contain consecutive hyphens +- Match the directory name exactly + +Valid examples: `git-release`, `api-design`, `review2` +Invalid examples: `Git-Release`, `my--skill`, `-skill`, `skill-` + +## Discovery Locations + +Skills are discovered from these locations (in order of precedence): +1. `~/.agents/skills/` (global, lowest priority) +2. `.agents/skills/` (project, highest priority) + +Project skills override global skills with the same name. + +## How Agents Use Skills + +Agents see available skills listed in the `skill` tool description. When an agent needs a skill's instructions, it calls: + +``` +skill({ name: "my-skill" }) +``` + +The full SKILL.md content is then returned to the agent. diff --git a/common/src/templates/initial-agents-dir/skills/example-skill/SKILL.md b/common/src/templates/initial-agents-dir/skills/example-skill/SKILL.md new file mode 100644 index 0000000000..d2644c2e88 --- /dev/null +++ b/common/src/templates/initial-agents-dir/skills/example-skill/SKILL.md @@ -0,0 +1,29 @@ +--- +name: example-skill +description: An example skill demonstrating the SKILL.md format +license: MIT +metadata: + category: examples + audience: developers +--- + +# Example Skill + +This is an example skill that demonstrates the SKILL.md format. + +## When to use this skill + +Use this skill when you need an example of how skills work. + +## Instructions + +1. Skills are loaded on-demand via the `skill` tool +2. The agent sees available skills listed in the tool description +3. When needed, the agent calls `skill({ name: "example-skill" })` to load the full content +4. The skill content is then available in the conversation context + +## Notes + +- Skills should have clear, specific descriptions +- The name must be lowercase alphanumeric with hyphens +- The name must match the directory name diff --git a/common/src/tools/constants.ts b/common/src/tools/constants.ts index 123a4e0d8e..a7cbeba73e 100644 --- a/common/src/tools/constants.ts +++ b/common/src/tools/constants.ts @@ -40,6 +40,7 @@ export const toolNames = [ 'run_terminal_command', 'set_messages', 'set_output', + 'skill', 'spawn_agents', 'spawn_agent_inline', 'str_replace', @@ -70,6 +71,7 @@ export const publishedTools = [ 'run_terminal_command', 'set_messages', 'set_output', + 'skill', 'spawn_agents', 'str_replace', 'suggest_followups', diff --git a/common/src/tools/list.ts b/common/src/tools/list.ts index bc2157b1c5..1cd7d9f66d 100644 --- a/common/src/tools/list.ts +++ b/common/src/tools/list.ts @@ -21,6 +21,7 @@ import { runFileChangeHooksParams } from './params/tool/run-file-change-hooks' import { runTerminalCommandParams } from './params/tool/run-terminal-command' import { setMessagesParams } from './params/tool/set-messages' import { setOutputParams } from './params/tool/set-output' +import { skillParams } from './params/tool/skill' import { spawnAgentInlineParams } from './params/tool/spawn-agent-inline' import { spawnAgentsParams } from './params/tool/spawn-agents' import { strReplaceParams } from './params/tool/str-replace' @@ -57,6 +58,7 @@ export const toolParams = { run_terminal_command: runTerminalCommandParams, set_messages: setMessagesParams, set_output: setOutputParams, + skill: skillParams, spawn_agents: spawnAgentsParams, spawn_agent_inline: spawnAgentInlineParams, str_replace: strReplaceParams, diff --git a/common/src/tools/params/tool/skill.ts b/common/src/tools/params/tool/skill.ts new file mode 100644 index 0000000000..8c43419608 --- /dev/null +++ b/common/src/tools/params/tool/skill.ts @@ -0,0 +1,56 @@ +import z from 'zod/v4' + +import { $getNativeToolCallExampleString, jsonToolResultSchema } from '../utils' + +import type { $ToolParams } from '../../constants' + +const toolName = 'skill' +const endsAgentStep = true + +const inputSchema = z + .object({ + name: z + .string() + .min(1) + .describe('The name of the skill to load'), + }) + .describe( + 'Load a skill by name to get its full instructions. Skills provide reusable behaviors and instructions.', + ) + +const outputValueSchema = z.object({ + name: z.string(), + description: z.string(), + content: z.string(), + license: z.string().optional(), +}) + +/** + * Placeholder marker that will be replaced with the actual available skills XML. + * This is replaced at runtime when generating tool prompts. + */ +export const AVAILABLE_SKILLS_PLACEHOLDER = '{{AVAILABLE_SKILLS}}' + +// Base description - the full description with available skills is generated dynamically +const baseDescription = `Load a skill by name to get its full instructions. Skills provide reusable behaviors and domain-specific knowledge that you can use to complete tasks. + +${AVAILABLE_SKILLS_PLACEHOLDER} + +Example: +${$getNativeToolCallExampleString({ + toolName, + inputSchema, + input: { + name: 'git-release', + }, + endsAgentStep, +})} +` + +export const skillParams = { + toolName, + endsAgentStep, + description: baseDescription.trim(), + inputSchema, + outputSchema: jsonToolResultSchema(outputValueSchema), +} satisfies $ToolParams diff --git a/common/src/types/skill.ts b/common/src/types/skill.ts new file mode 100644 index 0000000000..c89a24cb94 --- /dev/null +++ b/common/src/types/skill.ts @@ -0,0 +1,56 @@ +import { z } from 'zod/v4' + +import { + SKILL_NAME_MAX_LENGTH, + SKILL_NAME_REGEX, + SKILL_DESCRIPTION_MAX_LENGTH, +} from '../constants/skills' + +/** + * Zod schema for skill frontmatter metadata. + */ +export const SkillMetadataSchema = z.record(z.string(), z.string()) + +/** + * Zod schema for skill frontmatter (parsed from YAML). + */ +export const SkillFrontmatterSchema = z.object({ + name: z + .string() + .min(1) + .max(SKILL_NAME_MAX_LENGTH) + .regex( + SKILL_NAME_REGEX, + 'Name must be lowercase alphanumeric with single hyphen separators', + ), + description: z.string().min(1).max(SKILL_DESCRIPTION_MAX_LENGTH), + license: z.string().optional(), + metadata: SkillMetadataSchema.optional(), +}) + +export type SkillFrontmatter = z.infer + +/** + * Full skill definition including content and source path. + */ +export const SkillDefinitionSchema = z.object({ + /** Skill name (must match directory name) */ + name: z.string(), + /** Short description for agent discovery */ + description: z.string(), + /** Optional license */ + license: z.string().optional(), + /** Optional key-value metadata */ + metadata: SkillMetadataSchema.optional(), + /** Full SKILL.md content (including frontmatter) */ + content: z.string(), + /** Source file path */ + filePath: z.string(), +}) + +export type SkillDefinition = z.infer + +/** + * Collection of skills keyed by skill name. + */ +export type SkillsMap = Record diff --git a/common/src/util/file.ts b/common/src/util/file.ts index a31350a38e..dc47f28ee2 100644 --- a/common/src/util/file.ts +++ b/common/src/util/file.ts @@ -4,6 +4,7 @@ import * as path from 'path' import { z } from 'zod/v4' import type { CodebuffFileSystem } from '../types/filesystem' +import type { SkillsMap } from '../types/skill' export const FileTreeNodeSchema: z.ZodType = z.object({ name: z.string(), @@ -67,6 +68,7 @@ export const ProjectFileContextSchema = z.object({ userKnowledgeFiles: z.record(z.string(), z.string()).optional(), agentTemplates: z.record(z.string(), z.any()).default(() => ({})), customToolDefinitions: customToolDefinitionsSchema, + skills: z.record(z.string(), z.any()).optional(), gitChanges: z.object({ status: z.string(), diff: z.string(), @@ -95,6 +97,7 @@ export type ProjectFileContext = { userKnowledgeFiles?: Record agentTemplates: Record customToolDefinitions: CustomToolDefinitions + skills?: SkillsMap gitChanges: { status: string diff: string @@ -138,6 +141,7 @@ export const getStubProjectFileContext = (): ProjectFileContext => ({ userKnowledgeFiles: {}, agentTemplates: {}, customToolDefinitions: {}, + skills: {}, gitChanges: { status: '', diff: '', diff --git a/common/src/util/skills.ts b/common/src/util/skills.ts new file mode 100644 index 0000000000..9f92dd82ab --- /dev/null +++ b/common/src/util/skills.ts @@ -0,0 +1,32 @@ +import type { SkillsMap } from '../types/skill' + +/** + * Escapes special XML characters in a string. + */ +function escapeXml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +/** + * Formats available skills as XML for inclusion in tool descriptions. + */ +export function formatAvailableSkillsXml(skills: SkillsMap): string { + const skillEntries = Object.values(skills) + if (skillEntries.length === 0) { + return '' + } + + const skillsXml = skillEntries + .map( + (skill) => + ` \n ${skill.name}\n ${escapeXml(skill.description)}\n `, + ) + .join('\n') + + return `\n${skillsXml}\n` +} diff --git a/packages/agent-runtime/src/run-agent-step.ts b/packages/agent-runtime/src/run-agent-step.ts index 9135827984..a3f348ab71 100644 --- a/packages/agent-runtime/src/run-agent-step.ts +++ b/packages/agent-runtime/src/run-agent-step.ts @@ -638,6 +638,7 @@ export async function loopAgentSteps( return cachedAdditionalToolDefinitions }, agentTools, + skills: fileContext.skills, }) const hasUserMessage = Boolean( diff --git a/packages/agent-runtime/src/tools/handlers/list.ts b/packages/agent-runtime/src/tools/handlers/list.ts index d75eb829a9..103388e831 100644 --- a/packages/agent-runtime/src/tools/handlers/list.ts +++ b/packages/agent-runtime/src/tools/handlers/list.ts @@ -18,6 +18,7 @@ import { handleRunFileChangeHooks } from './tool/run-file-change-hooks' import { handleRunTerminalCommand } from './tool/run-terminal-command' import { handleSetMessages } from './tool/set-messages' import { handleSetOutput } from './tool/set-output' +import { handleSkill } from './tool/skill' import { handleSpawnAgentInline } from './tool/spawn-agent-inline' import { handleSpawnAgents } from './tool/spawn-agents' import { handleStrReplace } from './tool/str-replace' @@ -62,6 +63,7 @@ export const codebuffToolHandlers = { run_terminal_command: handleRunTerminalCommand, set_messages: handleSetMessages, set_output: handleSetOutput, + skill: handleSkill, spawn_agents: handleSpawnAgents, spawn_agent_inline: handleSpawnAgentInline, str_replace: handleStrReplace, diff --git a/packages/agent-runtime/src/tools/handlers/tool/skill.ts b/packages/agent-runtime/src/tools/handlers/tool/skill.ts new file mode 100644 index 0000000000..0c2956a117 --- /dev/null +++ b/packages/agent-runtime/src/tools/handlers/tool/skill.ts @@ -0,0 +1,53 @@ +import { jsonToolResult } from '@codebuff/common/util/messages' + +import type { CodebuffToolHandlerFunction } from '../handler-function-type' +import type { + CodebuffToolCall, + CodebuffToolOutput, +} from '@codebuff/common/tools/list' +import type { ProjectFileContext } from '@codebuff/common/util/file' + +type ToolName = 'skill' + +export const handleSkill = (async (params: { + previousToolCallFinished: Promise + toolCall: CodebuffToolCall + fileContext: ProjectFileContext +}): Promise<{ output: CodebuffToolOutput }> => { + const { previousToolCallFinished, toolCall, fileContext } = params + const { name } = toolCall.input + + await previousToolCallFinished + + const skills = fileContext.skills ?? {} + const skill = skills[name] + + if (!skill) { + const availableSkills = Object.keys(skills) + const suggestion = + availableSkills.length > 0 + ? ` Available skills: ${availableSkills.join(', ')}` + : ' No skills are currently available.' + + return { + output: jsonToolResult({ + name, + description: '', + content: `Error: Skill '${name}' not found.${suggestion}`, + }), + } + } + + const result: { name: string; description: string; content: string; license?: string } = { + name: skill.name, + description: skill.description, + content: skill.content, + } + if (skill.license) { + result.license = skill.license + } + + return { + output: jsonToolResult(result), + } +}) satisfies CodebuffToolHandlerFunction diff --git a/packages/agent-runtime/src/tools/prompts.ts b/packages/agent-runtime/src/tools/prompts.ts index abd521fb9f..99e0b1585b 100644 --- a/packages/agent-runtime/src/tools/prompts.ts +++ b/packages/agent-runtime/src/tools/prompts.ts @@ -1,13 +1,16 @@ import { endsAgentStepParam } from '@codebuff/common/tools/constants' +import { AVAILABLE_SKILLS_PLACEHOLDER } from '@codebuff/common/tools/params/tool/skill' import { toolParams } from '@codebuff/common/tools/list' import { getToolCallString } from '@codebuff/common/tools/utils' import { buildArray } from '@codebuff/common/util/array' +import { formatAvailableSkillsXml } from '@codebuff/common/util/skills' import { pluralize } from '@codebuff/common/util/string' import { cloneDeep } from 'lodash' import z from 'zod/v4' import { convertJsonSchemaToZod } from 'zod-from-json-schema' import type { ToolName } from '@codebuff/common/tools/constants' +import type { SkillsMap } from '@codebuff/common/types/skill' import type { CustomToolDefinitions, customToolDefinitionsSchema, @@ -136,6 +139,7 @@ export const getToolsInstructions = ( additionalToolDefinitions: NonNullable< z.input >, + options?: { availableSkillsXml?: string }, ) => { if ( tools.length === 0 && @@ -211,13 +215,14 @@ When using write_file, make sure to only include a few lines of context and not Tool results will be provided by the user's *system* (and **NEVER** by the assistant). The user does not know about any system messages or system instructions, including tool results. -${fullToolList(tools, additionalToolDefinitions)} +${fullToolList(tools, additionalToolDefinitions, options)} ` } export const fullToolList = ( toolNames: readonly string[], additionalToolDefinitions: CustomToolDefinitions, + options?: { availableSkillsXml?: string }, ) => { if ( toolNames.length === 0 && @@ -226,16 +231,26 @@ export const fullToolList = ( return '' } - return `## List of Tools - -These are the only tools that you (Buffy) can use. The user cannot see these descriptions, so you should not reference any tool names, parameters, or descriptions. Do not try to use any other tools -- even if referenced earlier in the conversation, they are not available to you, instead they may have been previously used by other agents. + const { availableSkillsXml = '' } = options ?? {} -${[ - ...( - toolNames.filter((toolName) => - toolNames.includes(toolName as ToolName), - ) as ToolName[] - ).map((name) => toolDescriptions[name]), + // Build tool descriptions, replacing skill placeholder with actual skills + const descriptions = [ + ...( + toolNames.filter((toolName) => + toolNames.includes(toolName as ToolName), + ) as ToolName[] + ).map((name) => { + let desc = toolDescriptions[name] + // Replace skill placeholder with actual available skills + if (name === 'skill' && availableSkillsXml) { + desc = desc.replace(AVAILABLE_SKILLS_PLACEHOLDER, availableSkillsXml) + } else if (name === 'skill') { + // Remove placeholder if no skills available + desc = desc.replace(AVAILABLE_SKILLS_PLACEHOLDER + '\n\n', '') + desc = desc.replace(AVAILABLE_SKILLS_PLACEHOLDER, '') + } + return desc + }), ...Object.keys(additionalToolDefinitions).map((toolName) => { const toolDef = additionalToolDefinitions[toolName] return buildToolDescription({ @@ -245,8 +260,13 @@ ${[ endsAgentStep: toolDef.endsAgentStep ?? true, exampleInputs: toolDef.exampleInputs, }) - }), -].join('\n\n')}`.trim() + }), ] + + return `## List of Tools + +These are the only tools that you (Buffy) can use. The user cannot see these descriptions, so you should not reference any tool names, parameters, or descriptions. Do not try to use any other tools -- even if referenced earlier in the conversation, they are not available to you, instead they may have been previously used by other agents. + +${descriptions.join('\n\n')}`.trim() } export const getShortToolInstructions = ( @@ -307,13 +327,44 @@ export async function getToolSet(params: { toolNames: string[] additionalToolDefinitions: () => Promise agentTools: ToolSet + skills?: SkillsMap }): Promise { - const { toolNames, additionalToolDefinitions, agentTools } = params + const { toolNames, additionalToolDefinitions, agentTools, skills } = params + + // Generate available skills XML for the skill tool description + const availableSkillsXml = skills ? formatAvailableSkillsXml(skills) : '' const toolSet: ToolSet = {} for (const toolName of toolNames) { if (toolName in toolParams) { - toolSet[toolName] = toolParams[toolName as ToolName] + const toolDef = toolParams[toolName as ToolName] + + // For the skill tool, replace the placeholder with actual available skills + if (toolName === 'skill' && availableSkillsXml) { + let description = toolDef.description ?? '' + description = description.replace( + AVAILABLE_SKILLS_PLACEHOLDER, + availableSkillsXml, + ) + toolSet[toolName] = { + ...toolDef, + description, + } + } else if (toolName === 'skill') { + // Remove placeholder if no skills available + let description = toolDef.description ?? '' + description = description.replace( + AVAILABLE_SKILLS_PLACEHOLDER + '\n\n', + '', + ) + description = description.replace(AVAILABLE_SKILLS_PLACEHOLDER, '') + toolSet[toolName] = { + ...toolDef, + description, + } + } else { + toolSet[toolName] = toolDef + } } } diff --git a/sdk/package.json b/sdk/package.json index 77bf13b66b..dddd14a33f 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -63,6 +63,7 @@ "@vscode/tree-sitter-wasm": "0.1.4", "ai": "^5.0.52", "diff": "8.0.3", + "gray-matter": "^4.0.3", "ignore": "7.0.5", "micromatch": "^4.0.8", "web-tree-sitter": "0.25.6", diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 378758fb68..fa8f405c76 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -35,6 +35,10 @@ export * from './constants' export { getUserInfoFromApiKey } from './impl/database' export * from './credentials' export { loadLocalAgents } from './agents/load-agents' +export { loadSkills } from './skills/load-skills' +export { formatAvailableSkillsXml } from '@codebuff/common/util/skills' +export type { LoadSkillsOptions } from './skills/load-skills' +export type { SkillDefinition, SkillsMap } from '@codebuff/common/types/skill' export type { LoadedAgents, LoadedAgentDefinition, diff --git a/sdk/src/run-state.ts b/sdk/src/run-state.ts index 12b896af70..2786879a2e 100644 --- a/sdk/src/run-state.ts +++ b/sdk/src/run-state.ts @@ -17,6 +17,7 @@ import { cloneDeep } from 'lodash' import z from 'zod/v4' import { loadLocalAgents } from './agents/load-agents' +import { loadSkills } from './skills/load-skills' // Re-export for SDK consumers export { @@ -487,6 +488,9 @@ export async function initialSessionState( ...providedUserKnowledgeFiles, } + // Load skills from project and home directories + const skills = await loadSkills({ cwd: cwd ?? process.cwd(), verbose: false }) + const initialState = getInitialSessionState({ projectRoot: cwd ?? process.cwd(), cwd: cwd ?? process.cwd(), @@ -497,6 +501,7 @@ export async function initialSessionState( userKnowledgeFiles, agentTemplates: processedAgentTemplates, customToolDefinitions: processedCustomToolDefinitions, + skills, gitChanges, changesSinceLastChat: {}, shellConfigFiles: {}, diff --git a/sdk/src/skills/load-skills.ts b/sdk/src/skills/load-skills.ts new file mode 100644 index 0000000000..45b32173e8 --- /dev/null +++ b/sdk/src/skills/load-skills.ts @@ -0,0 +1,237 @@ +import fs from 'fs' +import matter from 'gray-matter' +import os from 'os' +import path from 'path' + +import { + SKILLS_DIR_NAME, + SKILL_FILE_NAME, + isValidSkillName, +} from '@codebuff/common/constants/skills' +import { + SkillFrontmatterSchema, + type SkillDefinition, + type SkillsMap, +} from '@codebuff/common/types/skill' + +// Re-export from common for backward compatibility +export { formatAvailableSkillsXml } from '@codebuff/common/util/skills' + +/** + * Parses YAML frontmatter from a SKILL.md file using gray-matter. + * Frontmatter is expected to be between --- markers at the start of the file. + */ +function parseFrontmatter(content: string): { + frontmatter: Record + body: string +} | null { + try { + const parsed = matter(content) + if (!parsed.data || Object.keys(parsed.data).length === 0) { + return null + } + return { + frontmatter: parsed.data as Record, + body: parsed.content, + } + } catch { + return null + } +} + +/** + * Loads a single skill from a SKILL.md file. + * Returns null if the skill is invalid. + */ +function loadSkillFromFile( + skillDir: string, + skillFilePath: string, + verbose: boolean, +): SkillDefinition | null { + const dirName = path.basename(skillDir) + + // Read the file + let content: string + try { + content = fs.readFileSync(skillFilePath, 'utf8') + } catch { + if (verbose) { + console.error(`Failed to read skill file: ${skillFilePath}`) + } + return null + } + + // Parse frontmatter + const parsed = parseFrontmatter(content) + if (!parsed) { + if (verbose) { + console.error(`Invalid frontmatter in skill file: ${skillFilePath}`) + } + return null + } + + // Validate frontmatter + const result = SkillFrontmatterSchema.safeParse(parsed.frontmatter) + if (!result.success) { + if (verbose) { + console.error( + `Invalid skill frontmatter in ${skillFilePath}: ${result.error.message}`, + ) + } + return null + } + + const frontmatter = result.data + + // Verify name matches directory name + if (frontmatter.name !== dirName) { + if (verbose) { + console.error( + `Skill name '${frontmatter.name}' does not match directory name '${dirName}' in ${skillFilePath}`, + ) + } + return null + } + + return { + name: frontmatter.name, + description: frontmatter.description, + license: frontmatter.license, + metadata: frontmatter.metadata, + content, + filePath: skillFilePath, + } +} + +/** + * Discovers skills from a skills directory. + * Looks for //SKILL.md files. + */ +function discoverSkillsFromDirectory( + skillsDir: string, + verbose: boolean, +): SkillsMap { + const skills: SkillsMap = {} + + let entries: string[] + try { + entries = fs.readdirSync(skillsDir) + } catch { + return skills + } + + for (const entry of entries) { + const skillDir = path.join(skillsDir, entry) + + // Skip non-directories and invalid skill names + try { + const stat = fs.statSync(skillDir) + if (!stat.isDirectory()) continue + } catch { + continue + } + + if (!isValidSkillName(entry)) { + if (verbose) { + console.warn(`Skipping invalid skill directory name: ${entry}`) + } + continue + } + + const skillFilePath = path.join(skillDir, SKILL_FILE_NAME) + + // Check if SKILL.md exists + try { + fs.statSync(skillFilePath) + } catch { + continue + } + + const skill = loadSkillFromFile(skillDir, skillFilePath, verbose) + if (skill) { + skills[skill.name] = skill + } + } + + return skills +} + +/** + * Gets the default skills directories to search. + * Searches both .claude/skills and .agents/skills for Claude Code compatibility. + * + * Order (later overrides earlier): + * - ~/.claude/skills/ (global Claude-compatible) + * - ~/.agents/skills/ (global Codebuff) + * - {cwd}/.claude/skills/ (project Claude-compatible) + * - {cwd}/.agents/skills/ (project Codebuff) + */ +function getDefaultSkillsDirs(cwd: string): string[] { + const home = os.homedir() + return [ + // Global directories (Claude-compatible first, then Codebuff) + path.join(home, '.claude', SKILLS_DIR_NAME), + path.join(home, '.agents', SKILLS_DIR_NAME), + // Project directories (Claude-compatible first, then Codebuff) + path.join(cwd, '.claude', SKILLS_DIR_NAME), + path.join(cwd, '.agents', SKILLS_DIR_NAME), + ] +} + +export type LoadSkillsOptions = { + /** Working directory for project skills. Defaults to process.cwd() */ + cwd?: string + /** Optional specific skills directory path */ + skillsPath?: string + /** Whether to log errors during loading */ + verbose?: boolean +} + +/** + * Load skills from .agents/skills and .claude/skills directories. + * + * By default, searches for skills in (later overrides earlier): + * - `~/.claude/skills/` (global, Claude Code compatible) + * - `~/.agents/skills/` (global) + * - `{cwd}/.claude/skills/` (project, Claude Code compatible) + * - `{cwd}/.agents/skills/` (project, highest priority) + * + * Each skill must be in its own directory with a SKILL.md file: + * - `.agents/skills/my-skill/SKILL.md` + * - `.claude/skills/my-skill/SKILL.md` + * + * @param options.cwd - Working directory for project skills + * @param options.skillsPath - Optional path to a specific skills directory + * @param options.verbose - Whether to log errors during loading + * @returns Record of skill definitions keyed by skill name + * + * @example + * ```typescript + * // Load from default locations + * const skills = await loadSkills({ verbose: true }) + * + * // Load from a specific directory + * const skills = await loadSkills({ skillsPath: './my-skills' }) + * + * // Access a skill + * const gitReleaseSkill = skills['git-release'] + * console.log(gitReleaseSkill.description) + * ``` + */ +export async function loadSkills(options: LoadSkillsOptions = {}): Promise { + const { cwd = process.cwd(), skillsPath, verbose = false } = options + + const skills: SkillsMap = {} + + const skillsDirs = skillsPath ? [skillsPath] : getDefaultSkillsDirs(cwd) + + for (const skillsDir of skillsDirs) { + const dirSkills = discoverSkillsFromDirectory(skillsDir, verbose) + // Later directories override earlier ones (project overrides global) + Object.assign(skills, dirSkills) + } + + return skills +} + + From b2d79216545fcd0ca43ee3d82de3f1a186387118 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sun, 25 Jan 2026 16:35:01 -0800 Subject: [PATCH 02/10] Give skill instructions in system prompt --- agents/base2/base2.ts | 2 ++ agents/types/secret-agent-definition.ts | 2 ++ common/src/util/skills.ts | 28 ++++++++++++++++++- .../agent-runtime/src/templates/strings.ts | 2 ++ packages/agent-runtime/src/templates/types.ts | 1 + 5 files changed, 34 insertions(+), 1 deletion(-) diff --git a/agents/base2/base2.ts b/agents/base2/base2.ts index 66584c215a..abe5fca922 100644 --- a/agents/base2/base2.ts +++ b/agents/base2/base2.ts @@ -63,6 +63,7 @@ export function createBase2( 'propose_write_file', !noAskUser && 'ask_user', 'set_output', + 'skill', ), spawnableAgents: buildArray( !isMax && 'file-picker', @@ -232,6 +233,7 @@ ${isDefault || isMax ${PLACEHOLDER.FILE_TREE_PROMPT_SMALL} ${PLACEHOLDER.KNOWLEDGE_FILES_CONTENTS} +${PLACEHOLDER.SKILLS_PROMPT} ${PLACEHOLDER.SYSTEM_INFO_PROMPT} # Initial Git Changes diff --git a/agents/types/secret-agent-definition.ts b/agents/types/secret-agent-definition.ts index fa0656f557..045c506a26 100644 --- a/agents/types/secret-agent-definition.ts +++ b/agents/types/secret-agent-definition.ts @@ -7,6 +7,7 @@ export type AllToolNames = | 'add_subgoal' | 'browser_logs' | 'create_plan' + | 'skill' | 'spawn_agent_inline' | 'update_subgoal' @@ -31,6 +32,7 @@ const placeholderNames = [ 'KNOWLEDGE_FILES_CONTENTS', 'PROJECT_ROOT', 'REMAINING_STEPS', + 'SKILLS_PROMPT', 'SYSTEM_INFO_PROMPT', 'TOOLS_PROMPT', 'USER_CWD', diff --git a/common/src/util/skills.ts b/common/src/util/skills.ts index 9f92dd82ab..5585d13609 100644 --- a/common/src/util/skills.ts +++ b/common/src/util/skills.ts @@ -3,7 +3,7 @@ import type { SkillsMap } from '../types/skill' /** * Escapes special XML characters in a string. */ -function escapeXml(str: string): string { +export function escapeXml(str: string): string { return str .replace(/&/g, '&') .replace(/\n${skillsXml}\n` } + +/** + * Formats skills as a system prompt section for injection into agent prompts. + * Returns a markdown section with available skills and instructions on using the skill tool. + * Returns empty string if no skills are available. + */ +export function formatSkillsSystemPrompt(skills: SkillsMap | undefined): string { + if (!skills) { + return '' + } + + const skillEntries = Object.values(skills) + if (skillEntries.length === 0) { + return '' + } + + const skillsXml = formatAvailableSkillsXml(skills) + + return `# Available Skills + +The following skills are available to help you complete tasks. Each skill provides specialized instructions and behaviors. + +${skillsXml} + +Use the \`skill\` tool to load a skill's full instructions when relevant to the current task. Skills are loaded on-demand - only load them when you need their specific guidance. Always load any relevant skills immediately: You should bias toward loading too many skills as early as possible.` +} diff --git a/packages/agent-runtime/src/templates/strings.ts b/packages/agent-runtime/src/templates/strings.ts index f145feaf41..ad3e91825b 100644 --- a/packages/agent-runtime/src/templates/strings.ts +++ b/packages/agent-runtime/src/templates/strings.ts @@ -1,4 +1,5 @@ import { KNOWLEDGE_FILE_NAMES_LOWERCASE } from '@codebuff/common/constants/knowledge' +import { formatSkillsSystemPrompt } from '@codebuff/common/util/skills' import { escapeString } from '@codebuff/common/util/string' import { z } from 'zod/v4' @@ -131,6 +132,7 @@ export async function formatPrompt( return `\`\`\`${path}\n${content.trim()}\n\`\`\`` }) .join('\n\n'), + [PLACEHOLDER.SKILLS_PROMPT]: () => formatSkillsSystemPrompt(fileContext.skills), } for (const varName of placeholderValues) { diff --git a/packages/agent-runtime/src/templates/types.ts b/packages/agent-runtime/src/templates/types.ts index 6ce6739631..92e12bfb18 100644 --- a/packages/agent-runtime/src/templates/types.ts +++ b/packages/agent-runtime/src/templates/types.ts @@ -21,6 +21,7 @@ const placeholderNames = [ 'KNOWLEDGE_FILES_CONTENTS', 'PROJECT_ROOT', 'REMAINING_STEPS', + 'SKILLS_PROMPT', 'SYSTEM_INFO_PROMPT', 'USER_CWD', 'USER_INPUT_PROMPT', From 917d1ad533d4cc011dc4f641d56a3f024e0acd06 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sun, 25 Jan 2026 16:35:14 -0800 Subject: [PATCH 03/10] Add review skill --- .agents/skills/review/SKILL.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .agents/skills/review/SKILL.md diff --git a/.agents/skills/review/SKILL.md b/.agents/skills/review/SKILL.md new file mode 100644 index 0000000000..fb3a0610b8 --- /dev/null +++ b/.agents/skills/review/SKILL.md @@ -0,0 +1,8 @@ +--- +name: review +description: Review uncommitted changes +--- + +# Review + +Run commands to get the current unstaged and stage changes. Read those files and any other that are relevant. Find ways to simplify, improve the code, find any bugs, etc. \ No newline at end of file From 4bf0d38bb2a737f0649dda2fae356a1ab34b69c7 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sun, 25 Jan 2026 16:37:35 -0800 Subject: [PATCH 04/10] Prefix skill slash commands with "skill:" --- cli/src/commands/command-registry.ts | 11 +++++++---- cli/src/data/slash-commands.ts | 4 ++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/cli/src/commands/command-registry.ts b/cli/src/commands/command-registry.ts index 5901c9fedb..ed0423d96f 100644 --- a/cli/src/commands/command-registry.ts +++ b/cli/src/commands/command-registry.ts @@ -500,10 +500,13 @@ export function findCommand(cmd: string): CommandDefinition | undefined { return staticCommand } - // Check if this is a skill command - const skill = getSkillByName(lowerCmd) - if (skill) { - return createSkillCommand(skill.name) + // Check if this is a skill command (prefixed with "skill:") + if (lowerCmd.startsWith('skill:')) { + const skillName = lowerCmd.slice('skill:'.length) + const skill = getSkillByName(skillName) + if (skill) { + return createSkillCommand(skill.name) + } } return undefined diff --git a/cli/src/data/slash-commands.ts b/cli/src/data/slash-commands.ts index 52dd4ca3ba..385ff19ce7 100644 --- a/cli/src/data/slash-commands.ts +++ b/cli/src/data/slash-commands.ts @@ -159,8 +159,8 @@ export const SLASHLESS_COMMAND_IDS = new Set( */ export function getSlashCommandsWithSkills(skills: SkillsMap): SlashCommand[] { const skillCommands: SlashCommand[] = Object.values(skills).map((skill) => ({ - id: skill.name, - label: skill.name, + id: `skill:${skill.name}`, + label: `skill:${skill.name}`, description: skill.description, })) From 8867350d03945f981188d62022a95e0d8417af69 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sun, 25 Jan 2026 16:46:34 -0800 Subject: [PATCH 05/10] tweak skill prompt --- cli/src/commands/command-registry.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/cli/src/commands/command-registry.ts b/cli/src/commands/command-registry.ts index ed0423d96f..6b6b504e28 100644 --- a/cli/src/commands/command-registry.ts +++ b/cli/src/commands/command-registry.ts @@ -491,7 +491,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ export function findCommand(cmd: string): CommandDefinition | undefined { const lowerCmd = cmd.toLowerCase() - + // First check the static command registry const staticCommand = COMMAND_REGISTRY.find( (def) => def.name === lowerCmd || def.aliases.includes(lowerCmd), @@ -541,9 +541,10 @@ function createSkillCommand(skillName: string): CommandDefinition { ${skill.content} ` - const userPrompt = args.trim() - ? `${skillContext}\n\nUser request: ${args.trim()}` - : `${skillContext}\n\nPlease use this skill to help me.` + const userPrompt = `I invoke the following skill:\n\n${skillContext}\n\n` + + (args.trim() + ? `User request: ${args.trim()}` + : '') // Check streaming/queue state if ( From 41ea0a0e43ff3696b6ef2f6eb2237c5d1d1fef62 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sun, 25 Jan 2026 16:47:33 -0800 Subject: [PATCH 06/10] cleanup skill --- .agents/skills/cleanup/SKILL.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .agents/skills/cleanup/SKILL.md diff --git a/.agents/skills/cleanup/SKILL.md b/.agents/skills/cleanup/SKILL.md new file mode 100644 index 0000000000..dd41e2a10f --- /dev/null +++ b/.agents/skills/cleanup/SKILL.md @@ -0,0 +1,8 @@ +--- +name: cleanup +description: Simplify and clean code +--- + +# Cleanup + +Please review the uncommitted changes (staged and unstaged) and find ways to simplify the code. Clean up logic. Find a simpler design. Reuse existing functions. Move utilities to utility files. Lower the cyclomatic complexity. Remove try/catch statements when not completely necessary. \ No newline at end of file From e43512cc2a032372470a1761db77c05bb8a4ad89 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sun, 25 Jan 2026 17:05:58 -0800 Subject: [PATCH 07/10] Simple skill tool component UI --- cli/src/components/tools/registry.ts | 2 ++ cli/src/components/tools/skill.tsx | 29 ++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 cli/src/components/tools/skill.tsx diff --git a/cli/src/components/tools/registry.ts b/cli/src/components/tools/registry.ts index 4abb349fa6..fc005ed1c6 100644 --- a/cli/src/components/tools/registry.ts +++ b/cli/src/components/tools/registry.ts @@ -1,4 +1,5 @@ import { CodeSearchComponent } from './code-search' +import { SkillComponent } from './skill' import { GlobComponent } from './glob' import { ListDirectoryComponent } from './list-directory' import { ReadDocsComponent } from './read-docs' @@ -40,6 +41,7 @@ const toolComponentRegistry = new Map([ // Propose tools reuse the same rendering as their base counterparts ['propose_str_replace', StrReplaceComponent], ['propose_write_file', WriteFileComponent], + [SkillComponent.toolName, SkillComponent], ]) /** diff --git a/cli/src/components/tools/skill.tsx b/cli/src/components/tools/skill.tsx new file mode 100644 index 0000000000..5dcc67bc3e --- /dev/null +++ b/cli/src/components/tools/skill.tsx @@ -0,0 +1,29 @@ +import { SimpleToolCallItem } from './tool-call-item' +import { defineToolComponent } from './types' + +import type { ToolRenderConfig } from './types' + +/** + * UI component for skill tool. + * Displays the skill name being loaded in a compact format. + */ +export const SkillComponent = defineToolComponent({ + toolName: 'skill', + + render(toolBlock): ToolRenderConfig { + const input = toolBlock.input as any + + const skillName = + typeof input?.name === 'string' ? input.name.trim() : '' + + if (!skillName) { + return { content: null } + } + + return { + content: ( + + ), + } + }, +}) From a9ffa4d057f7f055d390c32599fe93680f99c74b Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sun, 25 Jan 2026 19:50:41 -0800 Subject: [PATCH 08/10] Revert "Give skill instructions in system prompt" This reverts commit b2d79216545fcd0ca43ee3d82de3f1a186387118. --- agents/base2/base2.ts | 2 -- agents/types/secret-agent-definition.ts | 2 -- common/src/util/skills.ts | 28 +------------------ .../agent-runtime/src/templates/strings.ts | 2 -- packages/agent-runtime/src/templates/types.ts | 1 - 5 files changed, 1 insertion(+), 34 deletions(-) diff --git a/agents/base2/base2.ts b/agents/base2/base2.ts index abe5fca922..66584c215a 100644 --- a/agents/base2/base2.ts +++ b/agents/base2/base2.ts @@ -63,7 +63,6 @@ export function createBase2( 'propose_write_file', !noAskUser && 'ask_user', 'set_output', - 'skill', ), spawnableAgents: buildArray( !isMax && 'file-picker', @@ -233,7 +232,6 @@ ${isDefault || isMax ${PLACEHOLDER.FILE_TREE_PROMPT_SMALL} ${PLACEHOLDER.KNOWLEDGE_FILES_CONTENTS} -${PLACEHOLDER.SKILLS_PROMPT} ${PLACEHOLDER.SYSTEM_INFO_PROMPT} # Initial Git Changes diff --git a/agents/types/secret-agent-definition.ts b/agents/types/secret-agent-definition.ts index 045c506a26..fa0656f557 100644 --- a/agents/types/secret-agent-definition.ts +++ b/agents/types/secret-agent-definition.ts @@ -7,7 +7,6 @@ export type AllToolNames = | 'add_subgoal' | 'browser_logs' | 'create_plan' - | 'skill' | 'spawn_agent_inline' | 'update_subgoal' @@ -32,7 +31,6 @@ const placeholderNames = [ 'KNOWLEDGE_FILES_CONTENTS', 'PROJECT_ROOT', 'REMAINING_STEPS', - 'SKILLS_PROMPT', 'SYSTEM_INFO_PROMPT', 'TOOLS_PROMPT', 'USER_CWD', diff --git a/common/src/util/skills.ts b/common/src/util/skills.ts index 5585d13609..9f92dd82ab 100644 --- a/common/src/util/skills.ts +++ b/common/src/util/skills.ts @@ -3,7 +3,7 @@ import type { SkillsMap } from '../types/skill' /** * Escapes special XML characters in a string. */ -export function escapeXml(str: string): string { +function escapeXml(str: string): string { return str .replace(/&/g, '&') .replace(/\n${skillsXml}\n` } - -/** - * Formats skills as a system prompt section for injection into agent prompts. - * Returns a markdown section with available skills and instructions on using the skill tool. - * Returns empty string if no skills are available. - */ -export function formatSkillsSystemPrompt(skills: SkillsMap | undefined): string { - if (!skills) { - return '' - } - - const skillEntries = Object.values(skills) - if (skillEntries.length === 0) { - return '' - } - - const skillsXml = formatAvailableSkillsXml(skills) - - return `# Available Skills - -The following skills are available to help you complete tasks. Each skill provides specialized instructions and behaviors. - -${skillsXml} - -Use the \`skill\` tool to load a skill's full instructions when relevant to the current task. Skills are loaded on-demand - only load them when you need their specific guidance. Always load any relevant skills immediately: You should bias toward loading too many skills as early as possible.` -} diff --git a/packages/agent-runtime/src/templates/strings.ts b/packages/agent-runtime/src/templates/strings.ts index ad3e91825b..f145feaf41 100644 --- a/packages/agent-runtime/src/templates/strings.ts +++ b/packages/agent-runtime/src/templates/strings.ts @@ -1,5 +1,4 @@ import { KNOWLEDGE_FILE_NAMES_LOWERCASE } from '@codebuff/common/constants/knowledge' -import { formatSkillsSystemPrompt } from '@codebuff/common/util/skills' import { escapeString } from '@codebuff/common/util/string' import { z } from 'zod/v4' @@ -132,7 +131,6 @@ export async function formatPrompt( return `\`\`\`${path}\n${content.trim()}\n\`\`\`` }) .join('\n\n'), - [PLACEHOLDER.SKILLS_PROMPT]: () => formatSkillsSystemPrompt(fileContext.skills), } for (const varName of placeholderValues) { diff --git a/packages/agent-runtime/src/templates/types.ts b/packages/agent-runtime/src/templates/types.ts index 92e12bfb18..6ce6739631 100644 --- a/packages/agent-runtime/src/templates/types.ts +++ b/packages/agent-runtime/src/templates/types.ts @@ -21,7 +21,6 @@ const placeholderNames = [ 'KNOWLEDGE_FILES_CONTENTS', 'PROJECT_ROOT', 'REMAINING_STEPS', - 'SKILLS_PROMPT', 'SYSTEM_INFO_PROMPT', 'USER_CWD', 'USER_INPUT_PROMPT', From 93d9ef8c3cc2b52a7c42fa7da898ecf43153c430 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sun, 25 Jan 2026 20:23:02 -0800 Subject: [PATCH 09/10] Add skill tool to base2, update tools definitions --- agents/base2/base2.ts | 1 + agents/types/tools.ts | 10 ++ .../initial-agents-dir/types/tools.ts | 43 +++++++++ packages/agent-runtime/src/run-agent-step.ts | 4 +- packages/agent-runtime/src/tools/prompts.ts | 93 +++++++++---------- 5 files changed, 102 insertions(+), 49 deletions(-) diff --git a/agents/base2/base2.ts b/agents/base2/base2.ts index 66584c215a..1d3fd0b6b2 100644 --- a/agents/base2/base2.ts +++ b/agents/base2/base2.ts @@ -62,6 +62,7 @@ export function createBase2( 'propose_str_replace', 'propose_write_file', !noAskUser && 'ask_user', + 'skill', 'set_output', ), spawnableAgents: buildArray( diff --git a/agents/types/tools.ts b/agents/types/tools.ts index 2c14b6e383..3ee83384f0 100644 --- a/agents/types/tools.ts +++ b/agents/types/tools.ts @@ -19,6 +19,7 @@ export type ToolName = | 'run_terminal_command' | 'set_messages' | 'set_output' + | 'skill' | 'spawn_agents' | 'str_replace' | 'suggest_followups' @@ -49,6 +50,7 @@ export interface ToolParamsMap { run_terminal_command: RunTerminalCommandParams set_messages: SetMessagesParams set_output: SetOutputParams + skill: SkillParams spawn_agents: SpawnAgentsParams str_replace: StrReplaceParams suggest_followups: SuggestFollowupsParams @@ -246,6 +248,14 @@ export interface SetMessagesParams { */ export interface SetOutputParams {} +/** + * Load a skill's full instructions when relevant to the current task. Skills are loaded on-demand - only load them when you need their specific guidance. + */ +export interface SkillParams { + /** The name of the skill to load */ + name: string +} + /** * Spawn multiple agents and send a prompt and/or parameters to each of them. These agents will run in parallel. Note that that means they will run independently. If you need to run agents sequentially, use spawn_agents with one agent at a time instead. */ diff --git a/common/src/templates/initial-agents-dir/types/tools.ts b/common/src/templates/initial-agents-dir/types/tools.ts index 4d47cc8c4c..3ee83384f0 100644 --- a/common/src/templates/initial-agents-dir/types/tools.ts +++ b/common/src/templates/initial-agents-dir/types/tools.ts @@ -10,6 +10,8 @@ export type ToolName = | 'glob' | 'list_directory' | 'lookup_agent_info' + | 'propose_str_replace' + | 'propose_write_file' | 'read_docs' | 'read_files' | 'read_subtree' @@ -17,6 +19,7 @@ export type ToolName = | 'run_terminal_command' | 'set_messages' | 'set_output' + | 'skill' | 'spawn_agents' | 'str_replace' | 'suggest_followups' @@ -38,6 +41,8 @@ export interface ToolParamsMap { glob: GlobParams list_directory: ListDirectoryParams lookup_agent_info: LookupAgentInfoParams + propose_str_replace: ProposeStrReplaceParams + propose_write_file: ProposeWriteFileParams read_docs: ReadDocsParams read_files: ReadFilesParams read_subtree: ReadSubtreeParams @@ -45,6 +50,7 @@ export interface ToolParamsMap { run_terminal_command: RunTerminalCommandParams set_messages: SetMessagesParams set_output: SetOutputParams + skill: SkillParams spawn_agents: SpawnAgentsParams str_replace: StrReplaceParams suggest_followups: SuggestFollowupsParams @@ -149,6 +155,35 @@ export interface LookupAgentInfoParams { agentId: string } +/** + * Propose string replacements in a file without actually applying them. + */ +export interface ProposeStrReplaceParams { + /** The path to the file to edit. */ + path: string + /** Array of replacements to make. */ + replacements: { + /** The string to replace. This must be an *exact match* of the string you want to replace, including whitespace and punctuation. */ + old: string + /** The string to replace the corresponding old string with. Can be empty to delete. */ + new: string + /** Whether to allow multiple replacements of old string. */ + allowMultiple?: boolean + }[] +} + +/** + * Propose creating or editing a file without actually applying the changes. + */ +export interface ProposeWriteFileParams { + /** Path to the file relative to the **project root** */ + path: string + /** What the change is intended to do in only one sentence. */ + instructions: string + /** Edit snippet to apply to the file. */ + content: string +} + /** * Fetch up-to-date documentation for libraries and frameworks using Context7 API. */ @@ -213,6 +248,14 @@ export interface SetMessagesParams { */ export interface SetOutputParams {} +/** + * Load a skill's full instructions when relevant to the current task. Skills are loaded on-demand - only load them when you need their specific guidance. + */ +export interface SkillParams { + /** The name of the skill to load */ + name: string +} + /** * Spawn multiple agents and send a prompt and/or parameters to each of them. These agents will run in parallel. Note that that means they will run independently. If you need to run agents sequentially, use spawn_agents with one agent at a time instead. */ diff --git a/packages/agent-runtime/src/run-agent-step.ts b/packages/agent-runtime/src/run-agent-step.ts index a3f348ab71..5220e65c2f 100644 --- a/packages/agent-runtime/src/run-agent-step.ts +++ b/packages/agent-runtime/src/run-agent-step.ts @@ -627,7 +627,7 @@ export async function loopAgentSteps( const tools = useParentTools ? parentTools : await getToolSet({ - toolNames: agentTemplate.toolNames, + toolNames: agentTemplate.toolNames, additionalToolDefinitions: async () => { if (!cachedAdditionalToolDefinitions) { cachedAdditionalToolDefinitions = await additionalToolDefinitions({ @@ -638,7 +638,7 @@ export async function loopAgentSteps( return cachedAdditionalToolDefinitions }, agentTools, - skills: fileContext.skills, + skills: fileContext.skills ?? {}, }) const hasUserMessage = Boolean( diff --git a/packages/agent-runtime/src/tools/prompts.ts b/packages/agent-runtime/src/tools/prompts.ts index 99e0b1585b..68cfd93018 100644 --- a/packages/agent-runtime/src/tools/prompts.ts +++ b/packages/agent-runtime/src/tools/prompts.ts @@ -58,12 +58,12 @@ function paramsSection(params: { schema: z.ZodType; endsAgentStep: boolean }) { const safeSchema = ensureJsonSchemaCompatible(schema) const schemaWithEndsAgentStepParam = endsAgentStep ? safeSchema.and( - z.object({ - [endsAgentStepParam]: z - .literal(endsAgentStep) - .describe('Easp flag must be set to true'), - }), - ) + z.object({ + [endsAgentStepParam]: z + .literal(endsAgentStep) + .describe('Easp flag must be set to true'), + }), + ) : safeSchema const jsonSchema = toJsonSchemaSafe(schemaWithEndsAgentStepParam) delete jsonSchema.description @@ -158,13 +158,13 @@ You (Buffy) have access to the following tools. Call them when needed. Tool calls use a specific XML and JSON-like format. Adhere *precisely* to this nested element structure: ${getToolCallString( - 'tool_name', - { - parameter1: 'value1', - parameter2: 123, - }, - false, -)} + 'tool_name', + { + parameter1: 'value1', + parameter2: 123, + }, + false, + )} ### Commentary @@ -178,20 +178,20 @@ User: can you update the console logs in example/file.ts? Assistant: Sure thing! Let's update that file! ${getToolCallString( - 'example_editing_tool', - { - example_file_path: 'path/to/example/file.ts', - example_array: [ - { - old_content_with_newlines: - "// some context\nconsole.log('Hello world!');\n", - new_content_with_newlines: - "// some context\nconsole.log('Hello from Buffy!');\n", - }, - ], - }, - false, -)} + 'example_editing_tool', + { + example_file_path: 'path/to/example/file.ts', + example_array: [ + { + old_content_with_newlines: + "// some context\nconsole.log('Hello world!');\n", + new_content_with_newlines: + "// some context\nconsole.log('Hello from Buffy!');\n", + }, + ], + }, + false, + )} All done with the update! User: thanks it worked! :) @@ -251,16 +251,16 @@ export const fullToolList = ( } return desc }), - ...Object.keys(additionalToolDefinitions).map((toolName) => { - const toolDef = additionalToolDefinitions[toolName] - return buildToolDescription({ - toolName, - schema: ensureZodSchema(toolDef.inputSchema), - description: toolDef.description, - endsAgentStep: toolDef.endsAgentStep ?? true, - exampleInputs: toolDef.exampleInputs, - }) - }), ] + ...Object.keys(additionalToolDefinitions).map((toolName) => { + const toolDef = additionalToolDefinitions[toolName] + return buildToolDescription({ + toolName, + schema: ensureZodSchema(toolDef.inputSchema), + description: toolDef.description, + endsAgentStep: toolDef.endsAgentStep ?? true, + exampleInputs: toolDef.exampleInputs, + }) + }),] return `## List of Tools @@ -309,13 +309,13 @@ Use the tools below to complete the user request, if applicable. Tool calls use a specific XML and JSON-like format. Adhere *precisely* to this nested element structure: ${getToolCallString( - 'tool_name', - { - parameter1: 'value1', - parameter2: 123, - }, - false, -)} + 'tool_name', + { + parameter1: 'value1', + parameter2: 123, + }, + false, + )} Important: You only have access to the tools below. Do not use any other tools -- they are not available to you, instead they may have been previously used by other agents. @@ -327,13 +327,12 @@ export async function getToolSet(params: { toolNames: string[] additionalToolDefinitions: () => Promise agentTools: ToolSet - skills?: SkillsMap + skills: SkillsMap }): Promise { const { toolNames, additionalToolDefinitions, agentTools, skills } = params // Generate available skills XML for the skill tool description - const availableSkillsXml = skills ? formatAvailableSkillsXml(skills) : '' - + const availableSkillsXml = formatAvailableSkillsXml(skills) const toolSet: ToolSet = {} for (const toolName of toolNames) { if (toolName in toolParams) { From 2e069891c6c07275e83e49481c074d7c79bff3b9 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sun, 25 Jan 2026 20:27:30 -0800 Subject: [PATCH 10/10] fix types --- .../agent-runtime/src/__tests__/prompts-schema-handling.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/agent-runtime/src/__tests__/prompts-schema-handling.test.ts b/packages/agent-runtime/src/__tests__/prompts-schema-handling.test.ts index 6b6a45b36d..999d45e0f8 100644 --- a/packages/agent-runtime/src/__tests__/prompts-schema-handling.test.ts +++ b/packages/agent-runtime/src/__tests__/prompts-schema-handling.test.ts @@ -186,6 +186,7 @@ describe('Schema handling error recovery', () => { toolNames: [], additionalToolDefinitions: async () => customToolDefs, agentTools: {}, + skills: {}, }) // Should have the tool defined without throwing