OpenCode's legacy plugin loader iterates every exported function in a plugin module and tries to invoke each one with the plugin context object. parseCommandFile was exported alongside the default plugin function, so the loader called it with an object instead of a file path and crashed with 'path must be a string or a file descriptor' before any hook ran, which aborted the whole plugin load. Move the frontmatter parser to its own .cjs sibling; the plugin module now exposes exactly one top-level export (the default plugin function). The plugin requires the parser via createRequire, and the existing parseCommandFile tests now require the sibling directly. Co-authored-by: ayushere <ayushere@users.noreply.github.com>
95 lines
3.5 KiB
JavaScript
95 lines
3.5 KiB
JavaScript
// ponytail — OpenCode plugin.
|
|
//
|
|
// Injects the ponytail ruleset into every chat's system prompt at the active
|
|
// intensity, persists /ponytail mode switches, and registers slash commands so
|
|
// they work when the package is installed from npm. 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": ["@dietrichgebert/ponytail"] }
|
|
|
|
import { createRequire } from 'module';
|
|
import fs from 'fs';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
|
// 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');
|
|
const { parseCommandFile } = require('./ponytail-frontmatter.cjs');
|
|
|
|
// 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) {}
|
|
};
|
|
|
|
const ponytailSkillsDir = path.resolve(__dirname, '../../skills');
|
|
|
|
return {
|
|
// Register slash commands + skills directory.
|
|
config: async (config) => {
|
|
if (!config.command) config.command = {};
|
|
const commandDir = path.join(__dirname, '..', 'command');
|
|
try {
|
|
for (const file of fs.readdirSync(commandDir).filter((f) => f.endsWith('.md'))) {
|
|
const name = path.basename(file, '.md');
|
|
const parsed = parseCommandFile(path.join(commandDir, file));
|
|
if (parsed) config.command[name] = parsed;
|
|
}
|
|
} catch (e) {}
|
|
|
|
config.skills = config.skills || {};
|
|
config.skills.paths = config.skills.paths || [];
|
|
if (!config.skills.paths.includes(ponytailSkillsDir)) {
|
|
config.skills.paths.push(ponytailSkillsDir);
|
|
}
|
|
},
|
|
|
|
// 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 <level>` 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 args = (input.arguments || '').trim();
|
|
const mode = args ? normalizePersistedMode(args) : getDefaultMode();
|
|
if (!mode) return;
|
|
writeMode(mode);
|
|
log('info', 'ponytail ' + mode);
|
|
},
|
|
};
|
|
};
|