chill-agent 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/rules/ponytail.md +30 -0
- package/.claude-plugin/marketplace.json +17 -0
- package/.clinerules/ponytail.md +30 -0
- package/.cursor/rules/ponytail.mdc +36 -0
- package/.grok-plugin/marketplace.json +16 -0
- package/.kiro/steering/ponytail.md +35 -0
- package/.openclaw/skills/ponytail/SKILL.md +52 -0
- package/.openclaw/skills/ponytail-audit/SKILL.md +103 -0
- package/.openclaw/skills/ponytail-debt/SKILL.md +44 -0
- package/.openclaw/skills/ponytail-gain/SKILL.md +52 -0
- package/.openclaw/skills/ponytail-help/SKILL.md +76 -0
- package/.openclaw/skills/ponytail-review/SKILL.md +101 -0
- package/.opencode/command/ponytail-audit.md +5 -0
- package/.opencode/command/ponytail-debt.md +5 -0
- package/.opencode/command/ponytail-gain.md +5 -0
- package/.opencode/command/ponytail-help.md +5 -0
- package/.opencode/command/ponytail-review.md +5 -0
- package/.opencode/command/ponytail.md +5 -0
- package/.opencode/plugins/index.js +4 -0
- package/.opencode/plugins/ponytail-frontmatter.cjs +63 -0
- package/.opencode/plugins/ponytail.mjs +206 -0
- package/.windsurf/rules/ponytail.md +30 -0
- package/AGENTS.md +19 -0
- package/__init__.py +228 -0
- package/commands/ponytail-audit.toml +2 -0
- package/commands/ponytail-debt.toml +2 -0
- package/commands/ponytail-gain.toml +2 -0
- package/commands/ponytail-help.toml +2 -0
- package/commands/ponytail-review.toml +2 -0
- package/commands/ponytail.toml +2 -0
- package/hooks/chill-statusline.ps1 +1 -1
- package/hooks/ponytail-activate.js +201 -0
- package/hooks/ponytail-instructions.js +98 -0
- package/hooks/ponytail-mode-tracker.js +216 -0
- package/hooks/ponytail-runtime.js +182 -0
- package/hooks/ponytail-statusline.ps1 +24 -0
- package/hooks/ponytail-statusline.sh +18 -0
- package/package.json +9 -3
- package/pi-extension/index.js +254 -0
- package/pi-extension/package.json +8 -0
- package/scripts/check-rule-copies.js +74 -0
- package/scripts/check-versions.js +77 -0
- package/scripts/cursor-hooks.js +145 -0
- package/scripts/generate-chillslop.mjs +42 -0
- package/scripts/generate-examples.mjs +149 -0
- package/scripts/generate-plugin-doors.mjs +108 -0
- package/scripts/sync-ponytail.mjs +65 -0
- package/scripts/uninstall.js +92 -0
- package/skills/ponytail/SKILL.md +52 -0
- package/skills/ponytail-audit/SKILL.md +103 -0
- package/skills/ponytail-debt/SKILL.md +44 -0
- package/skills/ponytail-gain/SKILL.md +52 -0
- package/skills/ponytail-help/SKILL.md +76 -0
- package/skills/ponytail-review/SKILL.md +101 -0
- package/tests/commands.test.js +94 -0
- package/tests/copilot-plugin.test.js +35 -0
- package/tests/correctness.test.js +245 -0
- package/tests/cursor-hooks.test.js +343 -0
- package/tests/gemini-extension.test.js +91 -0
- package/tests/grok-plugin.test.js +39 -0
- package/tests/hermes-plugin.test.js +403 -0
- package/tests/hooks.test.js +1005 -0
- package/tests/kimi-plugin.test.js +25 -0
- package/tests/loc.test.js +18 -0
- package/tests/openclaw-skills.test.js +26 -0
- package/tests/opencode-plugin.test.js +340 -0
- package/tests/qoder-plugin.test.js +84 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Unit test for the correctness benchmark assertion. Feeds known-good and
|
|
3
|
+
// known-bad LLM outputs through each task checker and asserts the expected
|
|
4
|
+
// pass/fail verdict. Runs without promptfoo — just node:test + the module.
|
|
5
|
+
|
|
6
|
+
const test = require('node:test');
|
|
7
|
+
const assert = require('node:assert/strict');
|
|
8
|
+
const correctness = require('../benchmarks/correctness');
|
|
9
|
+
|
|
10
|
+
// Helper: wrap code in a fenced block and call the assertion with task vars.
|
|
11
|
+
function check(task, lang, code) {
|
|
12
|
+
const output = '```' + lang + '\n' + code + '\n```';
|
|
13
|
+
return correctness(output, { vars: { task } });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// --- Email validator ---
|
|
17
|
+
|
|
18
|
+
test('email: correct one-liner passes', () => {
|
|
19
|
+
const result = check(
|
|
20
|
+
'Write me a Python function that validates email addresses.',
|
|
21
|
+
'python',
|
|
22
|
+
'def validate_email(email):\n return "@" in email and "." in email.split("@")[-1] and email.split("@")[0] != ""',
|
|
23
|
+
);
|
|
24
|
+
assert.equal(result.pass, true);
|
|
25
|
+
assert.equal(result.score, 1);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('email: always-true validator fails', () => {
|
|
29
|
+
const result = check(
|
|
30
|
+
'Write me a Python function that validates email addresses.',
|
|
31
|
+
'python',
|
|
32
|
+
'def validate_email(email):\n return True',
|
|
33
|
+
);
|
|
34
|
+
assert.equal(result.pass, false);
|
|
35
|
+
assert.equal(result.score, 0);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('email: no code block fails', () => {
|
|
39
|
+
const result = correctness('Here is my answer: just use regex.', {
|
|
40
|
+
vars: { task: 'Write me a Python function that validates email addresses.' },
|
|
41
|
+
});
|
|
42
|
+
assert.equal(result.pass, false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('email: bare unfenced code is still scored (#65)', () => {
|
|
46
|
+
const result = correctness('import re\ndef validate_email(e):\n return bool(re.match(r"^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$", e))', {
|
|
47
|
+
vars: { task: 'Write me a Python function that validates email addresses.' },
|
|
48
|
+
});
|
|
49
|
+
assert.equal(result.pass, true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// --- Debounce ---
|
|
53
|
+
|
|
54
|
+
test('debounce: correct implementation passes', () => {
|
|
55
|
+
const result = check(
|
|
56
|
+
'Add debounce to a search input in vanilla JavaScript.',
|
|
57
|
+
'javascript',
|
|
58
|
+
`function debounce(fn, delay) {
|
|
59
|
+
let timer;
|
|
60
|
+
return function(...args) {
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
timer = setTimeout(() => fn.apply(this, args), delay);
|
|
63
|
+
};
|
|
64
|
+
}`,
|
|
65
|
+
);
|
|
66
|
+
assert.equal(result.pass, true);
|
|
67
|
+
assert.equal(result.score, 1);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('debounce: immediate-call implementation fails', () => {
|
|
71
|
+
const result = check(
|
|
72
|
+
'Add debounce to a search input in vanilla JavaScript.',
|
|
73
|
+
'javascript',
|
|
74
|
+
`function debounce(fn, delay) {
|
|
75
|
+
return function(...args) { fn.apply(this, args); };
|
|
76
|
+
}`,
|
|
77
|
+
);
|
|
78
|
+
assert.equal(result.pass, false);
|
|
79
|
+
assert.equal(result.score, 0);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('debounce: bare unfenced arrow function is still scored (#65)', () => {
|
|
83
|
+
const result = correctness('const debounce = (fn, delay) => {\n let t;\n return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), delay); };\n};', {
|
|
84
|
+
vars: { task: 'Write a reusable debounce function in vanilla JavaScript: debounce(fn, delay).' },
|
|
85
|
+
});
|
|
86
|
+
assert.equal(result.pass, true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// --- CSV sum ---
|
|
90
|
+
|
|
91
|
+
test('csv: correct pandas one-liner passes', () => {
|
|
92
|
+
const result = check(
|
|
93
|
+
"Write Python code that reads sales.csv and sums the 'amount' column.",
|
|
94
|
+
'python',
|
|
95
|
+
`import pandas as pd
|
|
96
|
+
df = pd.read_csv('sales.csv')
|
|
97
|
+
print(df['amount'].sum())`,
|
|
98
|
+
);
|
|
99
|
+
assert.equal(result.pass, true);
|
|
100
|
+
assert.equal(result.score, 1);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('csv: an exception in the code is reported, not swallowed (#919)', () => {
|
|
104
|
+
const result = check(
|
|
105
|
+
"Write Python code that reads sales.csv and sums the 'amount' column.",
|
|
106
|
+
'python',
|
|
107
|
+
`raise RuntimeError("boom")`,
|
|
108
|
+
);
|
|
109
|
+
assert.equal(result.pass, false);
|
|
110
|
+
assert.match(result.reason, /boom/);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('csv: code that prints wrong value fails', () => {
|
|
114
|
+
const result = check(
|
|
115
|
+
"Write Python code that reads sales.csv and sums the 'amount' column.",
|
|
116
|
+
'python',
|
|
117
|
+
`print(999)`,
|
|
118
|
+
);
|
|
119
|
+
assert.equal(result.pass, false);
|
|
120
|
+
assert.equal(result.score, 0);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('csv: value containing 351 as substring fails (e.g. 13510)', () => {
|
|
124
|
+
const result = check(
|
|
125
|
+
"Write Python code that reads sales.csv and sums the 'amount' column.",
|
|
126
|
+
'python',
|
|
127
|
+
`print(13510)`,
|
|
128
|
+
);
|
|
129
|
+
assert.equal(result.pass, false);
|
|
130
|
+
assert.equal(result.score, 0);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('csv: timeout can be raised for slow pandas startup', () => {
|
|
134
|
+
const previous = process.env.PONYTAIL_CORRECTNESS_TIMEOUT_MS;
|
|
135
|
+
try {
|
|
136
|
+
process.env.PONYTAIL_CORRECTNESS_TIMEOUT_MS = '1';
|
|
137
|
+
const timedOut = check(
|
|
138
|
+
"Write Python code that reads sales.csv and sums the 'amount' column.",
|
|
139
|
+
'python',
|
|
140
|
+
`import time
|
|
141
|
+
time.sleep(0.05)
|
|
142
|
+
print(351)`,
|
|
143
|
+
);
|
|
144
|
+
assert.equal(timedOut.pass, false);
|
|
145
|
+
assert.match(timedOut.reason, /ETIMEDOUT|timed out/i);
|
|
146
|
+
|
|
147
|
+
process.env.PONYTAIL_CORRECTNESS_TIMEOUT_MS = '1000';
|
|
148
|
+
const completed = check(
|
|
149
|
+
"Write Python code that reads sales.csv and sums the 'amount' column.",
|
|
150
|
+
'python',
|
|
151
|
+
`import time
|
|
152
|
+
time.sleep(0.05)
|
|
153
|
+
print(351)`,
|
|
154
|
+
);
|
|
155
|
+
assert.equal(completed.pass, true);
|
|
156
|
+
assert.equal(completed.score, 1);
|
|
157
|
+
} finally {
|
|
158
|
+
if (previous === undefined) delete process.env.PONYTAIL_CORRECTNESS_TIMEOUT_MS;
|
|
159
|
+
else process.env.PONYTAIL_CORRECTNESS_TIMEOUT_MS = previous;
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// --- React countdown ---
|
|
164
|
+
|
|
165
|
+
test('countdown: valid React component passes', () => {
|
|
166
|
+
const result = check(
|
|
167
|
+
'Build me a countdown timer component in React.',
|
|
168
|
+
'javascript',
|
|
169
|
+
`import { useState, useEffect } from 'react';
|
|
170
|
+
export default function Countdown({ seconds }) {
|
|
171
|
+
const [count, setCount] = useState(seconds);
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
if (count <= 0) return;
|
|
174
|
+
const id = setInterval(() => setCount(prev => prev - 1), 1000);
|
|
175
|
+
return () => clearInterval(id);
|
|
176
|
+
}, [count]);
|
|
177
|
+
return <div>{count}</div>;
|
|
178
|
+
}`,
|
|
179
|
+
);
|
|
180
|
+
assert.equal(result.pass, true);
|
|
181
|
+
assert.equal(result.score, 1);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('countdown: static div without state fails', () => {
|
|
185
|
+
const result = check(
|
|
186
|
+
'Build me a countdown timer component in React.',
|
|
187
|
+
'javascript',
|
|
188
|
+
`export default function Countdown() { return <div>10</div>; }`,
|
|
189
|
+
);
|
|
190
|
+
assert.equal(result.pass, false);
|
|
191
|
+
assert.equal(result.score, 0);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// --- Rate limiter ---
|
|
195
|
+
|
|
196
|
+
test('ratelimit: FastAPI with limit logic passes', () => {
|
|
197
|
+
const result = check(
|
|
198
|
+
'Add rate limiting to my FastAPI endpoint so users can\'t spam it.',
|
|
199
|
+
'python',
|
|
200
|
+
`from fastapi import FastAPI, HTTPException
|
|
201
|
+
import time
|
|
202
|
+
|
|
203
|
+
app = FastAPI()
|
|
204
|
+
requests = {}
|
|
205
|
+
|
|
206
|
+
@app.get("/api")
|
|
207
|
+
def endpoint(user: str = "anon"):
|
|
208
|
+
now = time.time()
|
|
209
|
+
window = requests.get(user, [])
|
|
210
|
+
window = [t for t in window if now - t < 60]
|
|
211
|
+
if len(window) >= 10:
|
|
212
|
+
raise HTTPException(429, "Too Many Requests")
|
|
213
|
+
window.append(now)
|
|
214
|
+
requests[user] = window
|
|
215
|
+
return {"ok": True}`,
|
|
216
|
+
);
|
|
217
|
+
assert.equal(result.pass, true);
|
|
218
|
+
assert.equal(result.score, 1);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('ratelimit: plain endpoint without limiting fails', () => {
|
|
222
|
+
const result = check(
|
|
223
|
+
'Add rate limiting to my FastAPI endpoint.',
|
|
224
|
+
'python',
|
|
225
|
+
`from fastapi import FastAPI
|
|
226
|
+
app = FastAPI()
|
|
227
|
+
|
|
228
|
+
@app.get("/api")
|
|
229
|
+
def endpoint():
|
|
230
|
+
return {"ok": True}`,
|
|
231
|
+
);
|
|
232
|
+
assert.equal(result.pass, false);
|
|
233
|
+
assert.equal(result.score, 0);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// --- Edge cases ---
|
|
237
|
+
|
|
238
|
+
test('unknown task is gracefully skipped', () => {
|
|
239
|
+
const result = correctness('```python\nprint("hi")\n```', {
|
|
240
|
+
vars: { task: 'Explain quantum computing.' },
|
|
241
|
+
});
|
|
242
|
+
assert.equal(result.pass, true);
|
|
243
|
+
assert.equal(result.score, 1);
|
|
244
|
+
assert.match(result.reason, /unknown task/i);
|
|
245
|
+
});
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Cursor adapter compatibility check (#817). The hooks must speak Cursor's
|
|
3
|
+
// hooks.json contract: camelCase events, JSON on stdout (empty stdout is a
|
|
4
|
+
// no-op, raw text is a parse error), `additional_context` on sessionStart, and
|
|
5
|
+
// `continue` plus `additional_context` on beforeSubmitPrompt. The installer must
|
|
6
|
+
// merge into an existing hooks.json without touching unrelated hooks.
|
|
7
|
+
//
|
|
8
|
+
// Input shapes mirror docs/cursor-hooks.md (Cursor docs and the 3.20.17
|
|
9
|
+
// client). This checks the adapter's input/output behavior only; whether the
|
|
10
|
+
// model actually receives the context is the live session check in that doc.
|
|
11
|
+
|
|
12
|
+
const test = require('node:test');
|
|
13
|
+
const assert = require('node:assert/strict');
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const os = require('os');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const { spawnSync } = require('child_process');
|
|
18
|
+
|
|
19
|
+
const root = path.join(__dirname, '..');
|
|
20
|
+
const rootFwd = root.replace(/\\/g, '/');
|
|
21
|
+
const TEMPLATE = 'hooks/cursor-hooks.json';
|
|
22
|
+
const { isPonytailHook } = require('../scripts/cursor-hooks');
|
|
23
|
+
|
|
24
|
+
// Keep host detection and config resolution deterministic whatever shell the
|
|
25
|
+
// suite runs in (a Cursor hook env, a Codex shell, a machine-wide default mode).
|
|
26
|
+
for (const key of [
|
|
27
|
+
'CURSOR_VERSION', 'CURSOR_PROJECT_DIR', 'CLAUDE_PROJECT_DIR', 'CLAUDE_PLUGIN_ROOT',
|
|
28
|
+
'CLAUDE_CONFIG_DIR', 'PLUGIN_DATA', 'COPILOT_PLUGIN_DATA', 'QODER_SESSION_ID',
|
|
29
|
+
'PONYTAIL_SUBAGENT_MATCHER', 'PONYTAIL_DEFAULT_MODE', 'XDG_CONFIG_HOME',
|
|
30
|
+
]) {
|
|
31
|
+
delete process.env[key];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ponytail-cursor-'));
|
|
35
|
+
process.on('exit', () => fs.rmSync(temp, { recursive: true, force: true }));
|
|
36
|
+
|
|
37
|
+
function run(script, env, input = '', cwd = undefined) {
|
|
38
|
+
return spawnSync(process.execPath, [path.join(root, 'hooks', script)], {
|
|
39
|
+
env: { ...process.env, ...env },
|
|
40
|
+
input,
|
|
41
|
+
cwd,
|
|
42
|
+
encoding: 'utf8',
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function cli(args, env, cwd = undefined) {
|
|
47
|
+
return spawnSync(process.execPath, [path.join(root, 'scripts', 'cursor-hooks.js'), ...args], {
|
|
48
|
+
env: { ...process.env, ...env },
|
|
49
|
+
cwd,
|
|
50
|
+
encoding: 'utf8',
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parse(result) {
|
|
55
|
+
assert.equal(result.status, 0, result.stderr);
|
|
56
|
+
assert.ok(result.stdout.startsWith('{'), `expected JSON on stdout, got: ${result.stdout.slice(0, 80)}`);
|
|
57
|
+
return JSON.parse(result.stdout);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// A fresh HOME and workspace per case so flag state cannot leak between cases.
|
|
61
|
+
// The env is what Cursor 3.20.17 gives a hook process: CURSOR_VERSION and the
|
|
62
|
+
// workspace root in CURSOR_PROJECT_DIR (plus its CLAUDE_PROJECT_DIR alias).
|
|
63
|
+
function cursorEnv(name, extra = {}) {
|
|
64
|
+
const home = path.join(temp, name, 'home');
|
|
65
|
+
const project = path.join(temp, name, 'project');
|
|
66
|
+
fs.mkdirSync(home, { recursive: true });
|
|
67
|
+
fs.mkdirSync(project, { recursive: true });
|
|
68
|
+
return {
|
|
69
|
+
home,
|
|
70
|
+
project,
|
|
71
|
+
flag: path.join(home, '.cursor', '.ponytail-active'),
|
|
72
|
+
env: {
|
|
73
|
+
HOME: home,
|
|
74
|
+
USERPROFILE: home,
|
|
75
|
+
XDG_CONFIG_HOME: path.join(home, '.config'),
|
|
76
|
+
CURSOR_VERSION: '3.20.17',
|
|
77
|
+
CURSOR_PROJECT_DIR: project,
|
|
78
|
+
CLAUDE_PROJECT_DIR: project,
|
|
79
|
+
...extra,
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function writeFlag(c, mode) {
|
|
85
|
+
fs.mkdirSync(path.dirname(c.flag), { recursive: true });
|
|
86
|
+
fs.writeFileSync(c.flag, mode);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
test('cursor hooks template is a valid hooks.json with the two events that can inject context', () => {
|
|
90
|
+
const config = JSON.parse(fs.readFileSync(path.join(root, TEMPLATE), 'utf8'));
|
|
91
|
+
assert.equal(config.version, 1);
|
|
92
|
+
assert.deepEqual(Object.keys(config.hooks).sort(), ['beforeSubmitPrompt', 'sessionStart']);
|
|
93
|
+
assert.match(config.hooks.sessionStart[0].command, /ponytail-activate\.js/);
|
|
94
|
+
assert.match(config.hooks.beforeSubmitPrompt[0].command, /ponytail-mode-tracker\.js/);
|
|
95
|
+
// subagentStart answers only permission/user_message in Cursor, so it must
|
|
96
|
+
// not be registered: it would cost a process per subagent and inject nothing.
|
|
97
|
+
assert.equal(config.hooks.subagentStart, undefined);
|
|
98
|
+
for (const entries of Object.values(config.hooks)) {
|
|
99
|
+
assert.ok(Array.isArray(entries) && entries.length === 1);
|
|
100
|
+
for (const entry of entries) {
|
|
101
|
+
assert.match(entry.command, /^node "PONYTAIL_DIR\/hooks\/ponytail-[\w-]+\.js"$/, 'plain node call, quoted absolute path');
|
|
102
|
+
assert.doesNotMatch(entry.command, /(^|\s)exec\s|&&|\|\|/, 'must run under cmd, PowerShell and bash alike');
|
|
103
|
+
assert.equal(typeof entry.timeout, 'number');
|
|
104
|
+
const script = entry.command.match(/hooks\/([\w.-]+\.js)/)[1];
|
|
105
|
+
assert.ok(fs.existsSync(path.join(root, 'hooks', script)), `command references a missing hook script: ${script}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('hook detection ignores later lookalike arguments after a real ponytail hook', () => {
|
|
111
|
+
assert.equal(isPonytailHook({
|
|
112
|
+
command: 'node "/opt/p/hooks/ponytail-activate.js" --log /tmp/hooks/ponytail-x/',
|
|
113
|
+
}), true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('isCursor is off outside a Cursor hook process', () => {
|
|
117
|
+
const { isCursor } = require('../hooks/ponytail-runtime');
|
|
118
|
+
assert.equal(isCursor, false);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('sessionStart injects the default-level ruleset as additional_context and keeps state under ~/.cursor', () => {
|
|
122
|
+
const c = cursorEnv('start', { PONYTAIL_DEFAULT_MODE: 'ultra' });
|
|
123
|
+
const input = JSON.stringify({
|
|
124
|
+
hook_event_name: 'sessionStart', conversation_id: 'conv-1', session_id: 'conv-1',
|
|
125
|
+
is_background_agent: false, composer_mode: 'agent', workspace_roots: [c.project],
|
|
126
|
+
cursor_version: '3.20.17', model: 'claude-opus-4-7-thinking-max',
|
|
127
|
+
});
|
|
128
|
+
const output = parse(run('ponytail-activate.js', c.env, input));
|
|
129
|
+
assert.deepEqual(Object.keys(output), ['additional_context']);
|
|
130
|
+
assert.match(output.additional_context, /^PONYTAIL MODE ACTIVE — level: ultra/);
|
|
131
|
+
assert.match(output.additional_context, /Also question the request/, 'ultra row must survive the level filter');
|
|
132
|
+
assert.doesNotMatch(output.additional_context, /Build what was asked/, 'lite row must be filtered out');
|
|
133
|
+
assert.doesNotMatch(output.additional_context, /STATUSLINE SETUP NEEDED/, 'Cursor has no Claude statusline to nudge about');
|
|
134
|
+
assert.equal(fs.readFileSync(c.flag, 'utf8'), 'ultra');
|
|
135
|
+
assert.equal(fs.existsSync(path.join(c.home, '.claude')), false, 'Cursor state must not land in ~/.claude');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('sessionStart in off mode emits nothing and writes no flag', () => {
|
|
139
|
+
const c = cursorEnv('off', { PONYTAIL_DEFAULT_MODE: 'off' });
|
|
140
|
+
const result = run('ponytail-activate.js', c.env);
|
|
141
|
+
assert.equal(result.status, 0, result.stderr);
|
|
142
|
+
assert.equal(result.stdout, '', 'empty stdout is a no-op for Cursor; "OK" would be a JSON parse error');
|
|
143
|
+
assert.equal(fs.existsSync(c.flag), false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('Cursor running a Claude-format plugin (CLAUDE_PLUGIN_ROOT set) still gets Cursor JSON', () => {
|
|
147
|
+
const c = cursorEnv('plugin', { PONYTAIL_DEFAULT_MODE: 'full' });
|
|
148
|
+
const pluginRoot = path.join(c.home, '.cursor', 'plugins', 'ponytail');
|
|
149
|
+
c.env.CLAUDE_PLUGIN_ROOT = pluginRoot;
|
|
150
|
+
c.env.CURSOR_PLUGIN_ROOT = pluginRoot;
|
|
151
|
+
const output = parse(run('ponytail-activate.js', c.env));
|
|
152
|
+
assert.match(output.additional_context, /^PONYTAIL MODE ACTIVE — level: full/);
|
|
153
|
+
assert.equal(fs.readFileSync(c.flag, 'utf8'), 'full');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('beforeSubmitPrompt tracks /ponytail commands and delivers the new level ruleset', () => {
|
|
157
|
+
const c = cursorEnv('switch', { PONYTAIL_DEFAULT_MODE: 'full' });
|
|
158
|
+
writeFlag(c, 'full');
|
|
159
|
+
|
|
160
|
+
const sw = parse(run('ponytail-mode-tracker.js', c.env, JSON.stringify({
|
|
161
|
+
hook_event_name: 'beforeSubmitPrompt', conversation_id: 'conv-1',
|
|
162
|
+
prompt: '/ponytail lite', attachments: [],
|
|
163
|
+
})));
|
|
164
|
+
assert.equal(sw.continue, true, 'must never block the prompt');
|
|
165
|
+
assert.equal(sw.user_message, undefined, 'Cursor shows user_message only for blocked prompts');
|
|
166
|
+
assert.match(sw.additional_context, /^PONYTAIL MODE CHANGED — level: lite/);
|
|
167
|
+
assert.match(sw.additional_context, /Build what was asked/, 'Cursor has no /ponytail command, so the level ruleset rides along');
|
|
168
|
+
assert.doesNotMatch(sw.additional_context, /Also question the request/);
|
|
169
|
+
assert.equal(fs.readFileSync(c.flag, 'utf8'), 'lite');
|
|
170
|
+
|
|
171
|
+
// Bare /ponytail reports the live level without resetting it.
|
|
172
|
+
const report = parse(run('ponytail-mode-tracker.js', c.env, JSON.stringify({ prompt: '/ponytail' })));
|
|
173
|
+
assert.deepEqual(report, { continue: true, additional_context: 'PONYTAIL MODE ACTIVE — level: lite' });
|
|
174
|
+
assert.equal(fs.readFileSync(c.flag, 'utf8'), 'lite');
|
|
175
|
+
|
|
176
|
+
// /ponytail default persists the default without touching the session level.
|
|
177
|
+
const def = parse(run('ponytail-mode-tracker.js', c.env, JSON.stringify({ prompt: '/ponytail default ultra' })));
|
|
178
|
+
assert.equal(def.continue, true);
|
|
179
|
+
assert.match(def.additional_context, /PONYTAIL DEFAULT SET — new sessions start in ultra/);
|
|
180
|
+
assert.equal(JSON.parse(fs.readFileSync(path.join(c.home, '.config', 'ponytail', 'config.json'), 'utf8')).defaultMode, 'ultra');
|
|
181
|
+
assert.equal(fs.readFileSync(c.flag, 'utf8'), 'lite');
|
|
182
|
+
|
|
183
|
+
// /ponytail off and the plain-language deactivations clear the flag and tell the model.
|
|
184
|
+
const off = parse(run('ponytail-mode-tracker.js', c.env, JSON.stringify({ prompt: '/ponytail off' })));
|
|
185
|
+
assert.deepEqual(off, { continue: true, additional_context: 'PONYTAIL MODE OFF' });
|
|
186
|
+
assert.equal(fs.existsSync(c.flag), false);
|
|
187
|
+
|
|
188
|
+
writeFlag(c, 'full');
|
|
189
|
+
const stop = parse(run('ponytail-mode-tracker.js', c.env, JSON.stringify({ prompt: 'Stop ponytail.' })));
|
|
190
|
+
assert.equal(stop.additional_context, 'PONYTAIL MODE OFF');
|
|
191
|
+
assert.equal(fs.existsSync(c.flag), false);
|
|
192
|
+
|
|
193
|
+
// Ordinary prompts produce no output at all: Cursor treats empty stdout as "carry on".
|
|
194
|
+
writeFlag(c, 'full');
|
|
195
|
+
const plain = run('ponytail-mode-tracker.js', c.env, JSON.stringify({ prompt: 'add a normal mode toggle next to dark mode' }));
|
|
196
|
+
assert.equal(plain.status, 0, plain.stderr);
|
|
197
|
+
assert.equal(plain.stdout, '');
|
|
198
|
+
assert.equal(fs.readFileSync(c.flag, 'utf8'), 'full', 'incidental "normal mode" must not turn ponytail off');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('with the always-on rule in the workspace the hooks step back instead of duplicating the ruleset', () => {
|
|
202
|
+
const c = cursorEnv('rule', { PONYTAIL_DEFAULT_MODE: 'full' });
|
|
203
|
+
const rule = path.join(c.project, '.cursor', 'rules', 'ponytail.mdc');
|
|
204
|
+
fs.mkdirSync(path.dirname(rule), { recursive: true });
|
|
205
|
+
fs.copyFileSync(path.join(root, '.cursor', 'rules', 'ponytail.mdc'), rule);
|
|
206
|
+
|
|
207
|
+
const start = parse(run('ponytail-activate.js', c.env));
|
|
208
|
+
assert.match(start.additional_context, /always-on Cursor rule/);
|
|
209
|
+
assert.ok(start.additional_context.includes(rule), 'notice must name the rule file');
|
|
210
|
+
assert.doesNotMatch(start.additional_context, /PONYTAIL MODE ACTIVE/, 'no second copy of the ruleset');
|
|
211
|
+
assert.equal(fs.existsSync(c.flag), false, 'no mode flag while the rule owns the ruleset');
|
|
212
|
+
|
|
213
|
+
for (const prompt of ['/ponytail ultra', '/ponytail off', 'normal mode', '/ponytail']) {
|
|
214
|
+
const out = parse(run('ponytail-mode-tracker.js', c.env, JSON.stringify({ prompt })));
|
|
215
|
+
assert.equal(out.continue, true);
|
|
216
|
+
assert.match(out.additional_context, /always-on Cursor rule/, `${prompt} must answer with the rule notice`);
|
|
217
|
+
assert.doesNotMatch(out.additional_context, /PONYTAIL MODE (CHANGED|OFF|ACTIVE)/);
|
|
218
|
+
assert.equal(fs.existsSync(c.flag), false);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const plain = run('ponytail-mode-tracker.js', c.env, JSON.stringify({ prompt: 'hello' }));
|
|
222
|
+
assert.equal(plain.status, 0, plain.stderr);
|
|
223
|
+
assert.equal(plain.stdout, '', 'ordinary prompts stay silent');
|
|
224
|
+
|
|
225
|
+
// Project hooks run from the workspace root: the cwd fallback must find the rule too.
|
|
226
|
+
delete c.env.CURSOR_PROJECT_DIR;
|
|
227
|
+
delete c.env.CLAUDE_PROJECT_DIR;
|
|
228
|
+
const viaCwd = parse(run('ponytail-activate.js', c.env, '', c.project));
|
|
229
|
+
assert.match(viaCwd.additional_context, /always-on Cursor rule/);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('installer merges into an existing ~/.cursor/hooks.json and leaves unrelated hooks alone', () => {
|
|
233
|
+
const home = path.join(temp, 'install', 'home');
|
|
234
|
+
fs.mkdirSync(path.join(home, '.cursor'), { recursive: true });
|
|
235
|
+
const file = path.join(home, '.cursor', 'hooks.json');
|
|
236
|
+
const theirs = {
|
|
237
|
+
version: 1,
|
|
238
|
+
hooks: {
|
|
239
|
+
sessionStart: [{ command: './hooks/their-session.sh' }],
|
|
240
|
+
afterFileEdit: [{ command: './hooks/format.sh', matcher: 'Write' }],
|
|
241
|
+
stop: [{ command: 'node "./hooks/my-ponytail-logger.js"' }],
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
fs.writeFileSync(file, JSON.stringify(theirs));
|
|
245
|
+
const env = { HOME: home, USERPROFILE: home };
|
|
246
|
+
|
|
247
|
+
let result = cli(['install'], env);
|
|
248
|
+
assert.equal(result.status, 0, result.stderr);
|
|
249
|
+
let config = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
250
|
+
assert.equal(config.version, 1);
|
|
251
|
+
assert.deepEqual(config.hooks.afterFileEdit, theirs.hooks.afterFileEdit);
|
|
252
|
+
assert.deepEqual(config.hooks.stop, theirs.hooks.stop, 'similarly named user hook must survive install');
|
|
253
|
+
assert.equal(config.hooks.sessionStart[0].command, './hooks/their-session.sh', 'their sessionStart hook stays first and intact');
|
|
254
|
+
assert.equal(config.hooks.sessionStart.length, 2);
|
|
255
|
+
assert.deepEqual(config.hooks.sessionStart[1], { command: `node "${rootFwd}/hooks/ponytail-activate.js"`, timeout: 15 });
|
|
256
|
+
assert.deepEqual(config.hooks.beforeSubmitPrompt, [{ command: `node "${rootFwd}/hooks/ponytail-mode-tracker.js"`, timeout: 15 }]);
|
|
257
|
+
|
|
258
|
+
// Idempotent: a second install replaces ponytail's entries, never duplicates them.
|
|
259
|
+
result = cli(['install'], env);
|
|
260
|
+
assert.equal(result.status, 0, result.stderr);
|
|
261
|
+
config = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
262
|
+
assert.equal(config.hooks.sessionStart.length, 2);
|
|
263
|
+
assert.equal(config.hooks.beforeSubmitPrompt.length, 1);
|
|
264
|
+
|
|
265
|
+
// Uninstall removes only ponytail's entries.
|
|
266
|
+
result = cli(['uninstall'], env);
|
|
267
|
+
assert.equal(result.status, 0, result.stderr);
|
|
268
|
+
assert.deepEqual(JSON.parse(fs.readFileSync(file, 'utf8')), theirs);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('installer creates the file when absent and removes it again when only ponytail lived there', () => {
|
|
272
|
+
const home = path.join(temp, 'fresh', 'home');
|
|
273
|
+
fs.mkdirSync(home, { recursive: true });
|
|
274
|
+
const env = { HOME: home, USERPROFILE: home };
|
|
275
|
+
const file = path.join(home, '.cursor', 'hooks.json');
|
|
276
|
+
|
|
277
|
+
let result = cli(['install'], env);
|
|
278
|
+
assert.equal(result.status, 0, result.stderr);
|
|
279
|
+
assert.match(result.stdout, /Installed ponytail hooks in /);
|
|
280
|
+
const config = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
281
|
+
assert.equal(config.version, 1);
|
|
282
|
+
assert.deepEqual(Object.keys(config.hooks).sort(), ['beforeSubmitPrompt', 'sessionStart']);
|
|
283
|
+
|
|
284
|
+
result = cli(['uninstall'], env);
|
|
285
|
+
assert.equal(result.status, 0, result.stderr);
|
|
286
|
+
assert.equal(fs.existsSync(file), false);
|
|
287
|
+
|
|
288
|
+
// Uninstalling on a clean machine is a no-op, not an error.
|
|
289
|
+
result = cli(['uninstall'], env);
|
|
290
|
+
assert.equal(result.status, 0, result.stderr);
|
|
291
|
+
assert.match(result.stdout, /No ponytail hooks/);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('installer --project writes <cwd>/.cursor/hooks.json', () => {
|
|
295
|
+
const home = path.join(temp, 'proj', 'home');
|
|
296
|
+
const project = path.join(temp, 'proj', 'repo');
|
|
297
|
+
fs.mkdirSync(home, { recursive: true });
|
|
298
|
+
fs.mkdirSync(project, { recursive: true });
|
|
299
|
+
const env = { HOME: home, USERPROFILE: home };
|
|
300
|
+
|
|
301
|
+
let result = cli(['install', '--project'], env, project);
|
|
302
|
+
assert.equal(result.status, 0, result.stderr);
|
|
303
|
+
const file = path.join(project, '.cursor', 'hooks.json');
|
|
304
|
+
assert.ok(fs.existsSync(file));
|
|
305
|
+
assert.equal(fs.existsSync(path.join(home, '.cursor', 'hooks.json')), false, '--project must not touch the user file');
|
|
306
|
+
|
|
307
|
+
result = cli(['uninstall', '--project'], env, project);
|
|
308
|
+
assert.equal(result.status, 0, result.stderr);
|
|
309
|
+
assert.equal(fs.existsSync(file), false);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test('installer refuses to touch a malformed hooks.json', () => {
|
|
313
|
+
const home = path.join(temp, 'broken', 'home');
|
|
314
|
+
fs.mkdirSync(path.join(home, '.cursor'), { recursive: true });
|
|
315
|
+
const file = path.join(home, '.cursor', 'hooks.json');
|
|
316
|
+
const broken = '{ "version": 1, "hooks": { broken';
|
|
317
|
+
fs.writeFileSync(file, broken);
|
|
318
|
+
const env = { HOME: home, USERPROFILE: home };
|
|
319
|
+
|
|
320
|
+
for (const action of ['install', 'uninstall']) {
|
|
321
|
+
const result = cli([action], env);
|
|
322
|
+
assert.notEqual(result.status, 0, `${action} must fail on malformed JSON`);
|
|
323
|
+
assert.match(result.stderr, /not valid JSON/);
|
|
324
|
+
assert.equal(fs.readFileSync(file, 'utf8'), broken, 'malformed file must be left byte-for-byte intact');
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
for (const [name, config] of Object.entries({
|
|
329
|
+
'root-array': [{ command: 'keep me' }],
|
|
330
|
+
'hooks-array': { hooks: [{ command: 'keep me' }] },
|
|
331
|
+
'event-string': { hooks: { sessionStart: 'node custom-hook.js' } },
|
|
332
|
+
})) {
|
|
333
|
+
test(`installer refuses ${name} without overwriting hooks.json`, () => {
|
|
334
|
+
const c = cursorEnv(`invalid-${name}`);
|
|
335
|
+
const file = path.join(c.home, '.cursor', 'hooks.json');
|
|
336
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
337
|
+
const original = JSON.stringify(config, null, 2) + '\n';
|
|
338
|
+
fs.writeFileSync(file, original);
|
|
339
|
+
const result = cli(['install'], c.env);
|
|
340
|
+
assert.notEqual(result.status, 0, 'invalid config must not be silently rewritten');
|
|
341
|
+
assert.equal(fs.readFileSync(file, 'utf8'), original);
|
|
342
|
+
});
|
|
343
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Smoke test for the Gemini CLI adapter. The adapter is a single thin manifest
|
|
3
|
+
// (gemini-extension.json) that reuses the repo's existing files: AGENTS.md for
|
|
4
|
+
// always-on context, commands/*.toml for /ponytail + /ponytail-review, and
|
|
5
|
+
// skills/ for the agent skills. This test fails if the manifest is removed,
|
|
6
|
+
// loses its pinned version, or points contextFileName at a file that no longer
|
|
7
|
+
// carries the load-bearing rules — i.e. if the adapter stops wiring ponytail.
|
|
8
|
+
|
|
9
|
+
const test = require('node:test');
|
|
10
|
+
const assert = require('node:assert/strict');
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
|
|
14
|
+
const root = path.join(__dirname, '..');
|
|
15
|
+
const MANIFEST = 'gemini-extension.json';
|
|
16
|
+
const EXTENSION_NAME = 'ponytail';
|
|
17
|
+
// Floating refs are a supply-chain footgun; the manifest version must be pinned.
|
|
18
|
+
const PINNED_SEMVER = /^\d+\.\d+\.\d+$/;
|
|
19
|
+
const VERSIONED_MANIFESTS = [
|
|
20
|
+
'gemini-extension.json',
|
|
21
|
+
'.claude-plugin/plugin.json',
|
|
22
|
+
'.codex-plugin/plugin.json',
|
|
23
|
+
'.github/plugin/plugin.json',
|
|
24
|
+
];
|
|
25
|
+
// Gemini auto-discovers these by directory; the manifest is only useful if they exist.
|
|
26
|
+
const REUSED_COMMANDS = ['commands/ponytail.toml', 'commands/ponytail-review.toml'];
|
|
27
|
+
const REUSED_SKILLS = ['skills/ponytail/SKILL.md'];
|
|
28
|
+
// Gemini CLI auto-loads this exact path for extension hooks. Ponytail's
|
|
29
|
+
// Claude/Codex hook map uses events Gemini does not support, so it must stay
|
|
30
|
+
// behind the host-specific plugin manifests instead.
|
|
31
|
+
const GEMINI_AUTO_HOOKS = 'hooks/hooks.json';
|
|
32
|
+
// Same load-bearing phrases asserted by scripts/check-rule-copies.js: the file
|
|
33
|
+
// contextFileName points at must actually carry the rules, not just exist.
|
|
34
|
+
const RULE_INVARIANTS = [
|
|
35
|
+
'lazy senior',
|
|
36
|
+
'validation at trust boundaries',
|
|
37
|
+
'shortcut: <the limit>, <when to upgrade>',
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
function read(relPath) {
|
|
41
|
+
return fs.readFileSync(path.join(root, relPath), 'utf8');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Read inside each test (not at module scope) so a missing or malformed manifest
|
|
45
|
+
// surfaces as a clean per-test assertion failure, not a load-time crash that
|
|
46
|
+
// collapses every case into one unreadable stack trace.
|
|
47
|
+
function loadManifest() {
|
|
48
|
+
assert.ok(fs.existsSync(path.join(root, MANIFEST)), `${MANIFEST} must exist`);
|
|
49
|
+
return JSON.parse(read(MANIFEST));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
test('manifest names the ponytail extension with a pinned version', () => {
|
|
53
|
+
const manifest = loadManifest();
|
|
54
|
+
assert.equal(manifest.name, EXTENSION_NAME);
|
|
55
|
+
assert.match(manifest.version, PINNED_SEMVER);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('version stays aligned with the other plugin manifests', () => {
|
|
59
|
+
const versions = VERSIONED_MANIFESTS.map((rel) => {
|
|
60
|
+
const manifest = JSON.parse(read(rel));
|
|
61
|
+
assert.match(manifest.version, PINNED_SEMVER, `${rel} version must be pinned semver`);
|
|
62
|
+
return manifest.version;
|
|
63
|
+
});
|
|
64
|
+
const [sharedVersion, ...rest] = versions;
|
|
65
|
+
for (const version of rest) {
|
|
66
|
+
assert.equal(version, sharedVersion);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('contextFileName resolves to a file carrying the ponytail rules', () => {
|
|
71
|
+
const manifest = loadManifest();
|
|
72
|
+
assert.ok(manifest.contextFileName, 'contextFileName must be set so rules load every session');
|
|
73
|
+
const context = read(manifest.contextFileName);
|
|
74
|
+
for (const phrase of RULE_INVARIANTS) {
|
|
75
|
+
assert.ok(context.includes(phrase), `context file missing rule invariant: "${phrase}"`);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('the commands and skills the adapter reuses are present', () => {
|
|
80
|
+
for (const rel of [...REUSED_COMMANDS, ...REUSED_SKILLS]) {
|
|
81
|
+
assert.ok(fs.existsSync(path.join(root, rel)), `reused file missing: ${rel}`);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('Gemini cannot auto-discover Claude/Codex hook events', () => {
|
|
86
|
+
assert.equal(
|
|
87
|
+
fs.existsSync(path.join(root, GEMINI_AUTO_HOOKS)),
|
|
88
|
+
false,
|
|
89
|
+
`${GEMINI_AUTO_HOOKS} is auto-loaded by Gemini CLI; keep Claude/Codex hooks on manifest paths`,
|
|
90
|
+
);
|
|
91
|
+
});
|