feat: auto-detect formatter in post-edit hook (Biome/Prettier)

The post-edit-format hook was hardcoded to use Prettier. Projects using
Biome had their code reformatted with Prettier defaults (e.g. double
quotes overwriting single quotes).

Now the hook walks up from the edited file to find the project root,
then checks for config files:
- biome.json / biome.jsonc → runs Biome
- .prettierrc / prettier.config.* → runs Prettier
- Neither found → skips formatting silently
This commit is contained in:
Jonghyeok Park
2026-02-20 14:30:01 +09:00
parent 24047351c2
commit 4eb6fbdd3f
2 changed files with 64 additions and 11 deletions

View File

@@ -1,14 +1,17 @@
#!/usr/bin/env node
/**
* PostToolUse Hook: Auto-format JS/TS files with Prettier after edits
* PostToolUse Hook: Auto-format JS/TS files after edits
*
* Cross-platform (Windows, macOS, Linux)
*
* Runs after Edit tool use. If the edited file is a JS/TS file,
* formats it with Prettier. Fails silently if Prettier isn't installed.
* auto-detects the project formatter (Biome or Prettier) by looking
* for config files, then formats accordingly.
* Fails silently if no formatter is found or installed.
*/
const { execFileSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const MAX_STDIN = 1024 * 1024; // 1MB limit
@@ -21,6 +24,52 @@ process.stdin.on('data', chunk => {
}
});
function findProjectRoot(startDir) {
let dir = startDir;
while (dir !== path.dirname(dir)) {
if (fs.existsSync(path.join(dir, 'package.json'))) return dir;
dir = path.dirname(dir);
}
return startDir;
}
function detectFormatter(projectRoot) {
const biomeConfigs = ['biome.json', 'biome.jsonc'];
for (const cfg of biomeConfigs) {
if (fs.existsSync(path.join(projectRoot, cfg))) return 'biome';
}
const prettierConfigs = [
'.prettierrc',
'.prettierrc.json',
'.prettierrc.js',
'.prettierrc.cjs',
'.prettierrc.mjs',
'.prettierrc.yml',
'.prettierrc.yaml',
'.prettierrc.toml',
'prettier.config.js',
'prettier.config.cjs',
'prettier.config.mjs',
];
for (const cfg of prettierConfigs) {
if (fs.existsSync(path.join(projectRoot, cfg))) return 'prettier';
}
return null;
}
function getFormatterCommand(formatter, filePath) {
const npxBin = process.platform === 'win32' ? 'npx.cmd' : 'npx';
if (formatter === 'biome') {
return { bin: npxBin, args: ['@biomejs/biome', 'format', '--write', filePath] };
}
if (formatter === 'prettier') {
return { bin: npxBin, args: ['prettier', '--write', filePath] };
}
return null;
}
process.stdin.on('end', () => {
try {
const input = JSON.parse(data);
@@ -28,15 +77,19 @@ process.stdin.on('end', () => {
if (filePath && /\.(ts|tsx|js|jsx)$/.test(filePath)) {
try {
// Use npx.cmd on Windows to avoid shell: true which enables command injection
const npxBin = process.platform === 'win32' ? 'npx.cmd' : 'npx';
execFileSync(npxBin, ['prettier', '--write', filePath], {
cwd: path.dirname(path.resolve(filePath)),
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 15000
});
const projectRoot = findProjectRoot(path.dirname(path.resolve(filePath)));
const formatter = detectFormatter(projectRoot);
const cmd = getFormatterCommand(formatter, filePath);
if (cmd) {
execFileSync(cmd.bin, cmd.args, {
cwd: projectRoot,
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 15000
});
}
} catch {
// Prettier not installed, file missing, or failed — non-blocking
// Formatter not installed, file missing, or failed — non-blocking
}
}
} catch {