feat: publish ponytail as an installable npm package for OpenCode and Pi (#197)

* feat: register slash commands from .opencode/command/*.md

Add parseCommandFile() to read frontmatter-described markdown files
and wire them into opencode's config.command during init.

Extend the config hook to scan .opencode/command/ and register each
.md file as a named slash command.

Update the plugin doc comment to reflect the npm install path
(opencode-ponytail) vs the old relative path.

* chore: rename package to opencode-ponytail

Align package name with npm convention for opencode plugins.
Update keywords to include opencode-plugin and opencode tags.
Fix description back to original correct wording (grammatical
regression introduced during editing).

* chore: add npm metadata and publish workflow

Add author, homepage, repository, bugs, main, exports, files, and
publishConfig fields to package.json for npm publishing.

Add .github/workflows/publish.yml to auto-publish to npm on version
tags (v*) with provenance.

* docs: add npm plugin install for opencode-ponytail

---------

Co-authored-by: Emeriko <dietrich.gebert@gmail.com>
This commit is contained in:
Dang Trung An
2026-06-24 02:09:40 +02:00
committed by GitHub
co-authored by Emeriko
parent 2b426c6ac9
commit 17a466013e
5 changed files with 107 additions and 9 deletions
+23
View File
@@ -0,0 +1,23 @@
name: publish
on:
push:
tags: ['v*']
workflow_dispatch:
permissions:
id-token: write
contents: read
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
- run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+25 -4
View File
@@ -1,11 +1,13 @@
// ponytail — OpenCode plugin. // ponytail — OpenCode plugin.
// //
// Injects the ponytail ruleset into every chat's system prompt at the active // Injects the ponytail ruleset into every chat's system prompt at the active
// intensity, and persists /ponytail mode switches. Reuses the shared instruction // intensity, persists /ponytail mode switches, and registers slash commands so
// builder so Claude Code, Codex, pi, and OpenCode all read one source of truth. // 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: // OpenCode loads this as a server plugin — add it to your opencode.json:
// { "plugin": ["./.opencode/plugins/ponytail.mjs"] } // { "plugin": ["opencode-ponytail"] }
import { createRequire } from 'module'; import { createRequire } from 'module';
import fs from 'fs'; import fs from 'fs';
@@ -40,6 +42,15 @@ function writeMode(mode) {
fs.writeFileSync(statePath, mode); fs.writeFileSync(statePath, mode);
} }
export function parseCommandFile(filePath) {
const content = fs.readFileSync(filePath, 'utf8');
// Tolerate CRLF: a Windows checkout (autocrlf) delivers \r\n, npm ships \n.
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
if (!match) return null;
const description = match[1].match(/description:\s*(.+)/)?.[1]?.trim();
return { description, template: match[2].trim() };
}
export default async ({ client } = {}) => { export default async ({ client } = {}) => {
const log = (level, message) => { const log = (level, message) => {
try { client && client.app && client.app.log({ body: { service: 'ponytail', level, message } }); } catch (e) {} try { client && client.app && client.app.log({ body: { service: 'ponytail', level, message } }); } catch (e) {}
@@ -48,8 +59,18 @@ export default async ({ client } = {}) => {
const ponytailSkillsDir = path.resolve(__dirname, '../../skills'); const ponytailSkillsDir = path.resolve(__dirname, '../../skills');
return { return {
// Register skills directory so opencode discovers ponytail skills. // Register slash commands + skills directory.
config: async (config) => { 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 = config.skills || {};
config.skills.paths = config.skills.paths || []; config.skills.paths = config.skills.paths || [];
if (!config.skills.paths.includes(ponytailSkillsDir)) { if (!config.skills.paths.includes(ponytailSkillsDir)) {
+7 -1
View File
@@ -154,7 +154,13 @@ pi install git:github.com/DietrichGebert/ponytail
### OpenCode ### OpenCode
Run OpenCode from a checkout of this repo (the plugin reuses its `hooks/` and `skills/`), and add to `opencode.json`: Add to `opencode.json`:
```json
{ "plugin": ["opencode-ponytail"] }
```
Run from a checkout instead (the plugin reuses `hooks/` and `skills/`):
```json ```json
{ "plugin": ["./.opencode/plugins/ponytail.mjs"] } { "plugin": ["./.opencode/plugins/ponytail.mjs"] }
+31 -2
View File
@@ -1,14 +1,43 @@
{ {
"name": "ponytail", "name": "opencode-ponytail",
"version": "4.8.1", "version": "4.8.1",
"description": "Lazy senior dev mode for AI agents. The best code is the code you never wrote.", "description": "Lazy senior dev mode for AI agents. The best code is the code you never wrote.",
"keywords": ["pi-package", "pi", "skills", "ponytail"], "keywords": ["opencode-plugin", "opencode", "ponytail", "pi-package", "pi", "skills"],
"license": "MIT", "license": "MIT",
"author": {
"name": "Dietrich Gebert",
"url": "https://github.com/DietrichGebert"
},
"homepage": "https://github.com/DietrichGebert/ponytail",
"repository": {
"type": "git",
"url": "git+https://github.com/DietrichGebert/ponytail.git"
},
"bugs": {
"url": "https://github.com/DietrichGebert/ponytail/issues"
},
"main": "./.opencode/plugins/ponytail.mjs",
"exports": {
".": "./.opencode/plugins/ponytail.mjs",
"./plugin": "./.opencode/plugins/ponytail.mjs"
},
"files": [
"AGENTS.md",
"hooks/",
"skills/",
".opencode/",
"pi-extension/",
"assets/",
"LICENSE"
],
"scripts": { "scripts": {
"test": "node --test tests/*.test.js && npm test --prefix pi-extension" "test": "node --test tests/*.test.js && npm test --prefix pi-extension"
}, },
"pi": { "pi": {
"extensions": ["./pi-extension/index.js"], "extensions": ["./pi-extension/index.js"],
"skills": ["./skills"] "skills": ["./skills"]
},
"publishConfig": {
"access": "public"
} }
} }
+21 -2
View File
@@ -18,10 +18,12 @@ process.env.XDG_CONFIG_HOME = tmp;
delete process.env.PONYTAIL_DEFAULT_MODE; delete process.env.PONYTAIL_DEFAULT_MODE;
const statePath = path.join(tmp, 'opencode', '.ponytail-active'); const statePath = path.join(tmp, 'opencode', '.ponytail-active');
let loadPlugin; let loadPlugin, parseCommandFile;
test.before(async () => { test.before(async () => {
const url = pathToFileURL(path.join(__dirname, '..', '.opencode', 'plugins', 'ponytail.mjs')); const url = pathToFileURL(path.join(__dirname, '..', '.opencode', 'plugins', 'ponytail.mjs'));
loadPlugin = (await import(url)).default; const mod = await import(url);
loadPlugin = mod.default;
parseCommandFile = mod.parseCommandFile;
}); });
function transform(hooks) { function transform(hooks) {
@@ -61,4 +63,21 @@ test('unrelated commands do not touch the flag', async () => {
assert.equal(fs.existsSync(statePath), false); assert.equal(fs.existsSync(statePath), false);
}); });
test('parseCommandFile reads frontmatter description + body, LF and CRLF', () => {
const lf = path.join(tmp, 'cmd-lf.md');
fs.writeFileSync(lf, '---\ndescription: do a thing\n---\n\nthe template body\n');
assert.deepEqual(parseCommandFile(lf), { description: 'do a thing', template: 'the template body' });
// Windows checkouts (autocrlf) deliver CRLF — the parser must still match.
const crlf = path.join(tmp, 'cmd-crlf.md');
fs.writeFileSync(crlf, '---\r\ndescription: do a thing\r\n---\r\n\r\nthe template body\r\n');
assert.deepEqual(parseCommandFile(crlf), { description: 'do a thing', template: 'the template body' });
});
test('parseCommandFile returns null when there is no frontmatter', () => {
const bare = path.join(tmp, 'cmd-bare.md');
fs.writeFileSync(bare, 'no frontmatter here\n');
assert.equal(parseCommandFile(bare), null);
});
test.after(() => fs.rmSync(tmp, { recursive: true, force: true })); test.after(() => fs.rmSync(tmp, { recursive: true, force: true }));