verify-maister-opencode.mjs 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147
  1. #!/usr/bin/env node
  2. // Validate the generated opencode port. Run after tools/build-maister-opencode.mjs.
  3. //
  4. // Catches the two ways this port can rot: a maister release that introduces a new
  5. // Claude-Code-only construct, and a rewrite rule that mangles ordinary prose.
  6. import { readFileSync, readdirSync, statSync, existsSync } from "node:fs"
  7. import { join, dirname, relative } from "node:path"
  8. import { fileURLToPath } from "node:url"
  9. const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..")
  10. const OUT = join(ROOT, ".opencode")
  11. const failures = []
  12. const check = (ok, message) => {
  13. if (!ok) failures.push(message)
  14. }
  15. function walk(dir, out = []) {
  16. if (!existsSync(dir)) return out
  17. for (const entry of readdirSync(dir)) {
  18. const full = join(dir, entry)
  19. if (statSync(full).isDirectory()) walk(full, out)
  20. else out.push(full)
  21. }
  22. return out
  23. }
  24. const files = [
  25. ...walk(join(OUT, "skill")).filter((f) => f.endsWith(".md")),
  26. ...walk(join(OUT, "agent")).filter((f) => f.endsWith(".md")),
  27. ...walk(join(OUT, "command")).filter((f) => f.endsWith(".md")),
  28. ]
  29. check(files.length > 0, "no generated markdown found — run the build first")
  30. // --- 1. no Claude-Code-only constructs survive -----------------------------
  31. const FORBIDDEN = [
  32. ["AskUserQuestion", "Claude Code's user-question tool"],
  33. ["TaskCreate", "Claude Code's incremental task tool"],
  34. ["TaskUpdate", "Claude Code's incremental task tool"],
  35. ["TaskList", "Claude Code's task-list tool"],
  36. ["EnterPlanMode", "Claude Code's plan-mode tool"],
  37. ["ExitPlanMode", "Claude Code's plan-mode tool"],
  38. ["CLAUDE.md", "Claude Code's project instructions file"],
  39. ["CLAUDE_PLUGIN_ROOT", "Claude Code's plugin-root variable"],
  40. ["general-purpose", "Claude Code's general subagent"],
  41. ["maister:", "the Claude Code plugin namespace"],
  42. ]
  43. for (const file of files) {
  44. const text = readFileSync(file, "utf8")
  45. for (const [needle, what] of FORBIDDEN) {
  46. // the generated header documents the mapping on purpose
  47. const lines = text.split("\n").filter((l) => !l.includes("opencode port") && !l.trim().startsWith("> "))
  48. if (lines.some((l) => l.includes(needle))) {
  49. check(false, `${relative(ROOT, file)}: still mentions ${needle} (${what})`)
  50. }
  51. }
  52. }
  53. // --- 2. no mangled prose ---------------------------------------------------
  54. const SUSPECT = [
  55. [/\bquestion tool tool\b/, "duplicated 'tool'"],
  56. [/`explore` (the|a|an|for|to|and|this|it)\b/, "'Explore' verb rewritten as a subagent reference"],
  57. [/\bthe the\b/, "duplicated article"],
  58. [/\btool tool\b/, "duplicated 'tool'"],
  59. [/opencode's auto-approve permission mode, or\b/, "unfinished permission-mode rewrite"],
  60. ]
  61. for (const file of files) {
  62. const lines = readFileSync(file, "utf8").split("\n").filter((l) => !l.includes("opencode port") && !l.trim().startsWith("> "))
  63. for (const [pattern, what] of SUSPECT) {
  64. if (lines.some((l) => pattern.test(l))) check(false, `${relative(ROOT, file)}: ${what}`)
  65. }
  66. }
  67. // --- 3. every referenced skill and agent exists ----------------------------
  68. const skillNames = new Set(readdirSync(join(OUT, "skill")))
  69. const agentNames = new Set(
  70. readdirSync(join(OUT, "agent"))
  71. .filter((f) => f.endsWith(".md"))
  72. .map((f) => f.replace(/\.md$/, "")),
  73. )
  74. const commandNames = new Set(
  75. readdirSync(join(OUT, "command"))
  76. .filter((f) => f.endsWith(".md"))
  77. .map((f) => f.replace(/\.md$/, "")),
  78. )
  79. for (const file of files) {
  80. const text = readFileSync(file, "utf8")
  81. // every project-relative path into the generated tree must exist on disk
  82. for (const m of text.matchAll(/`(\.opencode\/skill\/[A-Za-z0-9._/-]+)`/g)) {
  83. check(existsSync(join(ROOT, m[1])), `${relative(ROOT, file)}: path does not exist: ${m[1]}`)
  84. }
  85. for (const m of text.matchAll(/(?<![\w./-])`(maister-[a-z0-9-]+)`/g)) {
  86. const name = m[1]
  87. // a name may be a skill, an agent or a command
  88. check(
  89. skillNames.has(name) || agentNames.has(name) || commandNames.has(name),
  90. `${relative(ROOT, file)}: references unknown identifier ${name}`,
  91. )
  92. }
  93. }
  94. // --- 4. frontmatter sanity -------------------------------------------------
  95. for (const file of walk(join(OUT, "skill")).filter((f) => f.endsWith(".md") && f.split("/").pop() === "SKILL.md")) {
  96. const text = readFileSync(file, "utf8")
  97. const fm = text.match(/^---\n([\s\S]*?)\n---\n/)
  98. check(!!fm, `${relative(ROOT, file)}: missing frontmatter`)
  99. if (!fm) continue
  100. check(/^name: [a-z0-9-]+$/m.test(fm[1]), `${relative(ROOT, file)}: skill name must be lowercase-hyphenated`)
  101. check(/^description: .+/m.test(fm[1]), `${relative(ROOT, file)}: skill has no description`)
  102. const dir = file.split("/").pop() === "SKILL.md" ? file.slice(0, file.lastIndexOf("/")) : ""
  103. const name = fm[1].match(/^name: (.+)$/m)?.[1]
  104. check(dir.endsWith(name), `${relative(ROOT, file)}: name "${name}" does not match its directory`)
  105. }
  106. for (const file of walk(join(OUT, "agent")).filter((f) => f.endsWith(".md"))) {
  107. const text = readFileSync(file, "utf8")
  108. const fm = text.match(/^---\n([\s\S]*?)\n---\n/)
  109. check(!!fm, `${relative(ROOT, file)}: missing frontmatter`)
  110. if (!fm) continue
  111. check(/^mode: subagent$/m.test(fm[1]), `${relative(ROOT, file)}: agent is not mode: subagent`)
  112. check(!/^name:/m.test(fm[1]), `${relative(ROOT, file)}: agent frontmatter must not set name (the filename does)`)
  113. check(!/^model:/m.test(fm[1]), `${relative(ROOT, file)}: agent frontmatter must not set model`)
  114. check(
  115. !/^color: (?!(primary|secondary|accent|success|warning|error|info|#[0-9a-fA-F]{6})$)/m.test(fm[1]),
  116. `${relative(ROOT, file)}: agent colour is not an opencode theme colour`,
  117. )
  118. }
  119. for (const file of walk(join(OUT, "command")).filter((f) => f.endsWith(".md"))) {
  120. const text = readFileSync(file, "utf8")
  121. check(text.includes("$ARGUMENTS"), `${relative(ROOT, file)}: command never mentions $ARGUMENTS`)
  122. }
  123. // --- 5. the hand-written pieces exist -------------------------------------
  124. check(existsSync(join(OUT, "plugin", "maister.ts")), "missing .opencode/plugin/maister.ts")
  125. check(existsSync(join(ROOT, "opencode.json")), "missing opencode.json")
  126. // --- report ---------------------------------------------------------------
  127. if (failures.length) {
  128. console.error(`FAIL — ${failures.length} problem(s):`)
  129. for (const f of failures) console.error(` - ${f}`)
  130. process.exit(1)
  131. }
  132. console.log(`OK — ${skillNames.size} skills, ${agentNames.size} agents, ${commandNames.size} commands validated`)