Compare commits

..
15 Commits
Author SHA1 Message Date
Emeriko 6205e9111d release: 4.8.2 2026-06-24 03:17:55 +02:00
DietrichGebert 7d303b7175 ci: publish via npm trusted publishing (OIDC), drop NPM_TOKEN (#282)
Authenticate the release workflow through GitHub OIDC instead of a long-lived
npm token: no secret to leak or rotate, provenance attached automatically.
Upgrade npm on the runner since OIDC publishing needs npm >= 11.5.1 (Node 22
ships npm 10).
2026-06-24 02:58:03 +02:00
DietrichGebert e368c48c52 feat: scope npm package as @dietrichgebert/ponytail (#280)
Publish under a scope you own outright instead of opencode-ponytail (which
sits on a third-party npm account). Matches the GitHub handle and repo.
publishConfig.access is already public, which scoped public packages need.
2026-06-24 02:50:42 +02:00
Dang Trung AnandEmeriko 17a466013e 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>
2026-06-24 02:09:40 +02:00
Haoqian 2b426c6ac9 fix: make shared hooks parse in PowerShell (#265) 2026-06-24 01:42:40 +02:00
Frank Denis 268be28051 docs: add instructions for usage with Swival (#264)
This explains how to use Ponytail with Swival.
2026-06-24 01:14:19 +02:00
DietrichGebert c8b12b6384 fix(pi-extension): guard status bar render when ui has no theme (#279)
syncStatus guarded setStatus but used theme.fg unguarded, so a Pi host
exposing setStatus without a theme threw TypeError on session_start (and
agent_start/agent_end/setMode). Require both before rendering.

Add tests for the render path (previously untested) and the theme-absent
degradation. Follow-up to #275 / #84.
2026-06-24 01:10:04 +02:00
Tanmay Garg 947f2ff4de feat(pi-extension): add status bar indicator for ponytail mode (closes #84) (#275)
Add a syncStatus function to Pi extension to display the current
Ponytail mode in the status bar.

The indicator updates upon mode changes and hooks into the agent_start
and agent_end events to display a visual active/inactive state.
This provides the user with clear feedback on the currently active
Ponytail intensity level (lite, full, ultra) without running commands.
2026-06-24 00:51:23 +02:00
Tanmay Garg 08f0daffbb fix(benchmark): scheme validation to ollama-url (closes #166) (#274)
Add urllib.parse.urlparse to benchmark-local.py and validate that the
provided --ollama-url uses either the http or https scheme.

Fix arbitrary URI handling where the script could previously access
local files (file://) or other unsupported protocols. If the scheme
is invalid, parser.error is called to exit cleanly with a clear message.
2026-06-24 00:44:47 +02:00
Tanmay Gargandgoogle-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> 7b21459621 🧪 Add missing test for resolveSessionMode edge case (#268)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-06-24 00:25:17 +02:00
Matthias Linhuber d82c68cba5 Update README with ponytail plugin installation steps (#272)
Update Claude Code install steps. Two separate copy paste steps are required for claude to understand
2026-06-24 00:08:26 +02:00
DietrichGebertandClaude Opus 4.8 6d5d75a4f1 docs: clarify uninstall run-order + statusLine ceiling (#278)
Follow-up to #228 (issue #226):

- README: state that scripts/uninstall.js must run *before* the host
  remove command, since the script is itself a plugin file and gets
  deleted by the removal (or run it from a separate clone).
- uninstall.js: add a ponytail: comment naming the statusLine match
  ceiling — substring match + whole-key delete removes a combined
  (e.g. caveman+ponytail) statusline wholesale; upgrade path noted.
- Add trailing newline to the file.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 23:46:40 +02:00
Isha katiyar ae24cd00bc fix: add uninstall cleanup script for state outside plugin files (#226) (#228) 2026-06-23 23:38:06 +02:00
DietrichGebert 8cff216b14 fix: use --tags (not --tag) for clawhub skill publish (#277) 2026-06-23 23:12:19 +02:00
DietrichGebertandClaude Opus 4.8 88be9caee7 feat: add publish-openclaw-skills.js to push skills to ClawHub (#273)
ClawHub does not sync from GitHub. Each OpenClaw skill is pushed with the
clawhub CLI at its own version, so the published copies can drift from the
repo the same way the plugin manifests did (#260). This adds a one-pass
publisher that pushes every generated .openclaw/skills/ skill at the
package.json version, with --dry-run to preview, and documents it in the
README next to the build step.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 20:05:34 +02:00
20 changed files with 436 additions and 20 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ponytail",
"version": "4.8.1",
"version": "4.8.2",
"description": "Lazy senior dev mode. Forces the simplest, shortest solution that actually works: YAGNI, stdlib first, no unrequested abstractions.",
"author": {
"name": "Dietrich Gebert",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ponytail",
"version": "4.8.1",
"version": "4.8.2",
"description": "Lazy senior dev mode. Forces the simplest, shortest solution that actually works: YAGNI, stdlib first, no unrequested abstractions.",
"author": {
"name": "Dietrich Gebert",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "ponytail",
"description": "Lazy senior dev mode. Forces the simplest, shortest solution that actually works: YAGNI, stdlib first, no unrequested abstractions.",
"version": "4.8.1",
"version": "4.8.2",
"author": {
"name": "Dietrich Gebert",
"url": "https://github.com/DietrichGebert"
+24
View File
@@ -0,0 +1,24 @@
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'
# Trusted publishing (OIDC) needs npm >= 11.5.1; Node 22 ships npm 10.
- run: npm install -g npm@latest
# No token: id-token: write above lets npm authenticate via OIDC, and
# provenance is attached automatically. access set in publishConfig.
- run: npm publish
+25 -4
View File
@@ -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": ["@dietrichgebert/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)) {
+38 -4
View File
@@ -105,8 +105,11 @@ The Claude Code and Codex plugins run two tiny Node.js lifecycle hooks, so `node
```
/plugin marketplace add DietrichGebert/ponytail
```
```
/plugin install ponytail@ponytail
```
(You have to send two separate prompts for the install to work)
The desktop app has no `/plugin` command. Install it from the UI instead: Customize, the + by personal plugins, Create plugin and add marketplace, Add from repository, then enter the repo URL (thanks @NiklasDHahn, #98).
@@ -151,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": ["@dietrichgebert/ponytail"] }
```
Run from a checkout instead (the plugin reuses `hooks/` and `skills/`):
```json
{ "plugin": ["./.opencode/plugins/ponytail.mjs"] }
@@ -186,6 +195,20 @@ It reuses this repo's `gemini-extension.json`. One difference: Antigravity conve
Reads `AGENTS.md` from the project root, zero setup. Copy [`AGENTS.md`](AGENTS.md) to your project, or run `codewhale` from a checkout of this repo. That's it.
### Swival
Stage the collection in your library first, then add the skills you want:
```bash
swival skills add --global https://github.com/DietrichGebert/ponytail # stage into ~/.config/swival/library
swival skills add ponytail # install the collection into this project
swival skills add --global ponytail # or activate it in every project
```
Swival also reads `AGENTS.md` from the project root and `~/.config/swival/AGENTS.md` globally, the instruction-only fallback.
On the command line, use a `$` prefix to explicitly activate a skill. For example: `$ponytail-review`.
### OpenClaw
```bash
@@ -200,7 +223,7 @@ Active every session, with a handful of commands (see [Commands](#commands)). `/
Set the level for every new session with the `PONYTAIL_DEFAULT_MODE` env var (`lite`/`full`/`ultra`/`off`), or a `defaultMode` field in `~/.config/ponytail/config.json` (`%APPDATA%\ponytail\config.json` on Windows). The default is `full`.
Cursor, Windsurf, Cline, GitHub Copilot (editor), Aider, Kiro, Zed, CodeWhale: 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/)).
Cursor, Windsurf, Cline, GitHub Copilot (editor), Aider, Kiro, Zed, CodeWhale, Swival: 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/)).
Kiro: copy `.kiro/steering/ponytail.md` to `~/.kiro/steering/` (global) or `.kiro/steering/` in your project.
@@ -210,6 +233,17 @@ VS Code with the Codex extension reads `AGENTS.md`, which this repo ships, so it
Which files map to which agent: [Agent portability](docs/agent-portability.md).
### Uninstall
| Host | Command |
|------|---------|
| Claude Code | `/plugin remove ponytail` |
| Codex | `codex plugin remove ponytail` |
| Pi agent | `pi uninstall ponytail` |
| Cursor / Windsurf / Cline / 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.
## Commands
| Command | What it does |
@@ -221,7 +255,7 @@ Which files map to which agent: [Agent portability](docs/agent-portability.md).
| `/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, OpenCode, Gemini, pi). 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, OpenCode, Gemini, pi, Swival). 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.
## Development
@@ -232,7 +266,7 @@ node scripts/check-rule-copies.js
npm test
```
The OpenClaw skill package (`.openclaw/skills/`) is generated from `skills/`; rerun `node scripts/build-openclaw-skills.js` after changing a skill, the test suite fails if it is stale.
The OpenClaw skill package (`.openclaw/skills/`) is generated from `skills/`; rerun `node scripts/build-openclaw-skills.js` after changing a skill, the test suite fails if it is stale. To publish the skills to ClawHub, run `clawhub login` once, then `node scripts/publish-openclaw-skills.js` (it publishes all six at the `package.json` version; pass `--dry-run` to preview).
The correctness benchmark spawns Python for email and CSV checks; `python3` is tried before `python`. CSV checks need `pandas` installed locally.
+6
View File
@@ -15,6 +15,7 @@ import json
import re
import time
import urllib.request
import urllib.parse
from pathlib import Path
ROOT = Path(__file__).parent.parent
@@ -149,6 +150,11 @@ def main():
parser.add_argument("--repeat", type=int, default=1, help="Runs per cell; median reported (default: 1)")
parser.add_argument("--ollama-url", default="http://localhost:11434", help="Ollama base URL")
args = parser.parse_args()
parsed_url = urllib.parse.urlparse(args.ollama_url)
if parsed_url.scheme not in ("http", "https"):
parser.error(f"Invalid --ollama-url scheme: '{parsed_url.scheme}'. Only 'http' and 'https' are supported.")
run(args.model, args.repeat, args.ollama_url)
+1
View File
@@ -20,6 +20,7 @@ to load in a given agent.
| GitHub Copilot CLI | `.github/plugin/`, `AGENTS.md`, `.github/copilot-instructions.md`, `~/.copilot/copilot-instructions.md` | Plugin-supported (`copilot plugin marketplace add DietrichGebert/ponytail` + `copilot plugin install ponytail@ponytail`). Fallback instruction mode remains: per-project from `AGENTS.md` or `.github/copilot-instructions.md`, or globally from `~/.copilot/copilot-instructions.md` (instruction-tier, no `/ponytail` levels or hooks). |
| Antigravity | `AGENTS.md` | Reads `AGENTS.md` at the repo root as always-on rules (like `.cursorrules`/`CLAUDE.md`); `.agents/rules/` also works for workspace rules. Instruction-tier. |
| CodeWhale | `AGENTS.md` | Reads `AGENTS.md` from the repo root as project instructions; also reads `CLAUDE.md` and `.claude/instructions.md` as fallbacks. Instruction-tier. |
| Swival | `.swival/skills/`, `AGENTS.md` | `swival skills add https://github.com/DietrichGebert/ponytail` installs the six skills straight into `.swival/skills/`. Add `--global` to stage them in the library (`~/.config/swival/library`) first, then `swival skills add ponytail` (or `--global ponytail`) to activate per-project or everywhere. Also reads `AGENTS.md` from the repo root and `~/.config/swival/AGENTS.md` globally as instruction-tier fallback. |
| VS Code + Codex extension | `AGENTS.md` | The Codex extension reads `AGENTS.md` (repo root, or `~/.codex/AGENTS.md` globally). Instruction-tier; the full Codex plugin row above adds `/ponytail` levels and hooks. |
| Kiro | `.kiro/steering/ponytail.md` | Steering rule; copy globally or into a project. |
| Generic agents | `AGENTS.md` or `skills/*/SKILL.md` | Copy the compact rule file or load the skill files directly. |
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ponytail",
"version": "4.8.1",
"version": "4.8.2",
"description": "Lazy senior dev mode. Forces the simplest, shortest solution that actually works: YAGNI, stdlib first, no unrequested abstractions.",
"contextFileName": "AGENTS.md"
}
+2 -2
View File
@@ -6,7 +6,7 @@
"hooks": [
{
"type": "command",
"command": "command -v node >/dev/null 2>&1 && node \"${CLAUDE_PLUGIN_ROOT}/hooks/ponytail-activate.js\" || exit 0",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/ponytail-activate.js\"; exit 0",
"commandWindows": "if (Get-Command node -ErrorAction SilentlyContinue) { node \"$env:CLAUDE_PLUGIN_ROOT\\hooks\\ponytail-activate.js\" }",
"timeout": 5,
"statusMessage": "Loading ponytail mode..."
@@ -19,7 +19,7 @@
"hooks": [
{
"type": "command",
"command": "command -v node >/dev/null 2>&1 && node \"${CLAUDE_PLUGIN_ROOT}/hooks/ponytail-mode-tracker.js\" || exit 0",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/ponytail-mode-tracker.js\"; exit 0",
"commandWindows": "if (Get-Command node -ErrorAction SilentlyContinue) { node \"$env:CLAUDE_PLUGIN_ROOT\\hooks\\ponytail-mode-tracker.js\" }",
"timeout": 5,
"statusMessage": "Tracking ponytail mode..."
+32 -3
View File
@@ -1,14 +1,43 @@
{
"name": "ponytail",
"version": "4.8.1",
"name": "@dietrichgebert/ponytail",
"version": "4.8.2",
"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"
}
}
+32
View File
@@ -56,6 +56,25 @@ export { writeDefaultMode };
export default function ponytailExtension(pi) {
let currentMode = DEFAULT_MODE;
let configuredDefaultMode = getDefaultMode();
let isActive = false;
let lastCtx = null;
// -- Status bar --
function syncStatus(ctx) {
if (ctx) lastCtx = ctx;
const c = ctx || lastCtx;
if (!c?.ui?.setStatus || !c.ui.theme?.fg) return;
const theme = c.ui.theme;
if (currentMode === "off") {
c.ui.setStatus("ponytail", "");
return;
}
const levelIcons = { lite: "🌿", full: "⚡", ultra: "🔥" };
const icon = levelIcons[currentMode] || "";
const label = currentMode.toUpperCase();
const indicator = isActive ? theme.fg("accent", "●") : theme.fg("dim", "○");
c.ui.setStatus("ponytail", indicator + " 🐴 " + theme.fg("muted", "ponytail: ") + theme.fg("text", icon + " " + label));
}
const setMode = (mode, ctx) => {
const normalized = normalizePersistedMode(mode);
@@ -63,6 +82,7 @@ export default function ponytailExtension(pi) {
currentMode = normalized;
pi.appendEntry("ponytail-mode", { mode: normalized });
syncStatus(ctx);
ctx?.ui?.notify?.(`Ponytail mode set to ${normalized}.`, "info");
};
@@ -148,6 +168,18 @@ export default function ponytailExtension(pi) {
const entries = ctx?.sessionManager?.getBranch?.() || ctx?.sessionManager?.getEntries?.() || [];
configuredDefaultMode = getDefaultMode();
currentMode = resolveSessionMode(entries, configuredDefaultMode);
syncStatus(ctx);
ctx?.ui?.notify?.(`Ponytail loaded: ${currentMode}`, "info");
});
pi.on("agent_start", async (_event, ctx) => {
isActive = true;
syncStatus(ctx);
});
pi.on("agent_end", async (_event, ctx) => {
isActive = false;
syncStatus(ctx);
});
pi.on("before_agent_start", async (event) => {
+30
View File
@@ -135,3 +135,33 @@ test("a request mentioning normal mode stays active", async () => withTempConfig
const result = await events.get("before_agent_start")({ systemPrompt: "BASE" }, ctx);
assert.match(result.systemPrompt, /PONYTAIL MODE ACTIVE/);
}));
test("status bar renders the mode and flips active on agent_start", async () => withTempConfig(async () => {
const { events } = createPiHarness();
const statusWrites = [];
const ctx = createCommandContext({
sessionManager: { getEntries: () => [{ type: "custom", customType: "ponytail-mode", data: { mode: "ultra" } }] },
ui: { notify() {}, setStatus: (key, text) => statusWrites.push({ key, text }), theme: { fg: (_color, text) => text } },
});
await events.get("session_start")({ reason: "resume" }, ctx);
await events.get("agent_start")({}, ctx);
assert.equal(statusWrites.at(-2).key, "ponytail");
assert.match(statusWrites.at(-2).text, /○.*ULTRA/);
assert.match(statusWrites.at(-1).text, /●.*ULTRA/);
}));
test("status bar stays silent when ui lacks a theme", async () => withTempConfig(async () => {
const { events } = createPiHarness();
const calls = [];
const ctx = createCommandContext({
sessionManager: { getEntries: () => [{ type: "custom", customType: "ponytail-mode", data: { mode: "ultra" } }] },
ui: { notify() {}, setStatus: (_key, text) => calls.push(text) }, // setStatus present, theme absent
});
await events.get("session_start")({ reason: "resume" }, ctx);
await events.get("agent_start")({}, ctx);
assert.deepEqual(calls, []);
}));
+7
View File
@@ -31,6 +31,13 @@ test("resolveSessionMode prefers latest persisted session mode", () => {
assert.equal(resolveSessionMode(entries, "full"), "ultra");
});
test("resolveSessionMode returns fallback when entries is not an array", () => {
assert.equal(resolveSessionMode(null, "ultra"), "ultra");
assert.equal(resolveSessionMode(undefined, "lite"), "lite");
assert.equal(resolveSessionMode({}, "full"), "full");
assert.equal(resolveSessionMode("not an array"), "full"); // DEFAULT_MODE fallback
});
test("readDefaultMode and writeDefaultMode use XDG config path", () => {
const tempDir = mkdtempSync(join(tmpdir(), "ponytail-config-"));
const previousXdg = process.env.XDG_CONFIG_HOME;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ponytail-mcp",
"version": "4.8.1",
"version": "4.8.2",
"description": "MCP server that serves Ponytail's lazy-senior-dev instructions as a prompt and a tool.",
"private": true,
"type": "module",
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env node
// Publish the generated OpenClaw skills (.openclaw/skills/) to ClawHub.
//
// ClawHub does not sync from GitHub: each skill is pushed explicitly with the
// clawhub CLI and carries its own version. This publishes every generated skill
// in one pass, versioned from the repo's package.json so ClawHub tracks the repo
// instead of drifting (the same drift that hit the plugin manifests in #260).
//
// Prereqs:
// - `clawhub login` once (registry auth persists)
// - skills must be current: run `node scripts/build-openclaw-skills.js` first
// if you changed a skill (CI fails if the committed copies are stale)
//
// Usage:
// node scripts/publish-openclaw-skills.js # publish all as latest
// node scripts/publish-openclaw-skills.js --dry-run # preview, upload nothing
// (any extra args are passed through to `clawhub skill publish`)
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const root = path.join(__dirname, '..');
const skillsDir = path.join(root, '.openclaw', 'skills');
const version = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version;
// Every generated skill dir with a SKILL.md is publishable. Reading the dir
// (instead of a hardcoded list) covers whatever build-openclaw-skills emits,
// with nothing to keep in sync.
const slugs = fs.readdirSync(skillsDir, { withFileTypes: true })
.filter((e) => e.isDirectory() && fs.existsSync(path.join(skillsDir, e.name, 'SKILL.md')))
.map((e) => e.name)
.sort();
if (slugs.length === 0) {
console.error(`No skills under ${path.relative(root, skillsDir)}; run build-openclaw-skills.js first.`);
process.exit(1);
}
// "ponytail-review" -> "Ponytail Review"
const displayName = (slug) =>
slug.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
// Minimal quoting that satisfies both POSIX sh and cmd.exe: only display names
// (which contain a space) need wrapping; slugs, versions, paths, and flags don't.
const quote = (a) => (/[^\w./-]/.test(a) ? `"${a}"` : a);
const passthrough = process.argv.slice(2);
const extra = passthrough.length ? ` (${passthrough.join(' ')})` : '';
console.log(`Publishing ${slugs.length} skills to ClawHub at version ${version}${extra}:`);
for (const slug of slugs) {
const args = [
'clawhub', 'skill', 'publish', `.openclaw/skills/${slug}`,
'--slug', slug,
'--name', displayName(slug),
'--version', version,
'--tags', 'latest',
...passthrough,
];
const cmdline = args.map(quote).join(' ');
console.log(`\n$ ${cmdline}`);
const res = spawnSync(cmdline, { stdio: 'inherit', cwd: root, shell: true });
if (res.status !== 0) {
console.error(
`\nPublish failed for "${slug}" (exit ${res.status}). ` +
`Check that the clawhub CLI is installed and you have run \`clawhub login\`, then re-run. ` +
`Skills already published in this run are unaffected.`,
);
process.exit(res.status || 1);
}
}
console.log(`\nDone. Published ${slugs.length} skills at ${version}.`);
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env node
// ponytail — removes state ponytail wrote outside the plugin's own files:
// the mode flag, the config file, and the statusLine entry it added to
// settings.json. Plugin files themselves are removed by each host's own
// uninstall command (see README); this only cleans up what those commands
// can't see.
const fs = require('fs');
const path = require('path');
const { getConfigPath, getClaudeDir } = require('../hooks/ponytail-config');
function removeIfExists(filePath, label) {
try {
fs.unlinkSync(filePath);
console.log(`Removed ${label}: ${filePath}`);
} catch (e) {
if (e.code !== 'ENOENT') throw e;
}
}
removeIfExists(path.join(getClaudeDir(), '.ponytail-active'), 'mode flag');
removeIfExists(getConfigPath(), 'config file');
const settingsPath = path.join(getClaudeDir(), 'settings.json');
try {
const raw = fs.readFileSync(settingsPath, 'utf8').replace(/^\uFEFF/, '');
const settings = JSON.parse(raw);
const cmd = settings.statusLine && settings.statusLine.command;
// ponytail: substring-match the script name, then drop the whole statusLine
// key. A combined statusline (e.g. caveman+ponytail) whose command contains
// "ponytail-statusline" gets removed wholesale. Parse out only ponytail's part
// if combined statuslines become common.
if (typeof cmd === 'string' && cmd.includes('ponytail-statusline')) {
delete settings.statusLine;
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
console.log(`Removed ponytail statusLine entry from ${settingsPath}`);
}
} catch (e) {
if (e.code !== 'ENOENT') throw e;
}
+22
View File
@@ -18,6 +18,8 @@ const HOST_PLUGIN_MANIFESTS = [
];
// cmd.exe variable syntax (%FOO%); PowerShell leaves it literal, breaking the path.
const CMD_VAR_SYNTAX = /%[A-Za-z_][A-Za-z0-9_]*%/;
// PowerShell 5.1 rejects these POSIX shell guards when a host runs `command`.
const POSIX_GUARD_SYNTAX = /\bcommand\s+-v\b|&&|\|\||>\/dev\/null|2>&1/;
// Pull the hooks/<script> a command launches, so we can check it exists.
const HOOK_SCRIPT = /hooks[\\/]([\w.-]+\.(?:js|mjs|cjs|ps1|sh))/;
@@ -40,6 +42,26 @@ test('every commandWindows uses PowerShell $env: syntax, not cmd.exe %VAR%', ()
}
});
test('shared hook commands avoid POSIX-only guard syntax', () => {
const commands = commandHooks()
.map((h) => h.command)
.filter(Boolean);
assert.ok(commands.length > 0, 'expected at least one shared command entry');
for (const cmd of commands) {
assert.doesNotMatch(cmd, POSIX_GUARD_SYNTAX, `command uses POSIX-only guard syntax: ${cmd}`);
}
});
test('shared hook commands keep lifecycle hooks non-blocking', () => {
const commands = commandHooks()
.map((h) => h.command)
.filter(Boolean);
assert.ok(commands.length > 0, 'expected at least one shared command entry');
for (const cmd of commands) {
assert.match(cmd, /;\s*exit 0$/, `command must exit successfully if node or the hook script fails: ${cmd}`);
}
});
test('every hook command points at a script that ships in hooks/', () => {
for (const hook of commandHooks()) {
for (const cmd of [hook.command, hook.commandWindows].filter(Boolean)) {
+21 -2
View File
@@ -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 }));
+76
View File
@@ -0,0 +1,76 @@
#!/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 runUninstall(env) {
return spawnSync(process.execPath, [path.join(root, 'scripts', 'uninstall.js')], {
env: { ...process.env, ...env },
encoding: 'utf8',
});
}
delete process.env.CLAUDE_CONFIG_DIR;
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ponytail-uninstall-'));
process.on('exit', () => fs.rmSync(temp, { recursive: true, force: true }));
const home = path.join(temp, 'home');
const claudeDir = path.join(home, '.claude');
fs.mkdirSync(claudeDir, { recursive: true });
const flagPath = path.join(claudeDir, '.ponytail-active');
fs.writeFileSync(flagPath, 'full');
const configDir = path.join(temp, 'config-home', 'ponytail');
fs.mkdirSync(configDir, { recursive: true });
const configPath = path.join(configDir, 'config.json');
fs.writeFileSync(configPath, JSON.stringify({ defaultMode: 'ultra' }));
const settingsPath = path.join(claudeDir, 'settings.json');
fs.writeFileSync(settingsPath, JSON.stringify({
statusLine: { type: 'command', command: 'bash /some/path/ponytail-statusline.sh' },
}));
const env = {
HOME: home,
USERPROFILE: home,
XDG_CONFIG_HOME: path.join(temp, 'config-home'),
};
let result = runUninstall(env);
assert.equal(result.status, 0, result.stderr);
assert.equal(fs.existsSync(flagPath), false, 'mode flag must be removed');
assert.equal(fs.existsSync(configPath), false, 'config file must be removed');
const settingsAfter = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
assert.equal(
settingsAfter.statusLine,
undefined,
'ponytail statusLine entry must be removed',
);
// A user's own, unrelated statusLine must survive untouched.
fs.writeFileSync(settingsPath, JSON.stringify({
statusLine: { type: 'command', command: 'bash ~/my-custom-statusline.sh' },
}));
result = runUninstall(env);
assert.equal(result.status, 0, result.stderr);
const settingsAfter2 = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
assert.equal(
settingsAfter2.statusLine.command,
'bash ~/my-custom-statusline.sh',
"a user's own statusLine must not be touched",
);
// Running on an already-clean machine must not throw.
result = runUninstall({ HOME: path.join(temp, 'home-empty'), USERPROFILE: path.join(temp, 'home-empty') });
assert.equal(result.status, 0, result.stderr);
console.log('uninstall script checks passed');