feat: v2 plugin injecting manifest skills with guardrails hook and init bin for agents
This commit is contained in:
1 parent
4ce4466a97
commit
c81bc91e03
6 files changed
+443
No files matched your search
@@ -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<string, unknown> | 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<readonly [string, RegExp]> = [
|
||||
["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"
|
||||
}
|
||||
@@ -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<string>(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 }
|
||||
+74
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
@@ -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<string, unknown>, opts: { agents?: { id: string }[]; dir?: string } = {}) {
|
||||
const added: Record<string, unknown>[] = []
|
||||
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)
|
||||
})
|
||||
Reference in new issue
Block a user