164 lines
6.0 KiB
TypeScript
164 lines
6.0 KiB
TypeScript
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`)
|
|
assert.ok(!(skill.content as string).startsWith("---"), `${skill.id}: frontmatter stripped`)
|
|
const entry = manifest.find((m) => m.id === skill.id)!
|
|
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)
|
|
})
|