diff --git a/src/guardrails.ts b/src/guardrails.ts new file mode 100644 index 0000000..c709de1 --- /dev/null +++ b/src/guardrails.ts @@ -0,0 +1,40 @@ +// Ports the pattern list of upstream skills/misc/git-guardrails-claude-code (block-dangerous-git.sh) +// to an OpenCode v2 tool hook. Opt-in via plugin options { guardrails: true }. + +export type Options = { guardrails: boolean } + +export function resolveOptions(options: Record | undefined): Options { + const opts = options ?? {} + const guardrails = opts.guardrails ?? false + if (typeof guardrails !== "boolean") { + throw new Error(`pocock: option guardrails must be a boolean, got ${JSON.stringify(guardrails)}`) + } + return { guardrails } +} + +// Same patterns as the upstream hook script (grep -E semantics), in order. +const DANGEROUS: ReadonlyArray = [ + ["git push", /git push/], + ["git reset --hard", /git reset --hard/], + ["git clean -fd", /git clean -fd/], + ["git clean -f", /git clean -f/], + ["git branch -D", /git branch -D/], + ["git checkout .", /git checkout \./], + ["git restore .", /git restore \./], + ["push --force", /push --force/], + ["reset --hard", /reset --hard/], +] + +export function blockedPattern(command: string): string | undefined { + for (const [pattern, re] of DANGEROUS) if (re.test(command)) return pattern + return undefined +} + +export function guardrailError(command: string, pattern: string): string { + return `BLOCKED: '${command}' matches dangerous pattern '${pattern}'. The user has prevented you from doing this.` +} + +/** True when a tool-hook event targets the shell tool (v2 id may be "bash" or "shell"). */ +export function isShellTool(tool: string): boolean { + return tool === "bash" || tool === "shell" +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..dc9ac6b --- /dev/null +++ b/src/index.ts @@ -0,0 +1,69 @@ +import { existsSync, readFileSync } from "node:fs" +import path from "node:path" +import { Plugin, Skill } from "@opencode/plugin" +import { blockedPattern, guardrailError, isShellTool, resolveOptions } from "./guardrails.ts" +import { loadManifest, packageRoot, packageVersion, skillFile } from "./manifest.ts" + +const AGENT_IDS = ["pocock", "pocock-worker", "pocock-reviewer"] as const + +function installedAgentVersion(projectDir: string): string | undefined { + const file = path.join(projectDir, ".opencode", "agents", ".pocock-version") + if (!existsSync(file)) return undefined + return readFileSync(file, "utf8").trim() +} + +export default Plugin.define({ + id: "haylan.pocock", + async setup(ctx) { + // Fails loudly on bad options or a broken install instead of silently shipping no skills. + const options = resolveOptions(ctx.options) + const entries = loadManifest() + + await ctx.skill.transform((editor) => { + for (const entry of entries) { + const file = skillFile(entry) + // Brand types (Skill.ID/Name/AbsolutePath) are nominal-only; runtime values are plain + // strings, asserted by test/plugin.test.ts and the serve-based smoke run. + editor.add({ + id: entry.id, + name: entry.name, + description: entry.description, + autoinvoke: entry.autoinvoke, + path: file, + content: readFileSync(file, "utf8"), + } as unknown as Skill.Info) + } + }) + + if (options.guardrails) { + await ctx.tool.hook("execute.before", (event) => { + if (!isShellTool(event.tool)) return + const command = (event.input as { command?: unknown } | undefined)?.command + if (typeof command !== "string") return + const pattern = blockedPattern(command) + if (pattern) throw new Error(`pocock guardrails: ${guardrailError(command, pattern)}`) + }) + } + + // Agents cannot be registered by plugins in v2 (AgentEditor has no add), so guide the user + // to the one required copy step instead of failing or writing into their repo. + let warned = false + await ctx.agent.transform((editor) => { + if (warned) return + warned = true + const ids = new Set(editor.list().map((agent) => String(agent.id))) + const have = installedAgentVersion(ctx.location.directory) + const want = packageVersion() + const initCmd = "npx @haylan/opencode-pocock init" + if (!AGENT_IDS.some((id) => ids.has(id))) { + console.warn(`pocock: skills loaded, but the pocock agents are not installed — run: ${initCmd}`) + } else if (have !== want) { + console.warn( + `pocock: installed agents are ${have ? `from v${have}` : "unversioned"}, package is v${want} — refresh: ${initCmd}`, + ) + } + }) + }, +}) + +export { packageRoot } diff --git a/src/init.ts b/src/init.ts new file mode 100644 index 0000000..184636d --- /dev/null +++ b/src/init.ts @@ -0,0 +1,74 @@ +#!/usr/bin/env node +// Copies the generated agent files into a discovered agents directory and stamps the version +// the plugin checks against. Never overwrites locally modified files without --force. +// +// opencode-pocock init [-g|--global] [--force] +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs" +import os from "node:os" +import path from "node:path" +import { fileURLToPath } from "node:url" +import { packageRoot, packageVersion } from "./manifest.ts" + +export type InitResult = { target: string; copied: string[]; skipped: string[] } + +export function agentsTarget(global: boolean, cwd: string, home: string): string { + return global + ? path.join(home, ".config", "opencode", "agents") + : path.join(cwd, ".opencode", "agents") +} + +export function runInit(opts: { + argv: string[] + cwd: string + home?: string + pkgRoot?: string +}): InitResult { + const args = new Set(opts.argv) + const global = args.has("-g") || args.has("--global") + const force = args.has("--force") + const unknown = opts.argv.filter((a) => !["-g", "--global", "--force", "init"].includes(a)) + if (unknown.length) throw new Error(`opencode-pocock: unknown argument ${unknown[0]} (try: init [-g] [--force])`) + + const root = opts.pkgRoot ?? packageRoot + const sourceDir = path.join(root, "agents") + if (!existsSync(sourceDir)) throw new Error(`opencode-pocock: no agents/ in package at ${root}`) + const target = agentsTarget(global, opts.cwd, opts.home ?? os.homedir()) + mkdirSync(target, { recursive: true }) + + const result: InitResult = { target, copied: [], skipped: [] } + for (const file of readdirSync(sourceDir).filter((f) => f.endsWith(".md")).sort()) { + const from = path.join(sourceDir, file) + const to = path.join(target, file) + const content = readFileSync(from, "utf8") + // A differing existing file is treated as user work: keep it unless forced. + if (existsSync(to) && readFileSync(to, "utf8") !== content && !force) { + result.skipped.push(file) + continue + } + writeFileSync(to, content) + result.copied.push(file) + } + writeFileSync(path.join(target, ".pocock-version"), packageVersion(root) + "\n") + return result +} + +function main() { + const result = runInit({ argv: process.argv.slice(2), cwd: process.cwd() }) + for (const file of result.copied) console.log(`installed ${path.join(result.target, file)}`) + for (const file of result.skipped) { + console.log(`skipped ${file}: local copy differs from the package (re-run with --force to replace)`) + } + console.log( + `\nNext: make sure opencode.json loads the plugin:\n { "plugins": ["@haylan/opencode-pocock"] }\n` + + `Then run /setup-matt-pocock-skills once in each project and start OpenCode with --agent pocock.`, + ) +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + try { + main() + } catch (err) { + console.error((err as Error).message) + process.exit(1) + } +} diff --git a/src/manifest.ts b/src/manifest.ts new file mode 100644 index 0000000..a868bcc --- /dev/null +++ b/src/manifest.ts @@ -0,0 +1,40 @@ +import { readFileSync } from "node:fs" +import path from "node:path" +import { fileURLToPath } from "node:url" + +export type ManifestEntry = { + id: string + name: string + description: string + autoinvoke: boolean + path: string +} + +// Works from dist/ (compiled) and src/ (tests, type stripping): both are one level under the root. +export const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") + +export function loadManifest(rootDir: string = packageRoot): ManifestEntry[] { + const file = path.join(rootDir, "manifest.json") + let entries: ManifestEntry[] + try { + entries = JSON.parse(readFileSync(file, "utf8")) + } catch { + throw new Error( + `pocock: cannot read ${file} — the package install is broken; reinstall @haylan/opencode-pocock`, + ) + } + if (!Array.isArray(entries) || entries.some((e) => !e.id || !e.path)) { + throw new Error(`pocock: malformed manifest at ${file}`) + } + return entries +} + +/** Absolute SKILL.md location for a manifest entry; relative paths resolve against the package. */ +export function skillFile(entry: ManifestEntry, rootDir: string = packageRoot): string { + return path.resolve(rootDir, entry.path) +} + +export function packageVersion(rootDir: string = packageRoot): string { + const pkg = JSON.parse(readFileSync(path.join(rootDir, "package.json"), "utf8")) + return pkg.version as string +} diff --git a/test/init.test.ts b/test/init.test.ts new file mode 100644 index 0000000..b442899 --- /dev/null +++ b/test/init.test.ts @@ -0,0 +1,63 @@ +import assert from "node:assert/strict" +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" +import { test } from "node:test" +import { agentsTarget, runInit } from "../src/init.ts" + +function fixturePkg() { + const pkgRoot = mkdtempSync(path.join(tmpdir(), "init-pkg-")) + mkdirSync(path.join(pkgRoot, "agents")) + writeFileSync(path.join(pkgRoot, "package.json"), JSON.stringify({ version: "9.9.9" })) + writeFileSync(path.join(pkgRoot, "agents", "pocock.md"), "---\nmode: primary\n---\nv1\n") + writeFileSync(path.join(pkgRoot, "agents", "pocock-worker.md"), "---\nmode: subagent\n---\nv1\n") + return pkgRoot +} + +test("runInit copies agents, stamps version, targets project or global dir", () => { + const pkgRoot = fixturePkg() + const home = mkdtempSync(path.join(tmpdir(), "init-home-")) + const cwd = mkdtempSync(path.join(tmpdir(), "init-cwd-")) + try { + const project = runInit({ argv: ["init"], cwd, pkgRoot, home }) + assert.equal(project.target, path.join(cwd, ".opencode", "agents")) + assert.deepEqual(project.copied, ["pocock-worker.md", "pocock.md"]) + assert.deepEqual(project.skipped, []) + assert.equal(readFileSync(path.join(project.target, "pocock.md"), "utf8"), "---\nmode: primary\n---\nv1\n") + assert.equal(readFileSync(path.join(project.target, ".pocock-version"), "utf8"), "9.9.9\n") + + const global = runInit({ argv: ["init", "-g"], cwd, pkgRoot, home }) + assert.equal(global.target, path.join(home, ".config", "opencode", "agents")) + assert.ok(existsSync(path.join(global.target, "pocock.md"))) + + // identical files are refreshed silently; locally modified files are kept without --force + writeFileSync(path.join(project.target, "pocock.md"), "---\nmode: primary\n---\nUSER EDIT\n") + const again = runInit({ argv: ["init"], cwd, pkgRoot, home }) + assert.deepEqual(again.skipped, ["pocock.md"]) + assert.deepEqual(again.copied, ["pocock-worker.md"]) + assert.match(readFileSync(path.join(project.target, "pocock.md"), "utf8"), /USER EDIT/) + + const forced = runInit({ argv: ["init", "--force"], cwd, pkgRoot, home }) + assert.deepEqual(forced.skipped, []) + assert.match(readFileSync(path.join(project.target, "pocock.md"), "utf8"), /v1/) + + assert.throws(() => runInit({ argv: ["init", "--wat"], cwd, pkgRoot, home }), /unknown argument --wat/) + } finally { + for (const dir of [pkgRoot, home, cwd]) rmSync(dir, { recursive: true, force: true }) + } +}) + +test("agentsTarget resolves global vs project locations", () => { + assert.equal(agentsTarget(false, "/repo", "/home/u"), path.join("/repo", ".opencode", "agents")) + assert.equal(agentsTarget(true, "/repo", "/home/u"), path.join("/home/u", ".config", "opencode", "agents")) +}) + +test("runInit fails clearly when the package has no agents dir", () => { + const empty = mkdtempSync(path.join(tmpdir(), "init-empty-")) + try { + writeFileSync(path.join(empty, "package.json"), "{}") + assert.throws(() => runInit({ argv: ["init"], cwd: empty, pkgRoot: empty, home: empty }), /no agents\/ in package/) + } finally { + rmSync(empty, { recursive: true, force: true }) + } +}) diff --git a/test/plugin.test.ts b/test/plugin.test.ts new file mode 100644 index 0000000..9aa2318 --- /dev/null +++ b/test/plugin.test.ts @@ -0,0 +1,157 @@ +import assert from "node:assert/strict" +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" +import { test } from "node:test" +import plugin from "../src/index.ts" +import { loadManifest } from "../src/manifest.ts" +import { blockedPattern, resolveOptions } from "../src/guardrails.ts" + +const manifest = loadManifest() + +function fakeCtx(options: Record, opts: { agents?: { id: string }[]; dir?: string } = {}) { + const added: Record[] = [] + const hooks: { name: string; fn: (event: unknown) => void }[] = [] + const warns: string[] = [] + const agentEditor = { + list: () => opts.agents ?? [], + get: () => undefined, + default: () => {}, + update: () => {}, + remove: () => {}, + } + const ctx = { + options, + location: { directory: opts.dir ?? tmpdir() }, + skill: { transform: async (fn: (e: { add: (s: unknown) => void }) => void) => fn({ add: (s) => added.push(s) }) }, + tool: { hook: async (name: string, fn: (event: unknown) => void) => hooks.push({ name, fn }) }, + agent: { transform: async (fn: (e: typeof agentEditor) => void) => fn(agentEditor) }, + } + const realWarn = console.warn + console.warn = (msg: string) => warns.push(msg) + return { + ctx: ctx as never, + added, + hooks, + warns, + restore: () => (console.warn = realWarn), + } +} + +test("plugin id", () => { + assert.equal(plugin.id, "haylan.pocock") +}) + +test("setup injects every manifest skill with absolute existing paths and autoinvoke flags", async () => { + const f = fakeCtx({}) + try { + await plugin.setup(f.ctx) + assert.equal(f.added.length, manifest.length) + const ids = f.added.map((s) => s.id) + assert.equal(new Set(ids).size, ids.length, "unique ids") + const hidden = f.added.filter((s) => s.autoinvoke === false) + assert.equal(hidden.length, 16, "user-invoked skills hidden from model") + for (const skill of f.added) { + assert.ok(path.isAbsolute(skill.path as string), `${skill.id}: absolute path`) + assert.ok(existsSync(skill.path as string), `${skill.id}: file exists`) + assert.ok((skill.content as string).length > 0, `${skill.id}: content loaded`) + const entry = manifest.find((m) => m.id === skill.id)! + assert.equal(skill.content, readFileSync(skill.path as string, "utf8")) + assert.equal(skill.description, entry.description) + } + const toSpec = f.added.find((s) => s.id === "to-spec")! + assert.equal(toSpec.autoinvoke, false) + const tdd = f.added.find((s) => s.id === "tdd")! + assert.equal(tdd.autoinvoke, true) + } finally { + f.restore() + } +}) + +test("setup throws on bad options and registers nothing", async () => { + const f = fakeCtx({ guardrails: "yes" }) + try { + await assert.rejects(Promise.resolve(plugin.setup(f.ctx)), /guardrails must be a boolean/) + assert.equal(f.added.length + f.hooks.length, 0) + } finally { + f.restore() + } +}) + +test("guardrails off by default; on: hook blocks dangerous git and only on shell tools", async () => { + const off = fakeCtx({}) + try { + await plugin.setup(off.ctx) + assert.equal(off.hooks.length, 0) + } finally { + off.restore() + } + + const on = fakeCtx({ guardrails: true }) + try { + await plugin.setup(on.ctx) + assert.equal(on.hooks.length, 1) + assert.equal(on.hooks[0].name, "execute.before") + const fire = (tool: string, command: string) => on.hooks[0].fn({ tool, input: { command } }) + assert.throws(() => fire("bash", "git push --force origin main"), /BLOCKED/) + assert.throws(() => fire("shell", "git reset --hard HEAD~1"), /BLOCKED/) + assert.throws(() => fire("bash", "git clean -fd"), /BLOCKED/) + fire("bash", "git status --short") + fire("bash", "npm test") + fire("read", "git push") // non-shell tool untouched + fire("bash", "") // no command edge + on.hooks[0].fn({ tool: "bash", input: undefined }) + } finally { + on.restore() + } +}) + +test("agent staleness warning: missing, stale and up-to-date installs", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "plugin-test-")) + try { + // missing agents + const missing = fakeCtx({}, { agents: [{ id: "build" }], dir }) + try { + await plugin.setup(missing.ctx) + assert.equal(missing.warns.length, 1) + assert.match(missing.warns[0], /not installed — run: npx @haylan\/opencode-pocock init/) + } finally { + missing.restore() + } + + // installed + matching version stamp -> silent + mkdirSync(path.join(dir, ".opencode", "agents"), { recursive: true }) + const pkg = JSON.parse(readFileSync(path.join(process.cwd(), "package.json"), "utf8")) + writeFileSync(path.join(dir, ".opencode", "agents", ".pocock-version"), pkg.version + "\n") + const ok = fakeCtx({}, { agents: [{ id: "pocock" }, { id: "pocock-worker" }], dir }) + try { + await plugin.setup(ok.ctx) + assert.deepEqual(ok.warns, []) + } finally { + ok.restore() + } + + // installed + version mismatch -> refresh hint + writeFileSync(path.join(dir, ".opencode", "agents", ".pocock-version"), "0.0.1\n") + const stale = fakeCtx({}, { agents: [{ id: "pocock-reviewer" }], dir }) + try { + await plugin.setup(stale.ctx) + assert.equal(stale.warns.length, 1) + assert.match(stale.warns[0], /refresh: npx @haylan\/opencode-pocock init/) + } finally { + stale.restore() + } + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +test("options and pattern helpers", () => { + assert.deepEqual(resolveOptions(undefined), { guardrails: false }) + assert.deepEqual(resolveOptions({ guardrails: true }), { guardrails: true }) + assert.equal(blockedPattern("git branch -D feature"), "git branch -D") + assert.equal(blockedPattern("git checkout ."), "git checkout .") + // grep -E substring semantics, faithful to upstream: "git pushy" contains "git push" + assert.equal(blockedPattern("echo git pushy"), "git push") + assert.equal(blockedPattern("git status && npm test"), undefined) +})