feat: add dry-run mode for hook execution (#2116) (#2188)

- Global --dry-run flag and ECC_DRY_RUN=1 env var
- Enriched preview: shows target file path, tool name, and command
- --dry-run stripped from argv so command routing works correctly
- Handles non-JSON and empty stdin gracefully (session/stop hooks)
- 10 tests covering isDryRun(), hook gating, enriched output, CLI routing
This commit is contained in:
Naomi
2026-06-15 19:01:21 +01:00
committed by GitHub
parent d24c7185fc
commit 48608863ea
4 changed files with 326 additions and 2 deletions
+19 -1
View File
@@ -106,6 +106,7 @@ ECC selective-install CLI
Usage:
ecc <command> [args...]
ecc [install args...]
ecc --dry-run <command> [args...]
Commands:
${PRIMARY_COMMANDS.map(command => ` ${command.padEnd(15)} ${COMMANDS[command].description}`).join('\n')}
@@ -115,6 +116,9 @@ Compatibility:
ecc [args...] Without a command, args are routed to "install"
ecc help <command> Show help for a specific command
Global Flags:
--dry-run Preview actions without executing (sets ECC_DRY_RUN=1)
Examples:
ecc typescript
ecc install --profile developer --target claude
@@ -152,7 +156,21 @@ function resolveCommand(argv) {
return { mode: 'help' };
}
const [firstArg, ...restArgs] = args;
if (args.includes('--dry-run')) {
process.env.ECC_DRY_RUN = '1';
}
let cmdStart = 0;
while (cmdStart < args.length && args[cmdStart] === '--dry-run') {
cmdStart++;
}
if (cmdStart >= args.length) {
return { mode: 'help' };
}
const firstArg = args[cmdStart];
const restArgs = args.slice(cmdStart + 1);
if (firstArg === '--help' || firstArg === '-h') {
return { mode: 'help' };
+52 -1
View File
@@ -11,7 +11,7 @@
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const { isHookEnabled } = require('../lib/hook-flags');
const { isHookEnabled, isDryRun } = require('../lib/hook-flags');
const { buildPreToolUseAdditionalContext } = require('./pretooluse-visible-output');
const MAX_STDIN = 1024 * 1024;
@@ -100,6 +100,50 @@ function getPluginRoot() {
return path.resolve(__dirname, '..', '..');
}
//Safely extract target context from hook stdin JSON for dry-run preview.
function extractTargetContext(raw) {
const result = { tool: '', filePath: '', command: '' };
if (!raw || typeof raw !== 'string') return result;
try {
const payload = JSON.parse(raw);
if (payload && typeof payload === 'object') {
result.tool = String(payload.tool || '');
const input = payload.tool_input;
if (input && typeof input === 'object') {
result.filePath = String(input.file_path || input.path || '');
result.command = String(input.command || '');
}
}
} catch {
}
return result;
}
// Build the [DryRun] preview line for stderr.
function buildDryRunPreview(hookId, relScriptPath, profilesCsv, raw) {
const ctx = extractTargetContext(raw);
const parts = [
`[DryRun] Hook "${hookId}" would execute: ${relScriptPath}`,
`(enabled=true, profiles=${profilesCsv || 'default'})`,
];
if (ctx.tool) {
parts.push(`tool=${ctx.tool}`);
}
if (ctx.filePath) {
parts.push(`target=${ctx.filePath}`);
}
if (ctx.command) {
parts.push(`command=${ctx.command}`);
}
return parts.join(' ') + '\n';
}
async function main() {
const [, , hookId, relScriptPath, profilesCsv] = process.argv;
const { raw, truncated } = await readStdinRaw();
@@ -125,6 +169,13 @@ async function main() {
return;
}
if (isDryRun()) {
const preview = buildDryRunPreview(hookId, relScriptPath, profilesCsv, raw);
process.stderr.write(preview);
process.stdout.write(raw);
process.exit(0);
}
const pluginRoot = getPluginRoot();
const resolvedRoot = path.resolve(pluginRoot);
const scriptPath = path.resolve(pluginRoot, relScriptPath);
+5
View File
@@ -50,6 +50,10 @@ function parseProfiles(rawProfiles, fallback = ['standard', 'strict']) {
return parsed.length > 0 ? parsed : [...fallback];
}
function isDryRun() {
return process.env.ECC_DRY_RUN === '1';
}
function isHookEnabled(hookId, options = {}) {
const id = normalizeId(hookId);
if (!id) return true;
@@ -71,4 +75,5 @@ module.exports = {
getDisabledHookIds,
parseProfiles,
isHookEnabled,
isDryRun,
};