From 39656246798c2ed08f245510144ba2c2da9d5631 Mon Sep 17 00:00:00 2001 From: Emeriko Date: Sat, 13 Jun 2026 02:58:53 +0200 Subject: [PATCH] feat: add OpenCode adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thin OpenCode plugin that injects the ponytail ruleset via the experimental.chat.system.transform hook and persists /ponytail level switches via command.execute.before. Reuses the shared instruction builder (hooks/ponytail-instructions.js) — no copied SKILL.md, no duplicated logic, no edits to the shared hooks. Adds .opencode/command/{ponytail,ponytail-review}.md, an example opencode.json, a smoke test, and README / agent-portability / help-card docs. Verified against OpenCode 1.17.4: plugin loads, the transform hook fires, the injected system prompt reaches the model, and mode gating (off/full) works. Co-Authored-By: Claude Opus 4.8 --- .opencode/command/ponytail-review.md | 5 +++ .opencode/command/ponytail.md | 5 +++ .opencode/plugins/ponytail.mjs | 65 ++++++++++++++++++++++++++++ README.md | 10 +++++ docs/agent-portability.md | 1 + opencode.json | 4 ++ skills/ponytail-help/SKILL.md | 3 +- tests/opencode-plugin.test.js | 64 +++++++++++++++++++++++++++ 8 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 .opencode/command/ponytail-review.md create mode 100644 .opencode/command/ponytail.md create mode 100644 .opencode/plugins/ponytail.mjs create mode 100644 opencode.json create mode 100644 tests/opencode-plugin.test.js diff --git a/.opencode/command/ponytail-review.md b/.opencode/command/ponytail-review.md new file mode 100644 index 0000000..74aa31c --- /dev/null +++ b/.opencode/command/ponytail-review.md @@ -0,0 +1,5 @@ +--- +description: Review changes for over-engineering — what can be deleted +--- + +Review the current code changes for over-engineering only — not correctness. One line per finding: L: . . Tags: delete (dead code/speculative feature), stdlib (reinvented standard library), native (dependency doing what the platform does), yagni (abstraction with one implementation), shrink (same logic, fewer lines). End with the net lines removable. If nothing to cut: 'Lean already. Ship.' diff --git a/.opencode/command/ponytail.md b/.opencode/command/ponytail.md new file mode 100644 index 0000000..4f7e162 --- /dev/null +++ b/.opencode/command/ponytail.md @@ -0,0 +1,5 @@ +--- +description: Switch ponytail intensity level (lite/full/ultra/off) +--- + +Switch to ponytail $ARGUMENTS mode. If no level specified, use full. Lazy senior dev mode — before any code: does it need to exist at all (YAGNI)? Does the standard library do it? A native platform feature? Can it be one line? Build the minimum that works. No unrequested abstractions, no avoidable dependencies, no boilerplate. Mark intentional simplifications with a ponytail: comment. diff --git a/.opencode/plugins/ponytail.mjs b/.opencode/plugins/ponytail.mjs new file mode 100644 index 0000000..2fd3625 --- /dev/null +++ b/.opencode/plugins/ponytail.mjs @@ -0,0 +1,65 @@ +// ponytail — OpenCode plugin. +// +// Injects the ponytail ruleset into every chat's system prompt at the active +// intensity, and persists /ponytail mode switches. Reuses the shared instruction +// builder so Claude Code, Codex, pi, and OpenCode all read one source of truth. +// +// OpenCode loads this as a server plugin — add it to your opencode.json: +// { "plugin": ["./.opencode/plugins/ponytail.mjs"] } + +import { createRequire } from 'module'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +// The shared instruction builder is CommonJS; bridge to it from this ES module. +const require = createRequire(import.meta.url); +const { getPonytailInstructions } = require('../../hooks/ponytail-instructions'); +const { getDefaultMode, normalizePersistedMode } = require('../../hooks/ponytail-config'); + +// OpenCode has no flag-file convention of its own; keep mode beside its config. +const statePath = path.join( + process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), + 'opencode', + '.ponytail-active', +); + +function readMode() { + try { + return normalizePersistedMode(fs.readFileSync(statePath, 'utf8').trim()) || getDefaultMode(); + } catch (e) { + return getDefaultMode(); + } +} + +function writeMode(mode) { + fs.mkdirSync(path.dirname(statePath), { recursive: true }); + fs.writeFileSync(statePath, mode); +} + +export default async ({ client } = {}) => { + const log = (level, message) => { + try { client && client.app && client.app.log({ body: { service: 'ponytail', level, message } }); } catch (e) {} + }; + + return { + // Append the ruleset to the system prompt every turn. + 'experimental.chat.system.transform': async (_input, output) => { + const mode = readMode(); + if (mode === 'off') return; + output.system.push(getPonytailInstructions(mode)); + }, + + // Persist `/ponytail ` so the next turn's injection follows it. + // ponytail: mode applies from the next message, not the current one — the + // transform reads the flag the command writes. Good enough; switch to a + // synchronous store if same-turn switching ever matters. + 'command.execute.before': async (input) => { + if (!input || input.command !== 'ponytail') return; + // `off` is persisted like any mode; the transform reads it and stays silent. + const mode = normalizePersistedMode((input.arguments || '').trim()) || getDefaultMode(); + writeMode(mode); + log('info', 'ponytail ' + mode); + }, + }; +}; diff --git a/README.md b/README.md index a432eb8..c3f8b9a 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,16 @@ open `/hooks`, review and trust its two lifecycle hooks, and start a new thread. pi install git:github.com/DietrichGebert/ponytail ``` +### OpenCode + +Run OpenCode from a checkout of this repo (the plugin reuses its `hooks/` and `skills/`), and add to `opencode.json`: + +```json +{ "plugin": ["./.opencode/plugins/ponytail.mjs"] } +``` + +Injects the ruleset every turn at the active level; adds `/ponytail` and `/ponytail-review`. OpenCode also auto-loads this repo's `AGENTS.md`, so the rules hold even without the plugin — the plugin adds the `lite/full/ultra/off` levels. + That was it. He'd be proud. He won't say it. Active every session. `/ponytail-review` finds what to delete in your diff. `/ponytail ultra` exists for when the codebase has wronged you personally. `/ponytail-help` explains the rest. diff --git a/docs/agent-portability.md b/docs/agent-portability.md index 7412d0f..9b71700 100644 --- a/docs/agent-portability.md +++ b/docs/agent-portability.md @@ -10,6 +10,7 @@ to load in a given agent. |------|-------|-------| | Claude Code | `.claude-plugin/`, `commands/`, `hooks/` | Full plugin install with session activation, mode tracking, commands, and statusline support. | | Codex | `.codex-plugin/plugin.json`, `hooks/hooks.json`, `hooks/`, `skills/` | Plugin install with the same skills plus lifecycle hooks for activation and mode tracking. | +| OpenCode | `.opencode/plugins/ponytail.mjs`, `.opencode/command/`, `hooks/`, `skills/` | Server plugin injects the ruleset each turn via `experimental.chat.system.transform` and persists `/ponytail` switches; reuses the shared instruction builder. | | Cursor | `.cursor/rules/ponytail.mdc` | Always-on project rule. | | Windsurf | `.windsurf/rules/ponytail.md` | Project rule. | | Cline | `.clinerules/ponytail.md` | Project rule. | diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000..2c0be99 --- /dev/null +++ b/opencode.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://opencode.ai/config.json", + "plugin": ["./.opencode/plugins/ponytail.mjs"] +} diff --git a/skills/ponytail-help/SKILL.md b/skills/ponytail-help/SKILL.md index 076db39..c82cba6 100644 --- a/skills/ponytail-help/SKILL.md +++ b/skills/ponytail-help/SKILL.md @@ -30,7 +30,8 @@ Level sticks until changed or session end. | **ponytail-help** | `/ponytail-help` | This card. | Codex uses `@ponytail`, `@ponytail-review`, and `@ponytail-help`; Claude Code -uses the slash-command forms above. +and OpenCode use the slash-command forms above (OpenCode ships `/ponytail` and +`/ponytail-review`). ## Deactivate diff --git a/tests/opencode-plugin.test.js b/tests/opencode-plugin.test.js new file mode 100644 index 0000000..6d9ceab --- /dev/null +++ b/tests/opencode-plugin.test.js @@ -0,0 +1,64 @@ +#!/usr/bin/env node +// Smoke test for the OpenCode adapter: the plugin's hooks behave against the +// real (structural) OpenCode hook shapes. No live OpenCode needed. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { pathToFileURL } = require('url'); + +// Point the plugin's mode-flag at a temp config home BEFORE it loads — the +// plugin resolves its state path once at load (as it does under a real OpenCode +// process, where XDG_CONFIG_HOME is already set). The dynamic import below runs +// after this assignment, so the ordering holds. +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ponytail-opencode-')); +process.env.XDG_CONFIG_HOME = tmp; +delete process.env.PONYTAIL_DEFAULT_MODE; +const statePath = path.join(tmp, 'opencode', '.ponytail-active'); + +let loadPlugin; +test.before(async () => { + const url = pathToFileURL(path.join(__dirname, '..', '.opencode', 'plugins', 'ponytail.mjs')); + loadPlugin = (await import(url)).default; +}); + +function transform(hooks) { + const output = { system: [] }; + return hooks['experimental.chat.system.transform']({ model: {} }, output).then(() => output.system); +} + +test('system.transform injects the ruleset at the default mode (full)', async () => { + try { fs.unlinkSync(statePath); } catch (e) {} + const hooks = await loadPlugin({}); + const system = await transform(hooks); + assert.equal(system.length, 1); + assert.match(system[0], /PONYTAIL MODE ACTIVE — level: full/); + assert.match(system[0], /lazy senior developer/); +}); + +test('command.execute.before persists /ponytail ultra, transform follows it', async () => { + const hooks = await loadPlugin({}); + await hooks['command.execute.before']({ command: 'ponytail', arguments: 'ultra', sessionID: 's' }); + assert.equal(fs.readFileSync(statePath, 'utf8'), 'ultra'); + const system = await transform(hooks); + assert.match(system[0], /PONYTAIL MODE ACTIVE — level: ultra/); +}); + +test('/ponytail off persists off and transform injects nothing', async () => { + const hooks = await loadPlugin({}); + await hooks['command.execute.before']({ command: 'ponytail', arguments: 'off', sessionID: 's' }); + assert.equal(fs.readFileSync(statePath, 'utf8'), 'off'); + const system = await transform(hooks); + assert.deepEqual(system, []); +}); + +test('unrelated commands do not touch the flag', async () => { + try { fs.unlinkSync(statePath); } catch (e) {} + const hooks = await loadPlugin({}); + await hooks['command.execute.before']({ command: 'commit', arguments: 'x', sessionID: 's' }); + assert.equal(fs.existsSync(statePath), false); +}); + +test.after(() => fs.rmSync(tmp, { recursive: true, force: true }));