From c16f967d37866a242940553b1a3a55b262b854c5 Mon Sep 17 00:00:00 2001 From: Paul Date: Fri, 12 Jun 2026 08:06:57 -0400 Subject: [PATCH] feat: codex support --- .agents/plugins/marketplace.json | 21 +++++++++ .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 31 +++++++++++++ README.md | 15 +++++++ hooks/hooks.json | 31 +++++++++++++ hooks/ponytail-activate.js | 24 ++++++---- hooks/ponytail-mode-tracker.js | 24 +++++----- hooks/ponytail-runtime.js | 39 +++++++++++++++++ skills/ponytail-help/SKILL.md | 3 ++ tests/hooks.test.js | 75 ++++++++++++++++++++++++++++++++ 10 files changed, 245 insertions(+), 20 deletions(-) create mode 100644 .agents/plugins/marketplace.json create mode 100644 .codex-plugin/plugin.json create mode 100644 hooks/hooks.json create mode 100644 hooks/ponytail-runtime.js create mode 100644 tests/hooks.test.js diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 0000000..9711363 --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,21 @@ +{ + "name": "ponytail", + "interface": { + "displayName": "Ponytail" + }, + "plugins": [ + { + "name": "ponytail", + "source": { + "source": "url", + "url": "https://github.com/DietrichGebert/ponytail.git", + "ref": "main" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + } + ] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index e36f706..d57c879 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ponytail", - "version": "4.0.0", + "version": "4.1.0", "description": "Lazy senior dev mode. Forces the simplest, shortest solution that actually works: YAGNI, stdlib first, no unrequested abstractions.", "author": { "name": "Dietrich Gebert", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json new file mode 100644 index 0000000..fbdf960 --- /dev/null +++ b/.codex-plugin/plugin.json @@ -0,0 +1,31 @@ +{ + "name": "ponytail", + "version": "4.1.0", + "description": "Lazy senior dev mode. Forces the simplest, shortest solution that actually works: YAGNI, stdlib first, no unrequested abstractions.", + "author": { + "name": "Dietrich Gebert", + "url": "https://github.com/DietrichGebert" + }, + "homepage": "https://github.com/DietrichGebert/ponytail", + "repository": "https://github.com/DietrichGebert/ponytail", + "license": "MIT", + "keywords": ["yagni", "minimalism", "code-review", "productivity"], + "skills": "./skills/", + "interface": { + "displayName": "Ponytail", + "shortDescription": "Lazy senior developer mode", + "longDescription": "Prefer YAGNI, the standard library, native platform features, and the smallest correct implementation.", + "developerName": "Dietrich Gebert", + "category": "Productivity", + "capabilities": ["Instructions", "Lifecycle hooks"], + "websiteURL": "https://github.com/DietrichGebert/ponytail", + "defaultPrompt": [ + "Use Ponytail mode for this task.", + "Review this diff for over-engineering.", + "Find the smallest correct implementation." + ], + "brandColor": "#111111", + "composerIcon": "./assets/logo.png", + "logo": "./assets/logo.png" + } +} diff --git a/README.md b/README.md index 274f1e2..3f27808 100644 --- a/README.md +++ b/README.md @@ -56,15 +56,30 @@ Lazy, not negligent: trust-boundary validation, data-loss handling, security, an The most effort ponytail will ever ask of you: +### Claude Code + ``` /plugin marketplace add DietrichGebert/ponytail /plugin install ponytail@ponytail ``` +### Codex + +```bash +codex plugin marketplace add DietrichGebert/ponytail +codex +``` + +Open `/plugins`, select the Ponytail marketplace, and install Ponytail. Then +open `/hooks`, review and trust its two lifecycle hooks, and start a new thread. + 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. +In Codex, invoke the skills as `@ponytail`, `@ponytail-review`, and +`@ponytail-help`. Startup and mode-change text shows the current mode. + Cursor, Windsurf, Cline, Copilot, Aider: copy the matching rules file from this repo ([`.cursor/rules/`](.cursor/rules/), [`.windsurf/rules/`](.windsurf/rules/), [`.clinerules/`](.clinerules/), [`.github/copilot-instructions.md`](.github/copilot-instructions.md), [`AGENTS.md`](AGENTS.md)). ## FAQ diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 0000000..534f8fa --- /dev/null +++ b/hooks/hooks.json @@ -0,0 +1,31 @@ +{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume|clear|compact", + "hooks": [ + { + "type": "command", + "command": "node \"${PLUGIN_ROOT}/hooks/ponytail-activate.js\"", + "commandWindows": "node \"%PLUGIN_ROOT%\\hooks\\ponytail-activate.js\"", + "timeout": 5, + "statusMessage": "Loading ponytail mode..." + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${PLUGIN_ROOT}/hooks/ponytail-mode-tracker.js\"", + "commandWindows": "node \"%PLUGIN_ROOT%\\hooks\\ponytail-mode-tracker.js\"", + "timeout": 5, + "statusMessage": "Tracking ponytail mode..." + } + ] + } + ] + } +} diff --git a/hooks/ponytail-activate.js b/hooks/ponytail-activate.js index 777b5a3..cca08ae 100644 --- a/hooks/ponytail-activate.js +++ b/hooks/ponytail-activate.js @@ -10,24 +10,28 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); const { getDefaultMode } = require('./ponytail-config'); +const { + clearMode, + isCodex, + setMode, + writeHookOutput, +} = require('./ponytail-runtime'); const claudeDir = path.join(os.homedir(), '.claude'); -const flagPath = path.join(claudeDir, '.ponytail-active'); const settingsPath = path.join(claudeDir, 'settings.json'); const mode = getDefaultMode(); // "off" mode — skip activation entirely, don't write flag or emit rules if (mode === 'off') { - try { fs.unlinkSync(flagPath); } catch (e) {} - process.stdout.write('OK'); + clearMode(); + writeHookOutput('SessionStart', 'off', isCodex ? '' : 'OK'); process.exit(0); } // 1. Write flag file try { - fs.mkdirSync(path.dirname(flagPath), { recursive: true }); - fs.writeFileSync(flagPath, mode); + setMode(mode); } catch (e) { // Silent fail -- flag is best-effort, don't block the hook } @@ -45,7 +49,11 @@ try { const INDEPENDENT_MODES = new Set(['review']); if (INDEPENDENT_MODES.has(mode)) { - process.stdout.write('PONYTAIL MODE ACTIVE — level: ' + mode + '. Behavior defined by /ponytail-' + mode + ' skill.'); + writeHookOutput( + 'SessionStart', + mode, + 'PONYTAIL MODE ACTIVE — level: ' + mode + '. Behavior defined by /ponytail-' + mode + ' skill.', + ); process.exit(0); } @@ -124,7 +132,7 @@ if (skillContent) { } // 3. Detect missing statusline config — nudge Claude to help set it up -try { +if (!isCodex) try { let hasStatusline = false; if (fs.existsSync(settingsPath)) { const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); @@ -153,4 +161,4 @@ try { // Silent fail — don't block session start over statusline detection } -process.stdout.write(output); +writeHookOutput('SessionStart', mode, output); diff --git a/hooks/ponytail-mode-tracker.js b/hooks/ponytail-mode-tracker.js index db37ce2..d4fda46 100644 --- a/hooks/ponytail-mode-tracker.js +++ b/hooks/ponytail-mode-tracker.js @@ -2,12 +2,8 @@ // ponytail — UserPromptSubmit hook to track which ponytail mode is active // Inspects user input for /ponytail commands and writes mode to flag file -const fs = require('fs'); -const path = require('path'); -const os = require('os'); const { getDefaultMode } = require('./ponytail-config'); - -const flagPath = path.join(os.homedir(), '.claude', '.ponytail-active'); +const { clearMode, setMode, writeHookOutput } = require('./ponytail-runtime'); let input = ''; process.stdin.on('data', chunk => { input += chunk; }); @@ -18,9 +14,9 @@ process.stdin.on('end', () => { const prompt = (data.prompt || '').trim().toLowerCase(); // Match /ponytail commands - if (prompt.startsWith('/ponytail')) { + if (/^[/@$]ponytail/.test(prompt)) { const parts = prompt.split(/\s+/); - const cmd = parts[0]; // /ponytail, /ponytail-review, /ponytail:ponytail, etc. + const cmd = parts[0].replace(/^[@$]/, '/'); const arg = parts[1] || ''; let mode = null; @@ -36,16 +32,22 @@ process.stdin.on('end', () => { } if (mode && mode !== 'off') { - fs.mkdirSync(path.dirname(flagPath), { recursive: true }); - fs.writeFileSync(flagPath, mode); + setMode(mode); + writeHookOutput( + 'UserPromptSubmit', + mode, + 'PONYTAIL MODE CHANGED — level: ' + mode, + ); } else if (mode === 'off') { - try { fs.unlinkSync(flagPath); } catch (e) {} + clearMode(); + writeHookOutput('UserPromptSubmit', 'off', 'PONYTAIL MODE OFF'); } } // Detect deactivation if (/\b(stop ponytail|normal mode)\b/i.test(prompt)) { - try { fs.unlinkSync(flagPath); } catch (e) {} + clearMode(); + writeHookOutput('UserPromptSubmit', 'off', 'PONYTAIL MODE OFF'); } } catch (e) { // Silent fail diff --git a/hooks/ponytail-runtime.js b/hooks/ponytail-runtime.js new file mode 100644 index 0000000..a6adf8b --- /dev/null +++ b/hooks/ponytail-runtime.js @@ -0,0 +1,39 @@ +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const isCodex = Boolean(process.env.PLUGIN_DATA); +const statePath = isCodex + ? path.join(process.env.PLUGIN_DATA, '.ponytail-active') + : path.join(os.homedir(), '.claude', '.ponytail-active'); + +function setMode(mode) { + fs.mkdirSync(path.dirname(statePath), { recursive: true }); + fs.writeFileSync(statePath, mode); +} + +function clearMode() { + try { fs.unlinkSync(statePath); } catch (e) {} +} + +function writeHookOutput(event, mode, context = '') { + if (!isCodex) { + process.stdout.write(context); + return; + } + const output = { systemMessage: `PONYTAIL:${mode.toUpperCase()}` }; + if (context) { + output.hookSpecificOutput = { + hookEventName: event, + additionalContext: context, + }; + } + process.stdout.write(JSON.stringify(output)); +} + +module.exports = { + clearMode, + isCodex, + setMode, + writeHookOutput, +}; diff --git a/skills/ponytail-help/SKILL.md b/skills/ponytail-help/SKILL.md index bba4ce4..076db39 100644 --- a/skills/ponytail-help/SKILL.md +++ b/skills/ponytail-help/SKILL.md @@ -29,6 +29,9 @@ Level sticks until changed or session end. | **ponytail-review** | `/ponytail-review` | Over-engineering review: `L42: yagni: factory, one product. Inline.` | | **ponytail-help** | `/ponytail-help` | This card. | +Codex uses `@ponytail`, `@ponytail-review`, and `@ponytail-help`; Claude Code +uses the slash-command forms above. + ## Deactivate Say "stop ponytail" or "normal mode". Resume anytime with `/ponytail`. diff --git a/tests/hooks.test.js b/tests/hooks.test.js new file mode 100644 index 0000000..540190c --- /dev/null +++ b/tests/hooks.test.js @@ -0,0 +1,75 @@ +#!/usr/bin/env node + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const root = path.join(__dirname, '..'); + +function run(script, env, input = '') { + return spawnSync(process.execPath, [path.join(root, 'hooks', script)], { + env: { ...process.env, ...env }, + input, + encoding: 'utf8', + }); +} + +const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ponytail-hooks-')); +const home = path.join(temp, 'home'); +const pluginData = path.join(temp, 'plugin-data'); +fs.mkdirSync(home, { recursive: true }); + +const codexEnv = { + HOME: home, + PLUGIN_DATA: pluginData, + PONYTAIL_DEFAULT_MODE: 'ultra', +}; +const codexState = path.join(pluginData, '.ponytail-active'); + +let result = run('ponytail-activate.js', codexEnv); +assert.equal(result.status, 0, result.stderr); +assert.equal(fs.readFileSync(codexState, 'utf8'), 'ultra'); +let output = JSON.parse(result.stdout); +assert.equal(output.systemMessage, 'PONYTAIL:ULTRA'); +assert.match( + output.hookSpecificOutput.additionalContext, + /PONYTAIL MODE ACTIVE — level: ultra/, +); + +result = run( + 'ponytail-mode-tracker.js', + codexEnv, + JSON.stringify({ prompt: '@ponytail lite' }), +); +assert.equal(result.status, 0, result.stderr); +assert.equal(fs.readFileSync(codexState, 'utf8'), 'lite'); +output = JSON.parse(result.stdout); +assert.equal(output.systemMessage, 'PONYTAIL:LITE'); + +result = run( + 'ponytail-mode-tracker.js', + codexEnv, + JSON.stringify({ prompt: 'normal mode' }), +); +assert.equal(result.status, 0, result.stderr); +assert.equal(fs.existsSync(codexState), false); +output = JSON.parse(result.stdout); +assert.equal(output.systemMessage, 'PONYTAIL:OFF'); + +const claudeEnv = { + HOME: home, + PONYTAIL_DEFAULT_MODE: 'full', +}; +delete claudeEnv.PLUGIN_DATA; + +result = run('ponytail-activate.js', claudeEnv); +assert.equal(result.status, 0, result.stderr); +assert.equal( + fs.readFileSync(path.join(home, '.claude', '.ponytail-active'), 'utf8'), + 'full', +); + +fs.rmSync(temp, { recursive: true, force: true }); +console.log('hook compatibility checks passed');