From 17a466013e7956f91418d188a960754ba26a1bdf Mon Sep 17 00:00:00 2001 From: Dang Trung An Date: Wed, 24 Jun 2026 07:09:40 +0700 Subject: [PATCH] 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 --- .github/workflows/publish.yml | 23 +++++++++++++++++++++++ .opencode/plugins/ponytail.mjs | 29 +++++++++++++++++++++++++---- README.md | 8 +++++++- package.json | 33 +++++++++++++++++++++++++++++++-- tests/opencode-plugin.test.js | 23 +++++++++++++++++++++-- 5 files changed, 107 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..e34331d --- /dev/null +++ b/.github/workflows/publish.yml @@ -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 }} diff --git a/.opencode/plugins/ponytail.mjs b/.opencode/plugins/ponytail.mjs index 7d7db16..af22e44 100644 --- a/.opencode/plugins/ponytail.mjs +++ b/.opencode/plugins/ponytail.mjs @@ -1,11 +1,13 @@ // 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. +// 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": ["./.opencode/plugins/ponytail.mjs"] } +// { "plugin": ["opencode-ponytail"] } import { createRequire } from 'module'; import fs from 'fs'; @@ -40,6 +42,15 @@ function writeMode(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 } = {}) => { const log = (level, message) => { 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'); return { - // Register skills directory so opencode discovers ponytail skills. + // 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)) { diff --git a/README.md b/README.md index 99dbee2..0a44a1f 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,13 @@ 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`: +Add to `opencode.json`: + +```json +{ "plugin": ["opencode-ponytail"] } +``` + +Run from a checkout instead (the plugin reuses `hooks/` and `skills/`): ```json { "plugin": ["./.opencode/plugins/ponytail.mjs"] } diff --git a/package.json b/package.json index 314d149..4fe8016 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,43 @@ { - "name": "ponytail", + "name": "opencode-ponytail", "version": "4.8.1", "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", + "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": { "test": "node --test tests/*.test.js && npm test --prefix pi-extension" }, "pi": { "extensions": ["./pi-extension/index.js"], "skills": ["./skills"] + }, + "publishConfig": { + "access": "public" } } diff --git a/tests/opencode-plugin.test.js b/tests/opencode-plugin.test.js index 6d9ceab..a2fad93 100644 --- a/tests/opencode-plugin.test.js +++ b/tests/opencode-plugin.test.js @@ -18,10 +18,12 @@ process.env.XDG_CONFIG_HOME = tmp; delete process.env.PONYTAIL_DEFAULT_MODE; const statePath = path.join(tmp, 'opencode', '.ponytail-active'); -let loadPlugin; +let loadPlugin, parseCommandFile; test.before(async () => { 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) { @@ -61,4 +63,21 @@ test('unrelated commands do not touch the flag', async () => { 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 }));