feat: install into Claude Code, opencode or Hermes from one script
opencode reads the same SKILL.md files but needs its own subagent format and a TypeScript plugin for the guard, formatter and gate, so install.py converts the agents and points the copies at this checkout. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
f2db2d69da
commit
29b5ebf400
2 files changed
+133
No files matched your search
@@ -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 }
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user