#!/usr/bin/env node // Validate the generated opencode port. Run after tools/build-maister-opencode.mjs. // // Catches the two ways this port can rot: a maister release that introduces a new // Claude-Code-only construct, and a rewrite rule that mangles ordinary prose. import { readFileSync, readdirSync, statSync, existsSync } from "node:fs" import { join, dirname, relative } from "node:path" import { fileURLToPath } from "node:url" const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..") const OUT = join(ROOT, ".opencode") const failures = [] const check = (ok, message) => { if (!ok) failures.push(message) } function walk(dir, out = []) { if (!existsSync(dir)) return out for (const entry of readdirSync(dir)) { const full = join(dir, entry) if (statSync(full).isDirectory()) walk(full, out) else out.push(full) } return out } const files = [ ...walk(join(OUT, "skill")).filter((f) => f.endsWith(".md")), ...walk(join(OUT, "agent")).filter((f) => f.endsWith(".md")), ...walk(join(OUT, "command")).filter((f) => f.endsWith(".md")), ] check(files.length > 0, "no generated markdown found — run the build first") // --- 1. no Claude-Code-only constructs survive ----------------------------- const FORBIDDEN = [ ["AskUserQuestion", "Claude Code's user-question tool"], ["TaskCreate", "Claude Code's incremental task tool"], ["TaskUpdate", "Claude Code's incremental task tool"], ["TaskList", "Claude Code's task-list tool"], ["EnterPlanMode", "Claude Code's plan-mode tool"], ["ExitPlanMode", "Claude Code's plan-mode tool"], ["CLAUDE.md", "Claude Code's project instructions file"], ["CLAUDE_PLUGIN_ROOT", "Claude Code's plugin-root variable"], ["general-purpose", "Claude Code's general subagent"], ["maister:", "the Claude Code plugin namespace"], ] for (const file of files) { const text = readFileSync(file, "utf8") for (const [needle, what] of FORBIDDEN) { // the generated header documents the mapping on purpose const lines = text.split("\n").filter((l) => !l.includes("opencode port") && !l.trim().startsWith("> ")) if (lines.some((l) => l.includes(needle))) { check(false, `${relative(ROOT, file)}: still mentions ${needle} (${what})`) } } } // --- 2. no mangled prose --------------------------------------------------- const SUSPECT = [ [/\bquestion tool tool\b/, "duplicated 'tool'"], [/`explore` (the|a|an|for|to|and|this|it)\b/, "'Explore' verb rewritten as a subagent reference"], [/\bthe the\b/, "duplicated article"], [/\btool tool\b/, "duplicated 'tool'"], [/opencode's auto-approve permission mode, or\b/, "unfinished permission-mode rewrite"], ] for (const file of files) { const lines = readFileSync(file, "utf8").split("\n").filter((l) => !l.includes("opencode port") && !l.trim().startsWith("> ")) for (const [pattern, what] of SUSPECT) { if (lines.some((l) => pattern.test(l))) check(false, `${relative(ROOT, file)}: ${what}`) } } // --- 3. every referenced skill and agent exists ---------------------------- const skillNames = new Set(readdirSync(join(OUT, "skill"))) const agentNames = new Set( readdirSync(join(OUT, "agent")) .filter((f) => f.endsWith(".md")) .map((f) => f.replace(/\.md$/, "")), ) const commandNames = new Set( readdirSync(join(OUT, "command")) .filter((f) => f.endsWith(".md")) .map((f) => f.replace(/\.md$/, "")), ) for (const file of files) { const text = readFileSync(file, "utf8") // every project-relative path into the generated tree must exist on disk for (const m of text.matchAll(/`(\.opencode\/skill\/[A-Za-z0-9._/-]+)`/g)) { check(existsSync(join(ROOT, m[1])), `${relative(ROOT, file)}: path does not exist: ${m[1]}`) } for (const m of text.matchAll(/(? f.endsWith(".md") && f.split("/").pop() === "SKILL.md")) { const text = readFileSync(file, "utf8") const fm = text.match(/^---\n([\s\S]*?)\n---\n/) check(!!fm, `${relative(ROOT, file)}: missing frontmatter`) if (!fm) continue check(/^name: [a-z0-9-]+$/m.test(fm[1]), `${relative(ROOT, file)}: skill name must be lowercase-hyphenated`) check(/^description: .+/m.test(fm[1]), `${relative(ROOT, file)}: skill has no description`) const dir = file.split("/").pop() === "SKILL.md" ? file.slice(0, file.lastIndexOf("/")) : "" const name = fm[1].match(/^name: (.+)$/m)?.[1] check(dir.endsWith(name), `${relative(ROOT, file)}: name "${name}" does not match its directory`) } for (const file of walk(join(OUT, "agent")).filter((f) => f.endsWith(".md"))) { const text = readFileSync(file, "utf8") const fm = text.match(/^---\n([\s\S]*?)\n---\n/) check(!!fm, `${relative(ROOT, file)}: missing frontmatter`) if (!fm) continue check(/^mode: subagent$/m.test(fm[1]), `${relative(ROOT, file)}: agent is not mode: subagent`) check(!/^name:/m.test(fm[1]), `${relative(ROOT, file)}: agent frontmatter must not set name (the filename does)`) check(!/^model:/m.test(fm[1]), `${relative(ROOT, file)}: agent frontmatter must not set model`) check( !/^color: (?!(primary|secondary|accent|success|warning|error|info|#[0-9a-fA-F]{6})$)/m.test(fm[1]), `${relative(ROOT, file)}: agent colour is not an opencode theme colour`, ) } for (const file of walk(join(OUT, "command")).filter((f) => f.endsWith(".md"))) { const text = readFileSync(file, "utf8") check(text.includes("$ARGUMENTS"), `${relative(ROOT, file)}: command never mentions $ARGUMENTS`) } // --- 5. the hand-written pieces exist ------------------------------------- check(existsSync(join(OUT, "plugin", "maister.ts")), "missing .opencode/plugin/maister.ts") check(existsSync(join(ROOT, "opencode.json")), "missing opencode.json") // --- report --------------------------------------------------------------- if (failures.length) { console.error(`FAIL — ${failures.length} problem(s):`) for (const f of failures) console.error(` - ${f}`) process.exit(1) } console.log(`OK — ${skillNames.size} skills, ${agentNames.size} agents, ${commandNames.size} commands validated`)