diff --git a/.agents/skills/cleanup/SKILL.md b/.agents/skills/cleanup/SKILL.md new file mode 100644 index 000000000..dd41e2a10 --- /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 diff --git a/.agents/skills/review/SKILL.md b/.agents/skills/review/SKILL.md new file mode 100644 index 000000000..fb3a0610b --- /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 diff --git a/agents/base2/base2.ts b/agents/base2/base2.ts index 66584c215..1d3fd0b6b 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 2c14b6e38..3ee83384f 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/bun.lock b/bun.lock index c99b6f462..e0d29a243 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 4ecb76364..f32a6bf2a 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 4c4efb555..6b6b504e2 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,81 @@ 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 (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 +} + +/** + * 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 = `I invoke the following skill:\n\n${skillContext}\n\n` + + (args.trim() + ? `User request: ${args.trim()}` + : '') + + // 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/components/tools/registry.ts b/cli/src/components/tools/registry.ts index 4abb349fa..fc005ed1c 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 000000000..5dcc67bc3 --- /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: ( + + ), + } + }, +}) diff --git a/cli/src/data/slash-commands.ts b/cli/src/data/slash-commands.ts index 44fa8f18a..385ff19ce 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:${skill.name}`, + label: `skill:${skill.name}`, + description: skill.description, + })) + + return [...SLASH_COMMANDS, ...skillCommands] +} diff --git a/cli/src/index.tsx b/cli/src/index.tsx index 2bb75ca5a..6b2735898 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 000000000..8cc8e8480 --- /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 000000000..63b8d95a8 --- /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 000000000..48414203a --- /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 000000000..d2644c2e8 --- /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/templates/initial-agents-dir/types/tools.ts b/common/src/templates/initial-agents-dir/types/tools.ts index 4d47cc8c4..3ee83384f 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/common/src/tools/constants.ts b/common/src/tools/constants.ts index 123a4e0d8..a7cbeba73 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 bc2157b1c..1cd7d9f66 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 000000000..8c4341960 --- /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 000000000..c89a24cb9 --- /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 a31350a38..dc47f28ee 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 000000000..9f92dd82a --- /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/__tests__/prompts-schema-handling.test.ts b/packages/agent-runtime/src/__tests__/prompts-schema-handling.test.ts index 6b6a45b36..999d45e0f 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 diff --git a/packages/agent-runtime/src/run-agent-step.ts b/packages/agent-runtime/src/run-agent-step.ts index 913582798..5220e65c2 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,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 d75eb829a..103388e83 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 000000000..0c2956a11 --- /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 abd521fb9..68cfd9301 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, @@ -55,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 @@ -136,6 +139,7 @@ export const getToolsInstructions = ( additionalToolDefinitions: NonNullable< z.input >, + options?: { availableSkillsXml?: string }, ) => { if ( tools.length === 0 && @@ -154,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 @@ -174,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! :) @@ -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,27 +231,42 @@ export const fullToolList = ( return '' } + const { availableSkillsXml = '' } = options ?? {} + + // 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({ + toolName, + schema: ensureZodSchema(toolDef.inputSchema), + description: toolDef.description, + endsAgentStep: toolDef.endsAgentStep ?? true, + exampleInputs: toolDef.exampleInputs, + }) + }),] + 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. -${[ - ...( - toolNames.filter((toolName) => - toolNames.includes(toolName as ToolName), - ) as ToolName[] - ).map((name) => toolDescriptions[name]), - ...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, - }) - }), -].join('\n\n')}`.trim() +${descriptions.join('\n\n')}`.trim() } export const getShortToolInstructions = ( @@ -289,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. @@ -307,13 +327,43 @@ 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 = 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 77bf13b66..dddd14a33 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 378758fb6..fa8f405c7 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 12b896af7..2786879a2 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 000000000..45b32173e --- /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 +} + +