| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799 |
- #!/usr/bin/env node
- // Build an opencode port of the maister plugin.
- //
- // Source: maister/plugins/maister (Claude Code plugin, vendored clone)
- // Output: .opencode/skill|maister-*/ skills
- // .opencode/agent/maister-*.md subagents
- // .opencode/command/maister-*.md slash commands
- //
- // Run after every `git -C maister pull`:
- // node tools/build-maister-opencode.mjs
- //
- // The generated tree is disposable — never edit it by hand, edit the upstream
- // clone and rebuild. Files that are hand-maintained (the plugin, opencode.json)
- // are not touched by this script.
- import { readFileSync, writeFileSync, readdirSync, mkdirSync, rmSync, existsSync, statSync, copyFileSync } from "node:fs"
- import { join, dirname, basename, relative } from "node:path"
- import { fileURLToPath } from "node:url"
- const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..")
- const SRC = join(ROOT, "maister", "plugins", "maister")
- const OUT = join(ROOT, ".opencode")
- const PREFIX = "maister-"
- // ---------------------------------------------------------------------------
- // upstream version, for the generated headers
- // ---------------------------------------------------------------------------
- function upstreamVersion() {
- try {
- return JSON.parse(readFileSync(join(ROOT, "maister", ".claude-plugin", "marketplace.json"), "utf8")).version
- } catch {
- return "unknown"
- }
- }
- const VERSION = upstreamVersion()
- // ---------------------------------------------------------------------------
- // source inventory
- // ---------------------------------------------------------------------------
- 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 SRC_SKILLS = readdirSync(join(SRC, "skills"), { withFileTypes: true })
- .filter((e) => e.isDirectory())
- .map((e) => e.name)
- .sort()
- const SRC_AGENTS = readdirSync(join(SRC, "agents"))
- .filter((f) => f.endsWith(".md"))
- .map((f) => basename(f, ".md"))
- .sort()
- const SRC_COMMANDS = readdirSync(join(SRC, "commands"))
- .filter((f) => f.endsWith(".md"))
- .map((f) => basename(f, ".md"))
- .sort()
- // Path of a skill's support file (relative to the skill dir) -> owning skill,
- // so a reference can be rewritten to a path that resolves from the project root.
- const SUPPORT_OWNER = new Map()
- for (const skill of SRC_SKILLS) {
- for (const file of walk(join(SRC, "skills", skill))) {
- const rel = relative(join(SRC, "skills", skill), file)
- if (rel !== "SKILL.md" && /\.(md|html|mjs|json)$/.test(rel)) {
- if (!SUPPORT_OWNER.has(rel)) SUPPORT_OWNER.set(rel, skill)
- }
- }
- }
- // ---------------------------------------------------------------------------
- // text transforms
- // ---------------------------------------------------------------------------
- // Ordered: specific phrases first, general fallbacks last.
- const REWRITES = [
- // --- task tracking: opencode has one list-replacing `todowrite` tool -------
- [
- /Use `TaskCreate` for all phases \(see Phase Configuration\), then set dependencies with `TaskUpdate addBlockedBy`/g,
- "Write every phase into the todo list with a single `todowrite` call (all `pending`) and encode phase dependencies in the item text (e.g. `Phase 4: Specification (blocked by 2, 3)`) — `todowrite` replaces the whole list on every call and has no dependency field",
- ],
- [
- /`TaskCreate` for all phases above \(pending\)/g,
- "a single `todowrite` call listing every phase above as `pending`",
- ],
- [
- /Use `TaskCreate` for all phases \(pending\), then set sequential dependencies with `TaskUpdate addBlockedBy`/g,
- "Write every phase into the todo list with a single `todowrite` call (all `pending`), in the order the phases run — `todowrite` replaces the whole list on every call and has no dependency field",
- ],
- [
- /use `TaskCreate` for all phases \(pending\), then set sequential dependencies with `TaskUpdate addBlockedBy`/g,
- "write every phase into the todo list with a single `todowrite` call (all `pending`), in the order the phases run — `todowrite` replaces the whole list on every call and has no dependency field",
- ],
- [
- /Set dependencies: ([^.]+)\. Phase/g,
- "Sequence them in item order. Phase",
- ],
- [
- /mark skipped phases as `completed` with `metadata: \{skipped: true\}`/g,
- 'mark skipped phases `completed` and append "(skipped)" to their text',
- ],
- [
- /For phases skipped due to scope \(([^)]*)\), mark `completed` with `metadata: \{skipped: true, reason: "([^"]*)"\}`/g,
- "For phases skipped due to scope ($1), mark them `completed` and append \"(skipped: $2)\" to their text",
- ],
- [
- /with `metadata: \{"skipped": true\}\`/g,
- "with `(skipped)` appended to the item text",
- ],
- [
- /`TaskCreate` for all phases \(pending\), then `TaskUpdate addBlockedBy` for dependencies/g,
- "a single `todowrite` call listing every phase as `pending`, with dependencies encoded in the item text",
- ],
- [
- /For each task group, call `TaskCreate`:/g,
- "For each task group, add an item in one `todowrite` call (it replaces the whole list, so include every existing item):",
- ],
- [
- /Set dependencies with `TaskUpdate addBlockedBy` mirroring the plan's dependency chain:/g,
- "Encode the plan's dependency chain in each item's text (e.g. `Group 3: refactor store (blocked by 1, 2)`):",
- ],
- [
- /Dependencies set via `TaskUpdate addBlockedBy`/g,
- "Dependencies encoded in the item text passed to `todowrite`",
- ],
- [
- /Call `TaskList` to find existing task group items from the planner\. If found, use them\. If not, create them with `TaskCreate` for each task group \(when the planner created none\)\./g,
- "Read the current todo list (opencode keeps it in context and `todowrite` replaces it wholesale). If the planner left no group items, add one per task group in a single `todowrite` call.",
- ],
- [
- /`TaskUpdate` to `status: "in_progress"` with `owner: "maister:([a-z-]+)"`/g,
- "`todowrite` with that group `in_progress` (delegated to `$1`)",
- ],
- [
- /`TaskUpdate` to `status: "completed"` with `metadata: \{[^}]*\}/g,
- "`todowrite` with that group `completed` (the outcome details go in `implementation/work-log.md`)",
- ],
- [
- /`TaskUpdate` to `status: "completed"`/g,
- "`todowrite` with that item `completed`",
- ],
- [
- /`TaskUpdate` to `status: "completed"`/g,
- "`todowrite` with that item `completed`",
- ],
- [
- /`TaskUpdate` with `status: "completed"` and `metadata: \{"skipped": true\}`/g,
- '`todowrite` with `status: "completed"` and "(skipped)" appended to the item text',
- ],
- [
- /`TaskUpdate` with `status: "completed"`/g,
- '`todowrite` with `status: "completed"`',
- ],
- [
- /`TaskUpdate` to `status: "in_progress"`/g,
- "`todowrite` with that item `in_progress`",
- ],
- [
- /`TaskUpdate` to `in_progress`/g,
- "`todowrite` with that item `in_progress`",
- ],
- [
- /`TaskUpdate` to `completed`/g,
- "`todowrite` with that item `completed`",
- ],
- [
- /`TaskUpdate` addBlockedBy/g,
- "dependencies encoded in the `todowrite` item text",
- ],
- [
- /Use `TaskCreate` tool:/g,
- "Use the `todowrite` tool (one call carrying the full list):",
- ],
- [
- /`TaskCreate` for each task group with `Dependencies` AND `Files to Modify` declared in `implementation-plan\.md`/g,
- "one `todowrite` item per task group, with `Dependencies` and `Files to Modify` from `implementation-plan.md` carried in the item text",
- ],
- [
- /`TaskCreate` per task group with `Dependencies` AND `Files to Modify` declared in `implementation-plan\.md`/g,
- "one `todowrite` item per task group, with `Dependencies` and `Files to Modify` from `implementation-plan.md` carried in the item text",
- ],
- [
- /`TaskCreate` per task group/g,
- "one `todowrite` item per task group",
- ],
- [
- /`TaskCreate` for all phases \(see Phase Configuration\)/g,
- "a single `todowrite` call listing every phase (see Phase Configuration)",
- ],
- [
- /`TaskCreate` for all phases/g,
- "a single `todowrite` call listing every phase",
- ],
- [
- /`TaskCreate` initialization\*\*/g,
- "todo-list initialization**",
- ],
- [
- /`TaskCreate`\/?`TaskUpdate`/g,
- "`todowrite`",
- ],
- [
- /`TaskCreate`\/`TaskUpdate`/g,
- "`todowrite`",
- ],
- [
- /`TaskCreate`/g,
- "`todowrite`",
- ],
- [
- /`TaskUpdate`/g,
- "`todowrite`",
- ],
- [
- /`TaskList`/g,
- "the todo list",
- ],
- [
- /`TaskCreate`\/`TaskUpdate` tools/g,
- "the `todowrite` tool",
- ],
- // bare (un-backticked) mentions the specific rules above did not catch
- [
- /`TaskUpdate addBlockedBy`/g,
- "dependencies encoded in the `todowrite` item text",
- ],
- [
- /\bTaskCreate\/TaskUpdate\b/g,
- "`todowrite`",
- ],
- [
- /\bTaskUpdate addBlockedBy\b/g,
- "dependencies encoded in the `todowrite` item text",
- ],
- [
- /\bTaskCreate IDs\b/g,
- "`todowrite` items",
- ],
- [
- /\bTaskCreate initialization\b/g,
- "todo-list initialization",
- ],
- [
- /\bTaskCreate\b/g,
- "`todowrite`",
- ],
- [
- /\bTaskUpdate\b/g,
- "`todowrite`",
- ],
- [
- /\bTaskList\b/g,
- "the todo list",
- ],
- // `todowrite` has no metadata bag, no owner and no dependency field: anything
- // upstream parked there has to move into the item text or the work log.
- [
- /`metadata: \{[^}]*\}`/g,
- "(recorded in the work log)",
- ],
- [
- /\bmetadata: \{[^}]*\}\.?/g,
- "; the details go in the work log.",
- ],
- [
- /`todowrite` with `addBlockedBy`/g,
- "the `todowrite` item order",
- ],
- [
- /with `addBlockedBy` dependencies/g,
- "in dependency order",
- ],
- [
- /\baddBlockedBy\b/g,
- "dependency order",
- ],
- [
- /`task_ids: \{\}`/g,
- "an empty `task_ids` map",
- ],
- // --- plan mode -----------------------------------------------------------
- [
- /Call `EnterPlanMode` and let plan mode run exactly as it normally does \(explore the codebase, design the approach, write the plan, then `ExitPlanMode` for approval\)\. Do not redefine its phases\./g,
- "Plan without editing any file: explore the codebase, design the approach, write the plan out, then request approval with the `question` tool. opencode has no plan-mode tool; read-only behaviour is what plan mode provided, so respect it for the whole planning pass.",
- ],
- [
- /Do not call `ExitPlanMode` until the plan reflects/g,
- "Do not ask for approval until the plan reflects",
- ],
- [
- /\*\*BLOCKING: Do NOT call `ExitPlanMode` until the plan file contains:\*\*/g,
- "**BLOCKING: do not ask for approval until the plan file contains:**",
- ],
- [
- /\*\*Use the `EnterPlanMode` tool to present the fix plan for user approval\.\*\*/g,
- "**Present the fix plan for approval with the `question` tool before touching any file.**",
- ],
- [
- /### ExitPlanMode Gate: Mandatory Sections/g,
- "### Approval Gate: Mandatory Sections",
- ],
- [
- /If any section is missing, add it before calling ExitPlanMode\./g,
- "If any section is missing, add it before asking for approval.",
- ],
- [
- /`EnterPlanMode`\/`ExitPlanMode`/g,
- "plan mode",
- ],
- [
- /`EnterPlanMode`/g,
- "plan mode",
- ],
- [
- /`ExitPlanMode`/g,
- "the approval request",
- ],
- // --- built-in subagents --------------------------------------------------
- [
- /subagent_type: `general-purpose`/g,
- "subagent_type: general",
- ],
- [
- /subagent_type="general-purpose"/g,
- 'subagent_type="general"',
- ],
- [
- /subagent_type: general-purpose/g,
- "subagent_type: general",
- ],
- [
- /subagent_type="Explore"/g,
- 'subagent_type="explore"',
- ],
- [
- /subagent_type: `Explore`/g,
- "subagent_type: explore",
- ],
- [
- /subagent_type: Explore/g,
- "subagent_type: explore",
- ],
- // "Explore" is only the built-in subagent where it names one. Everywhere else
- // it is an ordinary English verb ("Explore the codebase"), which must survive
- // untouched — so only the subagent-reference forms are rewritten.
- [/\bExplore subagents?\b/g, "`explore` subagents"],
- [/\bExplore agents\b/g, "`explore` agents"],
- [/\bExplore agent\b/g, "`explore` agent"],
- [/\bExplore\b(?=\s*\()/g, "`explore`"],
- [/\bthe Explore\b/g, "the `explore` subagent"],
- [/\braw Explore agents\b/g, "raw `explore` agent findings"],
- [
- /`general-purpose`/g,
- "`general`",
- ],
- [
- /\bgeneral-purpose subagents?\b/g,
- "`general` subagents",
- ],
- // --- tools ---------------------------------------------------------------
- [
- /AskUserQuestion/g,
- "question tool",
- ],
- [
- /\bquestion tool tool\b/g,
- "question tool",
- ],
- // Claude Code capitalises its tool names; opencode's are lowercase
- [/\bWebFetch tool\b/g, "webfetch tool"],
- [/\bWebSearch tool\b/g, "websearch tool"],
- [/\bRead tool\b/g, "read tool"],
- [/\bWrite tool\b/g, "write tool"],
- [/\bEdit tool\b/g, "edit tool"],
- [/\bBash tool\b/g, "bash tool"],
- [/\bGlob tool\b/g, "glob tool"],
- [/\bGrep tool\b/g, "grep tool"],
- [/\(Bash\)/g, "(bash tool)"],
- [/\bvia Bash\b/g, "via the bash tool"],
- [/\bvia Read\b/g, "via the read tool"],
- [/\bvia Write\b/g, "via the write tool"],
- [
- /Skill\/Task tools/g,
- "skill/task tools",
- ],
- [
- /Skill\/Task tool parameters/g,
- "skill/task tool parameters",
- ],
- [
- /`Task` tool/g,
- "`task` tool",
- ],
- [
- /\bTask tool\b/g,
- "`task` tool",
- ],
- [
- /\bTask tool calls?\b/g,
- "`task` tool calls",
- ],
- [
- /\bthe Task tool\b/g,
- "the `task` tool",
- ],
- [
- /one `Task` tool-use block/g,
- "one `task` tool-use block",
- ],
- [
- /`Task\(G2\)`/g,
- "`task(G2)`",
- ],
- [
- /`Task\(G3\)`/g,
- "`task(G3)`",
- ],
- [
- /`Task\(G4\)`/g,
- "`task(G4)`",
- ],
- [
- /Skill tool/g,
- "skill tool",
- ],
- [
- /`Skill` tool/g,
- "`skill` tool",
- ],
- [
- /via Task tool \(/g,
- "via `task` tool (",
- ],
- [
- /via Task tool\b/g,
- "via the `task` tool",
- ],
- [
- /Claude Code's `auto` permission mode/g,
- "opencode's auto-approve permission mode",
- ],
- [
- /`acceptEdits`/g,
- "edit-auto-approve",
- ],
- [
- /`bypassPermissions`/g,
- "full-access",
- ],
- [
- /`default`, edit-auto-approve, `auto`, `plan`, full-access/g,
- "any opencode permission mode",
- ],
- [
- /`default`, `acceptEdits`, `auto`, `plan`, `bypassPermissions`/g,
- "any opencode permission mode",
- ],
- [
- /\(auto \/ edit-auto-approve \/ full-access modes\)/g,
- "(any permission mode)",
- ],
- [
- /\bauto \/ edit-auto-approve \/ full-access\b/g,
- "any permission mode",
- ],
- [
- /Claude Code lifecycle events/g,
- "opencode lifecycle events",
- ],
- [
- /`SessionStart` \(matcher: `compact`\)/g,
- "post-compaction system reminder",
- ],
- [
- /`PreToolUse` \(matcher: `Bash`\)/g,
- "per-agent `permission.bash` rules",
- ],
- [
- /`PreToolUse` hook/g,
- "per-agent bash permission rules",
- ],
- [
- /auto-discovered by Claude Code/g,
- "auto-discovered by opencode",
- ],
- [
- /Claude Code's built-in plan mode/g,
- "opencode's read-only plan pass",
- ],
- [
- /Claude Code's plan mode/g,
- "plan mode",
- ],
- [
- /\bClaude Code\b/g,
- "opencode",
- ],
- [
- /CLAUDE\.md/g,
- "AGENTS.md",
- ],
- [
- /claude-md-template\.md/g,
- "agents-md-template.md",
- ],
- // The mockup studio server ships with the skill, so its path is a plain
- // project-relative path — no plugin-root variable to resolve.
- [
- /\$\{CLAUDE_PLUGIN_ROOT\}\/skills\/mockup-studio\//g,
- ".opencode/skill/maister-mockup-studio/",
- ],
- [
- /The plugin root is this plugin's own directory — the one holding\s*`\.claude-plugin\/plugin\.json` — and the variable naming it is set in the session\s*environment\. Use it as written; do not work the directory out and substitute a\s*path of your own\./g,
- "That path is relative to the project root. Use it exactly as written; do not resolve it by hand or substitute a path of your own.",
- ],
- [
- /The plugin root is this plugin's own directory — the one holding `\.claude-plugin\/plugin\.json` — and the variable naming it is set in the session environment\. Use it as written rather than substituting a path of your own\./g,
- "That path is relative to the project root. Use it exactly as written rather than substituting a path of your own.",
- ],
- // --- playwright MCP tools get the server name as prefix in opencode ------
- [/\bbrowser_(navigate|click|type|fill_form|snapshot|evaluate|take_screenshot|console_messages)\b/g, "playwright_browser_$1"],
- // --- namespaced identifiers: `maister:x` -> `maister-x` -------------------
- // opencode has no plugin namespace, so every skill, agent and command lives
- // in one flat space and needs the flat prefix instead. The lookbehind keeps
- // `.maisster/`-style project paths out of it.
- [/(?<![\w.-])maister:([a-z][a-z-]*)/g, `${PREFIX}$1`],
- // the `[orchestrator-name]` placeholder is a template, not a real identifier
- [/(?<![\w.-])maister:\[/g, `${PREFIX}`],
- // a wildcard reference to the whole namespace
- [/`\/maister:\*`/g, "`/maister-*`"],
- ]
- // Support-file paths, resolved after the generic rewrites.
- //
- // Two forms appear upstream: `../<skill>/references/x.md` (explicit sibling) and
- // a bare `references/x.md` that names another skill's file. opencode has no
- // plugin-relative base directory, so every reference becomes a path from the
- // project root. Same-skill references keep their relative form — opencode
- // announces the skill's base directory when it loads one.
- function rewriteSupportPaths(text, currentSkill) {
- // explicit `../<skill>/...` and `[plugin]/skills/<skill>/...` forms
- text = text.replace(/(?:\.\.\/|\[plugin\]\/skills\/|\.\/skills\/)([a-z-]+)\//g, (_, s) =>
- SRC_SKILLS.includes(s) ? `.opencode/skill/${PREFIX}${s}/` : `../${s}/`,
- )
- // bare `references/x.md` etc. that belong to a different skill
- for (const [rel, owner] of SUPPORT_OWNER) {
- if (owner === currentSkill) continue
- text = text.replaceAll(new RegExp(`(?<![\\w/.-])${rel.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "g"), `.opencode/skill/${PREFIX}${owner}/${rel}`)
- }
- // `skills/<skill>/SKILL.md` — the upstream plugin's own layout
- text = text.replace(/(?<![\w./-])skills\/([a-z-]+)\/[Ss][Kk][Ii][Ll][Ll]\.md/g, (_, s) =>
- SRC_SKILLS.includes(s) ? `.opencode/skill/${PREFIX}${s}/SKILL.md` : `skills/${s}/SKILL.md`,
- )
- return text
- }
- function applyRewrites(text, currentSkill) {
- for (const [pattern, replacement] of REWRITES) {
- text = text.replace(pattern, replacement)
- }
- return rewriteSupportPaths(text, currentSkill)
- }
- // ---------------------------------------------------------------------------
- // frontmatter
- // ---------------------------------------------------------------------------
- function splitFrontmatter(raw) {
- const m = raw.match(/^---\n([\s\S]*?)\n---\n?/)
- if (!m) return { frontmatter: null, body: raw }
- return { frontmatter: m[1], body: raw.slice(m[0].length) }
- }
- function parseFrontmatter(fm) {
- const out = {}
- for (const line of fm.split("\n")) {
- const m = line.match(/^([A-Za-z_-]+):\s*(.*)$/)
- if (m) out[m[1]] = m[2]
- }
- return out
- }
- function yamlValue(value) {
- if (value === "") return '""'
- if (/^[A-Za-z0-9_./@ -]+$/.test(value) && !/^(true|false|null|yes|no|on|off)$/i.test(value)) return value
- return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`
- }
- // ---------------------------------------------------------------------------
- // generated headers
- // ---------------------------------------------------------------------------
- const NOTE = `> **opencode port** — generated from the maister Claude Code plugin v${VERSION} by
- > \`tools/build-maister-opencode.mjs\`. Do not edit this file; edit \`maister/\` and rebuild.
- > Tool mapping: the \`question\` tool replaces \`AskUserQuestion\`, \`task\` replaces \`Task\`,
- > \`todowrite\` replaces \`TaskCreate\`/\`TaskUpdate\`/\`TaskList\`, the \`explore\` and \`general\`
- > subagents replace \`Explore\` and \`general-purpose\`, \`AGENTS.md\` replaces \`CLAUDE.md\`, and
- > \`playwright_browser_*\` are MCP tools served by the (disabled by default) \`playwright\` server.`
- const COMMAND_NOTE = `**opencode port** — generated from the maister Claude Code plugin v${VERSION} by \`tools/build-maister-opencode.mjs\`. Claude Code tool names are mapped as: \`question\` replaces \`AskUserQuestion\`, \`task\` replaces \`Task\`, \`todowrite\` replaces \`TaskCreate\`/\`TaskUpdate\`, \`explore\`/\`general\` replace the built-in \`Explore\`/\`general-purpose\` subagents, and \`AGENTS.md\` replaces \`CLAUDE.md\`.`
- // ---------------------------------------------------------------------------
- // generate: skills
- // ---------------------------------------------------------------------------
- const AGENT_COLOR = {
- blue: "info",
- cyan: "info",
- green: "success",
- orange: "warning",
- yellow: "warning",
- pink: "secondary",
- purple: "accent",
- red: "error",
- }
- // Agents the upstream PreToolUse hook whitelisted for full bash access.
- const FULL_BASH_AGENTS = new Set(["test-suite-runner", "e2e-test-verifier", "user-docs-generator", "docs-operator"])
- const DESTRUCTIVE_BASH = {
- "git stash*": "deny",
- "git reset --hard*": "deny",
- "git checkout -- *": "deny",
- "git checkout .*": "deny",
- "git clean*": "deny",
- "git push --force*": "deny",
- "git push -f*": "deny",
- "rm -rf*": "deny",
- }
- function generateSkills() {
- const dir = join(OUT, "skill")
- rmSync(dir, { recursive: true, force: true })
- let n = 0
- for (const skill of SRC_SKILLS) {
- const srcDir = join(SRC, "skills", skill)
- const dstDir = join(dir, PREFIX + skill)
- const srcMd = join(srcDir, "SKILL.md")
- if (!existsSync(srcMd)) continue
- const { frontmatter, body } = splitFrontmatter(readFileSync(srcMd, "utf8"))
- const meta = frontmatter ? parseFrontmatter(frontmatter) : {}
- let description = meta.description ?? ""
- if (meta["argument-hint"]) description += ` Usage: ${meta["argument-hint"]}.`
- if (meta["user-invocable"] === "false") {
- description += " Internal skill — not intended for direct user invocation; parent skills call it."
- }
- description = applyRewrites(description, skill).replace(/\s+/g, " ").trim()
- // copy support files verbatim, applying the same rewrites to markdown
- mkdirSync(dstDir, { recursive: true })
- for (const file of walk(srcDir)) {
- if (file === srcMd) continue
- const rel = relative(srcDir, file)
- const dst = join(dstDir, rel)
- mkdirSync(dirname(dst), { recursive: true })
- if (file.endsWith(".md")) writeFileSync(dst, applyRewrites(readFileSync(file, "utf8"), skill))
- else copyFileSync(file, dst)
- }
- const newBody = injectNote(applyRewrites(body, skill), NOTE)
- writeFileSync(join(dstDir, "SKILL.md"), `---\nname: ${PREFIX}${skill}\ndescription: ${yamlValue(description)}\n---\n\n${newBody}`)
- n++
- }
- return n
- }
- // ---------------------------------------------------------------------------
- // generate: agents
- // ---------------------------------------------------------------------------
- function generateAgents() {
- const dir = join(OUT, "agent")
- rmSync(dir, { recursive: true, force: true })
- mkdirSync(dir, { recursive: true })
- let n = 0
- for (const agent of SRC_AGENTS) {
- const { frontmatter, body } = splitFrontmatter(readFileSync(join(SRC, "agents", agent + ".md"), "utf8"))
- const meta = frontmatter ? parseFrontmatter(frontmatter) : {}
- const lines = [
- "---",
- `description: ${yamlValue(applyRewrites(meta.description ?? "", agent).replace(/\s+/g, " ").trim())}`,
- "mode: subagent",
- ]
- if (meta.color && AGENT_COLOR[meta.color]) lines.push(`color: ${AGENT_COLOR[meta.color]}`)
- // Claude Code whitelisted a few agents for full bash in a PreToolUse hook.
- // opencode expresses the same policy declaratively per agent.
- if (FULL_BASH_AGENTS.has(agent)) {
- lines.push("permission:", " bash: allow")
- } else {
- lines.push("permission:", " bash:")
- for (const [pattern, action] of Object.entries(DESTRUCTIVE_BASH)) lines.push(` "${pattern}": ${action}`)
- lines.push(' "*": allow')
- }
- lines.push("---")
- let prompt = applyRewrites(body, null)
- // `skills:` frontmatter is Claude Code only; opencode has no equivalent, so
- // the preloaded skill becomes an explicit first instruction.
- const preloaded = (frontmatter ?? "").match(/^skills:\s*\n((?:\s+-\s+.+\n?)+)/m)
- if (preloaded) {
- const names = [...preloaded[1].matchAll(/-\s+(.+)/g)].map((m) => m[1].trim())
- const rel = names.map((s) => `.opencode/skill/${PREFIX}${s}/SKILL.md`)
- prompt = `**Preloaded skill**: read ${rel.map((r) => `\`${r}\``).join(" and ")} first and follow it for every operation below.\n\n${prompt}`
- }
- writeFileSync(join(dir, PREFIX + agent + ".md"), `${lines.join("\n")}\n\n${prompt.trim()}\n`)
- n++
- }
- return n
- }
- // ---------------------------------------------------------------------------
- // generate: commands
- // ---------------------------------------------------------------------------
- function generateCommands() {
- const dir = join(OUT, "command")
- rmSync(dir, { recursive: true, force: true })
- mkdirSync(dir, { recursive: true })
- let n = 0
- for (const command of SRC_COMMANDS) {
- const { frontmatter, body } = splitFrontmatter(readFileSync(join(SRC, "commands", command + ".md"), "utf8"))
- const meta = frontmatter ? parseFrontmatter(frontmatter) : {}
- let template = applyRewrites(body, null)
- // Claude Code injects the invoked command's name via a <command-name> tag;
- // opencode has no such tag, so substitute the name directly.
- const tag = new RegExp("`?<command-name>`?", "g")
- template = template.replace(tag, `\`${PREFIX}${command}\``).replace(/THIS command only/g, `the \`${PREFIX}${command}\` command only`)
- // `$ARGUMENTS` is opencode's argument placeholder; Claude Code passed the
- // invocation string implicitly.
- if (!template.includes("$ARGUMENTS")) {
- template = `Invocation arguments: \`$ARGUMENTS\`\n\n${template}`
- }
- const description = applyRewrites(meta.description ?? "", command).replace(/\s+/g, " ").trim()
- writeFileSync(
- join(dir, PREFIX + command + ".md"),
- `---\ndescription: ${yamlValue(description)}\nagent: build\n---\n\n${COMMAND_NOTE}\n\n${template.trim()}\n`,
- )
- n++
- }
- return n
- }
- // ---------------------------------------------------------------------------
- function injectNote(body, note) {
- if (!body.startsWith("#")) return `${note}\n\n${body}`
- const nl = body.indexOf("\n")
- const title = body.slice(0, nl)
- return `${title}\n\n${note}\n${body.slice(nl + 1)}`
- }
- // ---------------------------------------------------------------------------
- if (!existsSync(SRC)) {
- console.error(`error: maister source not found at ${relative(ROOT, SRC)}\n run: git clone https://github.com/SkillPanel/maister.git maister`)
- process.exit(1)
- }
- mkdirSync(OUT, { recursive: true })
- const skills = generateSkills()
- const agents = generateAgents()
- const commands = generateCommands()
- console.log(`maister v${VERSION} -> opencode`)
- console.log(` ${skills} skills -> .opencode/skill/${PREFIX}*/`)
- console.log(` ${agents} agents -> .opencode/agent/${PREFIX}*.md`)
- console.log(` ${commands} commands -> .opencode/command/${PREFIX}*.md`)
- console.log(`\nRestart opencode to load them.`)
|