Merge branch 'main' into salaamdev/main

# Conflicts:
#	README.md
This commit is contained in:
Emeriko
2026-06-26 03:08:24 +02:00
47 changed files with 1716 additions and 131 deletions
+2 -1
View File
@@ -10,7 +10,7 @@ const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const commands = ['ponytail', 'ponytail-review', 'ponytail-audit', 'ponytail-debt', 'ponytail-help'];
const commands = ['ponytail', 'ponytail-review', 'ponytail-audit', 'ponytail-debt', 'ponytail-gain', 'ponytail-help'];
const skillCommands = commands.filter((name) => name !== 'ponytail');
const root = path.join(__dirname, '..');
@@ -70,6 +70,7 @@ print(json.dumps({'skills': ctx.skills, 'hooks': ctx.hooks, 'commands': ctx.comm
'ponytail',
'ponytail-audit',
'ponytail-debt',
'ponytail-gain',
'ponytail-help',
'ponytail-review',
]);
+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)) {
+56 -3
View File
@@ -8,6 +8,16 @@ const { spawnSync } = require('child_process');
const root = path.join(__dirname, '..');
// isShellSafe gates the statusline setup snippet (issue #200): ordinary install
// paths pass, paths carrying shell metacharacters are rejected so they never get
// embedded in a shell command.
const { isShellSafe } = require('../hooks/ponytail-config');
assert.equal(isShellSafe('C:\\Users\\x\\.claude\\plugins\\ponytail\\hooks\\ponytail-statusline.ps1'), true);
assert.equal(isShellSafe('/home/u/.claude/plugins/ponytail/hooks/ponytail-statusline.sh'), true);
assert.equal(isShellSafe('/tmp/a"&calc.exe&"/x.sh'), false);
assert.equal(isShellSafe('/tmp/$(calc)/x.sh'), false);
assert.equal(isShellSafe('/tmp/a;rm -rf/x.sh'), false);
function run(script, env, input = '') {
return spawnSync(process.execPath, [path.join(root, 'hooks', script)], {
env: { ...process.env, ...env },
@@ -16,11 +26,19 @@ function run(script, env, input = '') {
});
}
// Keep the base env clean so the default-dir checks are deterministic; the
// CLAUDE_CONFIG_DIR case sets it explicitly.
// Keep the base env clean so the default-dir / native-Claude checks are
// deterministic; the CLAUDE_CONFIG_DIR and codex/copilot cases set these
// explicitly where needed. run() spreads process.env, so a PLUGIN_DATA /
// COPILOT_PLUGIN_DATA leaked from the dev or CI shell would otherwise steer
// writeHookOutput into the wrong branch and mis-fire the native assertions.
delete process.env.CLAUDE_CONFIG_DIR;
delete process.env.PLUGIN_DATA;
delete process.env.COPILOT_PLUGIN_DATA;
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ponytail-hooks-'));
// Runs on normal exit and on assertion-throw exit; force makes it idempotent.
process.on('exit', () => fs.rmSync(temp, { recursive: true, force: true }));
const home = path.join(temp, 'home');
const pluginData = path.join(temp, 'plugin-data');
fs.mkdirSync(home, { recursive: true });
@@ -155,5 +173,40 @@ assert.equal(
output = JSON.parse(result.stdout);
assert.deepEqual(output, {});
fs.rmSync(temp, { recursive: true, force: true });
// SubagentStart hook: when ponytail mode is active it injects the ruleset into
// each subagent (issue #252). Native Claude must get the hookSpecificOutput JSON
// form, not raw stdout, or the context is dropped.
const subHome = path.join(temp, 'sub-home');
const subFlag = path.join(subHome, '.claude', '.ponytail-active');
fs.mkdirSync(path.dirname(subFlag), { recursive: true });
const subEnv = { HOME: subHome, USERPROFILE: subHome };
fs.writeFileSync(subFlag, 'full');
result = run('ponytail-subagent.js', subEnv);
assert.equal(result.status, 0, result.stderr);
output = JSON.parse(result.stdout);
assert.equal(output.hookSpecificOutput.hookEventName, 'SubagentStart');
assert.match(
output.hookSpecificOutput.additionalContext,
/PONYTAIL MODE ACTIVE — level: full/,
);
// No flag → ponytail off → inject nothing (empty stdout, no failure).
fs.unlinkSync(subFlag);
result = run('ponytail-subagent.js', subEnv);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, '', 'SubagentStart must stay silent when ponytail is off');
// Codex shares claude-codex-hooks.json, so SubagentStart is reachable under Codex
// too — assert the codex branch emits the badge plus hookSpecificOutput.
const subCodex = path.join(temp, 'sub-codex');
fs.mkdirSync(subCodex, { recursive: true });
fs.writeFileSync(path.join(subCodex, '.ponytail-active'), 'full');
result = run('ponytail-subagent.js', { HOME: subHome, USERPROFILE: subHome, PLUGIN_DATA: subCodex });
assert.equal(result.status, 0, result.stderr);
output = JSON.parse(result.stdout);
assert.equal(output.systemMessage, 'PONYTAIL:FULL');
assert.equal(output.hookSpecificOutput.hookEventName, 'SubagentStart');
assert.match(output.hookSpecificOutput.additionalContext, /PONYTAIL MODE ACTIVE — level: full/);
console.log('hook compatibility checks passed');
+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');