diff --git a/adapters/opencode/dev-workflows.ts b/adapters/opencode/dev-workflows.ts new file mode 100644 index 0000000..8e8cf15 --- /dev/null +++ b/adapters/opencode/dev-workflows.ts @@ -0,0 +1,34 @@ +// dev-workflows for opencode: the same guard, formatter and gate scripts the Claude Code hooks run. +// Installed by `python3 scripts/install.py opencode`, which replaces __DW_ROOT__ with the checkout path. +// opencode cannot block the end of a turn, so a missing or failing gate is logged instead of blocking. +const ROOT = "__DW_ROOT__" + +const DevWorkflows = async (ctx: any) => { + const $ = ctx.$ ?? (globalThis as any).Bun?.$ + const directory = ctx.directory ?? ctx.location?.directory ?? process.cwd() + return { + "tool.execute.before": async (input: any, output: any) => { + if (!["bash", "shell"].includes(String(input?.tool ?? "").toLowerCase())) return + const command = output?.args?.command + if (typeof command !== "string" || !command) return + const r = await $`python3 ${ROOT}/scripts/hooks/bash_guard.py --check ${command}`.quiet().nothrow() + if (r.exitCode === 2) throw new Error(String(r.stdout).trim()) + }, + "tool.execute.after": async (input: any, output: any) => { + if (!["edit", "write", "patch", "multiedit"].includes(String(input?.tool ?? "").toLowerCase())) return + const file = input?.args?.filePath ?? output?.args?.filePath + if (typeof file === "string" && file) { + await $`python3 ${ROOT}/scripts/hooks/post_edit.py --file ${file}`.quiet().nothrow() + } + }, + event: async ({ event }: any) => { + if (event?.type !== "session.idle") return + const r = await $`python3 ${ROOT}/scripts/hooks/stop_gate.py --check ${directory}`.quiet().nothrow() + if (r.exitCode === 2) { + await ctx.client?.app?.log?.({ body: { service: "dev-workflows", level: "warn", message: String(r.stdout).trim() } }) + } + }, + } +} + +export default { id: "dev-workflows", setup: DevWorkflows } diff --git a/scripts/install.py b/scripts/install.py new file mode 100644 index 0000000..92c20d8 --- /dev/null +++ b/scripts/install.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Install dev-workflows into Claude Code, opencode or Hermes Agent. + + python3 scripts/install.py claude # plugin via local marketplace (skills, agents, hooks) + python3 scripts/install.py opencode # skills + agents + plugin under ~/.config/opencode + python3 scripts/install.py hermes # skills under ~/.hermes/skills (no agents/hooks there) + +opencode and Hermes do not substitute ${CLAUDE_PLUGIN_ROOT}, so the copies point at this checkout: +keep it where it is, or re-run the install after moving it. +""" +import argparse, os, re, shutil, subprocess, sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +MARKER = ".dev-workflows-install" +TEXT_EXT = {".md", ".txt", ".json", ".toml", ".yaml", ".yml"} + + +def copy_tree(src, dst, force): + if os.path.exists(dst) and not os.path.exists(os.path.join(dst, MARKER)) and not force: + sys.exit(f"{dst} já existe e não foi instalado por este script. Use --force para sobrescrever.") + shutil.rmtree(dst, ignore_errors=True) + shutil.copytree(src, dst) + for dirpath, _, names in os.walk(dst): + for n in names: + if os.path.splitext(n)[1] in TEXT_EXT: + p = os.path.join(dirpath, n) + text = open(p, encoding="utf-8", errors="ignore").read() + if "${CLAUDE_PLUGIN_ROOT}" in text: + open(p, "w", encoding="utf-8").write(text.replace("${CLAUDE_PLUGIN_ROOT}", ROOT)) + open(os.path.join(dst, MARKER), "w").write(ROOT + "\n") + + +def install_skills(target_dir, force): + os.makedirs(target_dir, exist_ok=True) + names = sorted(d for d in os.listdir(os.path.join(ROOT, "skills")) + if os.path.isfile(os.path.join(ROOT, "skills", d, "SKILL.md"))) + for name in names: + copy_tree(os.path.join(ROOT, "skills", name), os.path.join(target_dir, name), force) + print(f"{len(names)} skills em {target_dir}") + + +def opencode_agent(path): + """Claude Code agent frontmatter -> opencode subagent frontmatter.""" + text = open(path, encoding="utf-8").read() + m = re.match(r"---\n(.*?)\n---\n(.*)", text, re.S) + meta = dict(re.findall(r"^(\w+):\s*(.*)$", m.group(1), re.M)) + tools = {t.strip() for t in meta.get("tools", "").split(",")} + skills = re.findall(r"[\w-]+", meta.get("skills", "")) + perm = {"edit": "allow" if tools & {"Edit", "Write"} else "deny", + "bash": "allow" if "Bash" in tools else "deny", + "webfetch": "allow" if "WebFetch" in tools else "deny"} + body = m.group(2).replace("${CLAUDE_PLUGIN_ROOT}", ROOT) + if skills: + body = f"Antes de começar, carregue a(s) skill(s): {', '.join(skills)}.\n\n" + body + head = ["# dev-workflows", f"description: {meta['description']}", "mode: subagent", "permission:"] + head += [f" {k}: {v}" for k, v in perm.items()] + return "---\n" + "\n".join(head) + "\n---\n\n" + body + + +def install_opencode(force): + base = os.path.expanduser("~/.config/opencode") + install_skills(os.path.join(base, "skills"), force) + agents_dir = os.path.join(base, "agents") + os.makedirs(agents_dir, exist_ok=True) + for f in sorted(os.listdir(os.path.join(ROOT, "agents"))): + dst = os.path.join(agents_dir, f) + if os.path.exists(dst) and "dev-workflows" not in open(dst, encoding="utf-8").read() and not force: + sys.exit(f"{dst} já existe e não é deste pacote. Use --force para sobrescrever.") + open(dst, "w", encoding="utf-8").write(opencode_agent(os.path.join(ROOT, "agents", f))) + plugins_dir = os.path.join(base, "plugins") + os.makedirs(plugins_dir, exist_ok=True) + plugin = open(os.path.join(ROOT, "adapters", "opencode", "dev-workflows.ts"), encoding="utf-8").read() + open(os.path.join(plugins_dir, "dev-workflows.ts"), "w", encoding="utf-8").write(plugin.replace("__DW_ROOT__", ROOT)) + print(f"agentes em {agents_dir}, plugin em {plugins_dir}/dev-workflows.ts") + + +def install_claude(): + for cmd in (["claude", "plugin", "marketplace", "add", ROOT], ["claude", "plugin", "install", "dev-workflows@dev-workflows"]): + print("$ " + " ".join(cmd)) + if subprocess.run(cmd).returncode != 0: + sys.exit("falhou; rode o comando acima manualmente para ver o erro") + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("target", choices=["claude", "opencode", "hermes"]) + ap.add_argument("--force", action="store_true", help="sobrescreve pastas com o mesmo nome que não vieram deste script") + a = ap.parse_args() + if a.target == "claude": + install_claude() + elif a.target == "opencode": + install_opencode(a.force) + else: + install_skills(os.path.expanduser("~/.hermes/skills"), a.force) + print("Hermes: agentes e hooks não se aplicam; rode o gate manualmente.") + + +if __name__ == "__main__": + main()