|
|
@@ -0,0 +1,799 @@
|
|
|
+#!/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.`)
|