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:
@@ -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 }}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
+31
-2
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
Reference in New Issue
Block a user