skill.ts 3.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
  1. import z from "zod"
  2. import { Config } from "../config/config"
  3. import { Instance } from "../project/instance"
  4. import { NamedError } from "@opencode-ai/util/error"
  5. import { ConfigMarkdown } from "../config/markdown"
  6. import { Log } from "../util/log"
  7. import { Global } from "@/global"
  8. import { Filesystem } from "@/util/filesystem"
  9. import { exists } from "fs/promises"
  10. export namespace Skill {
  11. const log = Log.create({ service: "skill" })
  12. export const Info = z.object({
  13. name: z.string(),
  14. description: z.string(),
  15. location: z.string(),
  16. })
  17. export type Info = z.infer<typeof Info>
  18. export const InvalidError = NamedError.create(
  19. "SkillInvalidError",
  20. z.object({
  21. path: z.string(),
  22. message: z.string().optional(),
  23. issues: z.custom<z.core.$ZodIssue[]>().optional(),
  24. }),
  25. )
  26. export const NameMismatchError = NamedError.create(
  27. "SkillNameMismatchError",
  28. z.object({
  29. path: z.string(),
  30. expected: z.string(),
  31. actual: z.string(),
  32. }),
  33. )
  34. const OPENCODE_SKILL_GLOB = new Bun.Glob("{skill,skills}/**/SKILL.md")
  35. const CLAUDE_SKILL_GLOB = new Bun.Glob("skills/**/SKILL.md")
  36. export const state = Instance.state(async () => {
  37. const skills: Record<string, Info> = {}
  38. const addSkill = async (match: string) => {
  39. const md = await ConfigMarkdown.parse(match)
  40. if (!md) {
  41. return
  42. }
  43. const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
  44. if (!parsed.success) return
  45. // Warn on duplicate skill names
  46. if (skills[parsed.data.name]) {
  47. log.warn("duplicate skill name", {
  48. name: parsed.data.name,
  49. existing: skills[parsed.data.name].location,
  50. duplicate: match,
  51. })
  52. }
  53. skills[parsed.data.name] = {
  54. name: parsed.data.name,
  55. description: parsed.data.description,
  56. location: match,
  57. }
  58. }
  59. // Scan .claude/skills/ directories (project-level)
  60. const claudeDirs = await Array.fromAsync(
  61. Filesystem.up({
  62. targets: [".claude"],
  63. start: Instance.directory,
  64. stop: Instance.worktree,
  65. }),
  66. )
  67. // Also include global ~/.claude/skills/
  68. const globalClaude = `${Global.Path.home}/.claude`
  69. if (await exists(globalClaude)) {
  70. claudeDirs.push(globalClaude)
  71. }
  72. for (const dir of claudeDirs) {
  73. for await (const match of CLAUDE_SKILL_GLOB.scan({
  74. cwd: dir,
  75. absolute: true,
  76. onlyFiles: true,
  77. followSymlinks: true,
  78. dot: true,
  79. })) {
  80. await addSkill(match)
  81. }
  82. }
  83. // Scan .opencode/skill/ directories
  84. for (const dir of await Config.directories()) {
  85. for await (const match of OPENCODE_SKILL_GLOB.scan({
  86. cwd: dir,
  87. absolute: true,
  88. onlyFiles: true,
  89. followSymlinks: true,
  90. })) {
  91. await addSkill(match)
  92. }
  93. }
  94. return skills
  95. })
  96. export async function get(name: string) {
  97. return state().then((x) => x[name])
  98. }
  99. export async function all() {
  100. return state().then((x) => Object.values(x))
  101. }
  102. }