Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .cursor-plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "ponytail",
"owner": {
"name": "Dietrich Gebert"
},
"metadata": {
"description": "Lazy senior dev mode for AI agents. The best code is the code you never wrote."
},
"plugins": [
{
"name": "ponytail",
"source": ".",
"description": "Forces the laziest solution that works. YAGNI, stdlib first, one line over fifty.",
"category": "developer-tools"
}
]
}
19 changes: 19 additions & 0 deletions .cursor-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"name": "ponytail",
"displayName": "Ponytail",
"version": "4.9.0",
"description": "Lazy senior dev mode. Forces the simplest, shortest solution that actually works: YAGNI, stdlib first, no unrequested abstractions.",
"author": {
"name": "Dietrich Gebert"
},
"homepage": "https://github.com/DietrichGebert/ponytail",
"repository": "https://github.com/DietrichGebert/ponytail",
"license": "MIT",
"logo": "assets/logo.png",
"keywords": ["yagni", "minimalism", "code-review", "productivity"],
"category": "developer-tools",
"rules": "./.cursor/rules/",
"skills": "./skills/",
"commands": "./.cursor/commands/",
"hooks": "./hooks/cursor-hooks.json"
}
6 changes: 6 additions & 0 deletions .cursor/commands/ponytail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
name: ponytail
description: Switch ponytail intensity level (lite/full/ultra/off)
---

Switch to the ponytail level named in this message (lite, full, ultra, or off). If none is named, use full. Lazy senior dev mode, before any code: does it need to exist at all (YAGNI)? Does the standard library do it? A native platform feature? Can it be one line? Build the minimum that works. No unrequested abstractions, no avoidable dependencies, no boilerplate. Mark deliberate simplifications that cut a real corner with a known ceiling using a ponytail: comment that names the ceiling and upgrade path.
21 changes: 18 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,20 @@ Qoder auto-loads `AGENTS.md` from the repo root as always-on context, so running

For full plugin-tier support (automatic mode activation + ruleset injection on every prompt), add the hooks from [`hooks/qoder-hooks.json`](hooks/qoder-hooks.json) to your `.qoder/settings.json`. Replace `PONYTAIL_DIR` with the path to your ponytail checkout. Qoder's `UserPromptSubmit` hook activates the default mode on first prompt and injects the ruleset every turn; `PreToolUse` with `task|Task` matcher injects the ruleset into subagents. Level switches (`/ponytail lite|full|ultra|off`) work automatically.

### Cursor

Symlink a checkout into Cursor's local plugin directory, then restart Cursor:

```bash
ln -s "$PWD" ~/.cursor/plugins/local/ponytail
```

Teams can add this repo as a plugin marketplace instead (it ships [`.cursor-plugin/marketplace.json`](.cursor-plugin/marketplace.json)) and install ponytail from **Customize** in the sidebar.

The plugin loads the always-on rule, the six skills, the `/ponytail` command, and two hooks: `sessionStart` injects the ruleset at the active level, `beforeSubmitPrompt` records `/ponytail lite|full|ultra|off` switches. Cursor's prompt hook can only allow or block a prompt, never inject, so a mid-session level change reaches the model through the `/ponytail` command itself and sticks for the next session.

Without the plugin, copy [`.cursor/rules/ponytail.mdc`](.cursor/rules/ponytail.mdc) into your project's `.cursor/rules/` for the always-on ruleset (no levels, no hooks).

### Antigravity CLI

Google is renaming Gemini CLI to Antigravity CLI (the `agy` binary); the same extension installs there:
Expand Down Expand Up @@ -271,7 +285,7 @@ Set the level for every new session with the `PONYTAIL_DEFAULT_MODE` env var (`l

While active, the ruleset is also injected into every subagent spawned via the Agent tool. To scope that to specific agent types (say, keep it off read-only search agents), set the `PONYTAIL_SUBAGENT_MATCHER` env var to a regex tested against the subagent's `agent_type`. It is unanchored and case-insensitive: `explore|general` matches either, `^general$` is exact, and plugin agent types look like `plugin:name`. Unset means inject into every subagent (the default); an invalid regex, or a subagent whose type the platform doesn't report, also falls back to injecting.

Cursor, Windsurf, Cline, GitHub Copilot Chat (the VS Code, JetBrains, and Visual Studio editor extension, not the standalone Copilot CLI covered under [Install](#install)), Aider, Kiro, Zed, CodeWhale, Swival, Qoder: 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), [`.kiro/steering/`](.kiro/steering/), [`.qoder/rules/`](.qoder/rules/)).
Cursor (instruction-only fallback, the plugin is under [Install](#cursor)), Windsurf, Cline, GitHub Copilot Chat (the VS Code, JetBrains, and Visual Studio editor extension, not the standalone Copilot CLI covered under [Install](#install)), Aider, Kiro, Zed, CodeWhale, Swival, Qoder: 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), [`.kiro/steering/`](.kiro/steering/), [`.qoder/rules/`](.qoder/rules/)).

Kiro: copy `.kiro/steering/ponytail.md` to `~/.kiro/steering/` (global) or `.kiro/steering/` in your project.

Expand All @@ -296,7 +310,8 @@ Which files map to which agent: [Agent portability](docs/agent-portability.md).
| Devin CLI | `devin plugins remove ponytail` |
| Grok Build | `grok plugin uninstall ponytail` |
| Pi agent | `pi uninstall ponytail` |
| Cursor / Windsurf / Cline / Qoder / etc. | Delete the copied rule file |
| Cursor | Uninstall from **Customize**, or `rm ~/.cursor/plugins/local/ponytail` |
| Windsurf / Cline / Qoder / etc. | Delete the copied rule file |

These remove the plugin's own files. They leave behind a small amount of state ponytail writes outside the plugin folder: the mode flag, `~/.config/ponytail/config.json`, and (if you accepted the setup nudge) a `statusLine` entry in `~/.claude/settings.json`. Run `node scripts/uninstall.js` to clean those up too. **Run it before the host remove command above** — the script is itself a plugin file, so removing the plugin first deletes it (or run it from a separate clone of this repo). It only removes the statusLine entry if it points at ponytail's own script, so a statusline you set up yourself is left untouched.

Expand All @@ -311,7 +326,7 @@ These remove the plugin's own files. They leave behind a small amount of state p
| `/ponytail-gain` | Show the measured impact scoreboard (less code, less cost, more speed) from the benchmark. |
| `/ponytail-help` | Quick reference for the commands above. |

Commands need a skill-capable host (Claude Code, Codex, Devin CLI, OpenCode, Gemini, pi, Swival, Hermes Agent, Qoder, Grok Build). In Codex they're skills, invoke with `@` (`@ponytail-review`). The instruction-only adapters (Cursor, Windsurf, Cline, Copilot, Kiro, Antigravity) load the always-on ruleset without the commands.
Commands need a skill-capable host (Claude Code, Codex, Devin CLI, OpenCode, Gemini, pi, Swival, Hermes Agent, Qoder, Grok Build). In Codex they're skills, invoke with `@` (`@ponytail-review`). In Cursor, `/ponytail` is a command and the other five are skills the agent picks up from their description. The instruction-only adapters (Windsurf, Cline, Copilot, Kiro, Antigravity) load the always-on ruleset without the commands.

## Development

Expand Down
2 changes: 1 addition & 1 deletion docs/agent-portability.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ to load in a given agent.
| pi | `pi-extension/`, `skills/`, `hooks/` | Package extension: injects the ruleset each turn through the shared instruction builder and registers the `/ponytail` commands. |
| Hermes Agent | `plugin.yaml`, `__init__.py`, `skills/` | Native Hermes plugin: injects active mode through `pre_llm_call`, rewrites gateway `/ponytail-*` skill commands into agent prompts, registers `/ponytail` mode switching, and exposes bundled skills as `ponytail:<skill>`. |
| Gemini CLI | `gemini-extension.json`, `AGENTS.md`, `commands/`, `skills/` | Extension manifest points `contextFileName` at `AGENTS.md` for always-on rules, and reuses the existing `commands/*.toml` and `skills/`, which Gemini CLI auto-discovers. The Claude/Codex hook map is not placed at Gemini's auto-discovered `hooks/hooks.json` path. |
| Cursor | `.cursor/rules/ponytail.mdc` | Always-on project rule. |
| Cursor | `.cursor-plugin/plugin.json`, `.cursor-plugin/marketplace.json`, `.cursor/rules/ponytail.mdc`, `.cursor/commands/ponytail.md`, `hooks/cursor-hooks.json`, `skills/` | Plugin-tier: the manifest points at the existing always-on rule, the six skills, the `/ponytail` command, and two hooks. `sessionStart` injects the ruleset at the active level; `beforeSubmitPrompt` persists `/ponytail lite\|full\|ultra\|off` switches. Cursor's `beforeSubmitPrompt` can only allow/block, so the mid-session ruleset for a new level comes from the `/ponytail` command (the reason that one command file exists — the other five behaviors are skills). Instruction-tier still works with just the rule file. |
| Windsurf | `.windsurf/rules/ponytail.md` | Project rule. |
| Cline | `.clinerules/ponytail.md` | Project rule. |
| GitHub Copilot | `.github/copilot-instructions.md` | Repository instruction file. |
Expand Down
15 changes: 15 additions & 0 deletions hooks/cursor-hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"version": 1,
"hooks": {
"sessionStart": [
{
"command": "node \"${CURSOR_PLUGIN_ROOT}/hooks/ponytail-activate.js\""
}
],
"beforeSubmitPrompt": [
{
"command": "node \"${CURSOR_PLUGIN_ROOT}/hooks/ponytail-mode-tracker.js\""
}
]
}
}
6 changes: 4 additions & 2 deletions hooks/ponytail-activate.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const {
clearMode,
isCodex,
isCopilot,
isCursor,
setMode,
writeHookOutput,
} = require('./ponytail-runtime');
Expand All @@ -26,7 +27,7 @@ const mode = getDefaultMode();
// "off" mode — skip activation entirely, don't write flag or emit rules
if (mode === 'off') {
clearMode();
const hookOutput = (isCodex || isCopilot) ? '' : 'OK';
const hookOutput = (isCodex || isCopilot || isCursor) ? '' : 'OK';
writeHookOutput('SessionStart', 'off', hookOutput);
process.exit(0);
}
Expand All @@ -42,7 +43,8 @@ try {
let output = getPonytailInstructions(mode);

// 3. Detect missing statusline config — nudge Claude to help set it up
if (!isCodex && !isCopilot) try {
// (Cursor has no statusline to configure, so it skips this too.)
if (!isCodex && !isCopilot && !isCursor) try {
let hasStatusline = false;
if (fs.existsSync(settingsPath)) {
// Strip UTF-8 BOM some editors prepend on Windows (breaks JSON.parse)
Expand Down
22 changes: 22 additions & 0 deletions hooks/ponytail-runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,20 @@ const isCopilot = Boolean(process.env.COPILOT_PLUGIN_DATA) ||
isVsCodeCopilotRoot(process.env.CLAUDE_PLUGIN_ROOT);
const isCodex = !isCopilot && Boolean(process.env.PLUGIN_DATA);
const isQoder = !isCopilot && !isCodex && Boolean(process.env.QODER_SESSION_ID);
// Cursor sets CURSOR_PLUGIN_ROOT for plugin-bundled hooks and CURSOR_VERSION for
// every hook, so hand-wired .cursor/hooks.json installs are detected too. Cursor
// also exports CLAUDE_PROJECT_DIR as an alias, which is why detection can't key
// off that.
const isCursor = !isCopilot && !isCodex && !isQoder &&
Boolean(process.env.CURSOR_PLUGIN_ROOT || process.env.CURSOR_VERSION);

let stateDir = getClaudeDir();
if (isCodex) stateDir = process.env.PLUGIN_DATA;
// COPILOT_PLUGIN_DATA is unset under VS Code Copilot, so fall back to
// getClaudeDir() rather than building a path from undefined.
if (isCopilot) stateDir = process.env.COPILOT_PLUGIN_DATA || getClaudeDir();
if (isQoder) stateDir = path.join(os.homedir(), '.qoder');
if (isCursor) stateDir = path.join(os.homedir(), '.cursor');

const statePath = path.join(stateDir, STATE_FILE);

Expand Down Expand Up @@ -79,6 +86,20 @@ function writeHookOutput(event, mode, context = '') {
process.stdout.write(JSON.stringify(output));
return;
}
if (isCursor) {
// Cursor parses hook stdout as JSON. sessionStart is its only documented
// injection point (additional_context); beforeSubmitPrompt can allow/block
// only, so a mid-session switch persists the flag and confirms to the user
// — the new level's ruleset lands via the /ponytail command or next session.
if (event === 'SessionStart') {
process.stdout.write(JSON.stringify(context ? { additional_context: context } : {}));
return;
}
const output = { continue: true };
if (context) output.user_message = context;
process.stdout.write(JSON.stringify(output));
return;
}
// Native Claude: SessionStart accepts raw stdout, but SubagentStart needs the
// hookSpecificOutput JSON form or the context is dropped.
if (event === 'SubagentStart') {
Expand All @@ -93,6 +114,7 @@ module.exports = {
clearMode,
isCodex,
isCopilot,
isCursor,
isQoder,
readMode,
setMode,
Expand Down
1 change: 1 addition & 0 deletions scripts/check-versions.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const PINNED_SEMVER = /^\d+\.\d+\.\d+$/;
const VERSION_FILES = [
'.claude-plugin/plugin.json', // Claude Code plugin — what users install
'.codex-plugin/plugin.json', // Codex plugin
'.cursor-plugin/plugin.json', // Cursor plugin
'.devin-plugin/plugin.json', // Devin CLI plugin
'.github/plugin/plugin.json', // Copilot plugin
'.qoder-plugin/plugin.json', // Qoder plugin
Expand Down
111 changes: 111 additions & 0 deletions tests/cursor-plugin.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
#!/usr/bin/env node
// Smoke test for the Cursor plugin adapter: manifest, hooks, rules, command and
// skills wiring, plus the Cursor-specific hook output shape (Cursor parses hook
// stdout as JSON and only sessionStart accepts injected context).

const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');

const root = path.join(__dirname, '..');
const SKILL_DIRS = [
'ponytail',
'ponytail-review',
'ponytail-audit',
'ponytail-debt',
'ponytail-gain',
'ponytail-help',
];

function readJSON(relPath) {
return JSON.parse(fs.readFileSync(path.join(root, relPath), 'utf8'));
}

test('cursor plugin manifest points at files that ship', () => {
const manifest = readJSON('.cursor-plugin/plugin.json');
assert.equal(manifest.name, 'ponytail');
assert.ok(manifest.version, 'manifest must declare a version');
assert.ok(manifest.description, 'manifest must declare a description');
assert.equal(manifest.license, 'MIT');
assert.equal(manifest.rules, './.cursor/rules/');
assert.equal(manifest.skills, './skills/');
assert.equal(manifest.commands, './.cursor/commands/');
assert.equal(manifest.hooks, './hooks/cursor-hooks.json');

for (const rel of [manifest.rules, manifest.skills, manifest.commands, manifest.hooks, manifest.logo]) {
assert.ok(fs.existsSync(path.join(root, rel)), `manifest points at missing path: ${rel}`);
}
for (const skill of SKILL_DIRS) {
assert.ok(
fs.existsSync(path.join(root, 'skills', skill, 'SKILL.md')),
`missing skill: skills/${skill}/SKILL.md`,
);
}
});

test('cursor hooks use camelCase events and resolve via CURSOR_PLUGIN_ROOT', () => {
const config = readJSON('hooks/cursor-hooks.json');
assert.equal(config.version, 1);
assert.match(config.hooks.sessionStart[0].command, /ponytail-activate\.js/);
assert.match(config.hooks.beforeSubmitPrompt[0].command, /ponytail-mode-tracker\.js/);
for (const event of Object.keys(config.hooks)) {
assert.match(
config.hooks[event][0].command,
/\$\{CURSOR_PLUGIN_ROOT\}/,
`${event} command must resolve through CURSOR_PLUGIN_ROOT`,
);
}
});

test('cursor rule and /ponytail command exist', () => {
const rule = fs.readFileSync(path.join(root, '.cursor', 'rules', 'ponytail.mdc'), 'utf8');
assert.match(rule, /alwaysApply:\s*true/, 'the rule must be always-on');
assert.match(rule, /lazy senior developer/);
const command = fs.readFileSync(path.join(root, '.cursor', 'commands', 'ponytail.md'), 'utf8');
assert.match(command, /^---\n[\s\S]*description:/, 'command needs frontmatter with a description');
assert.match(command, /lite, full, ultra, or off/);
});

test('cursor hook output: sessionStart injects, beforeSubmitPrompt only confirms', () => {
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ponytail-cursor-'));
const state = path.join(temp, '.cursor', '.ponytail-active');
const env = {
...process.env,
HOME: temp,
USERPROFILE: temp,
CURSOR_PLUGIN_ROOT: root,
PONYTAIL_DEFAULT_MODE: 'full',
};
delete env.PLUGIN_DATA;
delete env.COPILOT_PLUGIN_DATA;
delete env.QODER_SESSION_ID;

const run = (script, input = '') => spawnSync(
process.execPath,
[path.join(root, 'hooks', script)],
{ env, input, encoding: 'utf8' },
);

let result = run('ponytail-activate.js');
assert.equal(result.status, 0, result.stderr);
assert.equal(fs.readFileSync(state, 'utf8'), 'full');
let output = JSON.parse(result.stdout);
assert.match(output.additional_context, /PONYTAIL MODE ACTIVE — level: full/);
assert.ok(!/STATUSLINE SETUP NEEDED/.test(output.additional_context), 'Cursor has no statusline to nudge about');

result = run('ponytail-mode-tracker.js', JSON.stringify({ prompt: '/ponytail ultra' }));
assert.equal(result.status, 0, result.stderr);
assert.equal(fs.readFileSync(state, 'utf8'), 'ultra', 'switch must persist to ~/.cursor');
output = JSON.parse(result.stdout);
assert.equal(output.continue, true, 'beforeSubmitPrompt must never block the prompt');
assert.match(output.user_message, /PONYTAIL MODE CHANGED — level: ultra/);

result = run('ponytail-mode-tracker.js', JSON.stringify({ prompt: 'stop ponytail' }));
assert.equal(result.status, 0, result.stderr);
assert.equal(fs.existsSync(state), false, 'flag must be cleared after stop ponytail');

fs.rmSync(temp, { recursive: true, force: true });
});