@felixgeelhaar/cclint
Version:
Catch CLAUDE.md drift before Claude misbehaves. Lints CLAUDE.md, skills, subagents, and hooks for Claude Code projects.
441 lines • 15.7 kB
JavaScript
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, unlinkSync, } from 'fs';
import { join } from 'path';
/**
* Hook script content marker
*/
const CCLINT_MARKER = '# cclint pre-commit hook';
const CCLINT_END_MARKER = '# end cclint';
/**
* Manages Git pre-commit hooks for cclint.
*/
export class HookManager {
rootDir;
constructor(rootDir) {
this.rootDir = rootDir;
}
/**
* Detect which hook manager is being used in the project
*/
detect() {
// Check for Husky
if (existsSync(join(this.rootDir, '.husky'))) {
return 'husky';
}
// Check for Lefthook
if (existsSync(join(this.rootDir, 'lefthook.yml')) ||
existsSync(join(this.rootDir, 'lefthook.yaml'))) {
return 'lefthook';
}
// Check for pre-commit
if (existsSync(join(this.rootDir, '.pre-commit-config.yaml'))) {
return 'pre-commit';
}
// Check for git hooks directory
if (existsSync(join(this.rootDir, '.git', 'hooks'))) {
return 'git';
}
return null;
}
/**
* Install cclint pre-commit hook
*/
install(config = {}) {
const manager = config.manager ?? this.detect();
if (!manager) {
return {
success: false,
manager: 'git',
hookPath: '',
message: 'Could not detect hook manager. Make sure you have a .git directory or a hook manager configured.',
};
}
switch (manager) {
case 'husky':
return this.installHusky(config);
case 'lefthook':
return this.installLefthook(config);
case 'pre-commit':
return this.installPreCommit(config);
case 'git':
return this.installGitHook(config);
default: {
const unknownManager = manager;
return {
success: false,
manager: unknownManager,
hookPath: '',
message: `Unsupported hook manager: ${unknownManager}`,
};
}
}
}
/**
* Uninstall cclint pre-commit hook
*/
uninstall() {
const manager = this.detect();
if (!manager) {
return {
success: false,
manager: 'git',
hookPath: '',
message: 'No hook manager detected.',
};
}
switch (manager) {
case 'husky':
return this.uninstallHusky();
case 'lefthook':
return this.uninstallLefthook();
case 'pre-commit':
return this.uninstallPreCommit();
case 'git':
return this.uninstallGitHook();
default: {
const unknownManager = manager;
return {
success: false,
manager: unknownManager,
hookPath: '',
message: `Unsupported hook manager: ${unknownManager}`,
};
}
}
}
/**
* Check if cclint hook is installed
*/
isInstalled() {
const manager = this.detect();
if (!manager)
return false;
switch (manager) {
case 'husky': {
const hookPath = join(this.rootDir, '.husky', 'pre-commit');
return (existsSync(hookPath) &&
readFileSync(hookPath, 'utf8').includes(CCLINT_MARKER));
}
case 'lefthook': {
const lefthookPath = join(this.rootDir, 'lefthook.yml') ||
join(this.rootDir, 'lefthook.yaml');
return (existsSync(lefthookPath) &&
readFileSync(lefthookPath, 'utf8').includes('cclint'));
}
case 'pre-commit': {
const preCommitPath = join(this.rootDir, '.pre-commit-config.yaml');
return (existsSync(preCommitPath) &&
readFileSync(preCommitPath, 'utf8').includes('cclint'));
}
case 'git': {
const hookPath = join(this.rootDir, '.git', 'hooks', 'pre-commit');
return (existsSync(hookPath) &&
readFileSync(hookPath, 'utf8').includes(CCLINT_MARKER));
}
default:
return false;
}
}
buildCommand(config) {
const patterns = config.patterns ?? ['CLAUDE.md'];
const patternsStr = patterns.join(' ');
const fixFlag = config.fix ? ' --fix' : '';
if (config.staged) {
// Use git to get staged CLAUDE.md files
return `git diff --cached --name-only --diff-filter=ACM | grep -E 'CLAUDE\\.md$' | xargs -r npx cclint lint${fixFlag}`;
}
return `npx cclint lint ${patternsStr}${fixFlag}`;
}
installHusky(config) {
const huskyDir = join(this.rootDir, '.husky');
const hookPath = join(huskyDir, 'pre-commit');
// Ensure .husky directory exists
if (!existsSync(huskyDir)) {
mkdirSync(huskyDir, { recursive: true });
}
const command = this.buildCommand(config);
const hookContent = existsSync(hookPath)
? readFileSync(hookPath, 'utf8')
: '#!/usr/bin/env sh\n. "$(dirname -- "$0")/_/husky.sh"\n\n';
// Check if already installed
if (hookContent.includes(CCLINT_MARKER)) {
return {
success: true,
manager: 'husky',
hookPath,
message: 'cclint hook already installed in Husky.',
};
}
// Add cclint hook
const newContent = `${hookContent.trimEnd()}\n\n${CCLINT_MARKER}\n${command}\n${CCLINT_END_MARKER}\n`;
writeFileSync(hookPath, newContent, 'utf8');
chmodSync(hookPath, 0o755);
return {
success: true,
manager: 'husky',
hookPath,
message: 'Successfully installed cclint hook in Husky.',
};
}
installLefthook(config) {
const lefthookPath = existsSync(join(this.rootDir, 'lefthook.yml'))
? join(this.rootDir, 'lefthook.yml')
: join(this.rootDir, 'lefthook.yaml');
if (!existsSync(lefthookPath)) {
return {
success: false,
manager: 'lefthook',
hookPath: lefthookPath,
message: 'lefthook.yml not found. Please initialize Lefthook first.',
};
}
const content = readFileSync(lefthookPath, 'utf8');
// Check if already installed
if (content.includes('cclint')) {
return {
success: true,
manager: 'lefthook',
hookPath: lefthookPath,
message: 'cclint hook already configured in Lefthook.',
};
}
const command = this.buildCommand(config);
const hookConfig = `
cclint:
glob: "**/CLAUDE.md"
run: ${command}
`;
// Add to pre-commit section or create it
let newContent;
if (content.includes('pre-commit:')) {
// Add under existing pre-commit section
newContent = content.replace(/(pre-commit:\s*\n\s*commands:)/, `$1${hookConfig}`);
}
else {
// Create pre-commit section
newContent = `${content.trimEnd()}\n\npre-commit:\n commands:${hookConfig}`;
}
writeFileSync(lefthookPath, newContent, 'utf8');
return {
success: true,
manager: 'lefthook',
hookPath: lefthookPath,
message: 'Successfully added cclint hook to Lefthook. Run `lefthook install` to activate.',
};
}
installPreCommit(config) {
const preCommitPath = join(this.rootDir, '.pre-commit-config.yaml');
if (!existsSync(preCommitPath)) {
return {
success: false,
manager: 'pre-commit',
hookPath: preCommitPath,
message: '.pre-commit-config.yaml not found. Please initialize pre-commit first.',
};
}
const content = readFileSync(preCommitPath, 'utf8');
// Check if already installed
if (content.includes('cclint')) {
return {
success: true,
manager: 'pre-commit',
hookPath: preCommitPath,
message: 'cclint hook already configured in pre-commit.',
};
}
const fixFlag = config.fix ? ' --fix' : '';
const hookConfig = `
- repo: local
hooks:
- id: cclint
name: cclint
entry: npx cclint lint${fixFlag}
language: system
files: CLAUDE\\.md$
pass_filenames: true
`;
const newContent = `${content.trimEnd()}\n${hookConfig}`;
writeFileSync(preCommitPath, newContent, 'utf8');
return {
success: true,
manager: 'pre-commit',
hookPath: preCommitPath,
message: 'Successfully added cclint hook to pre-commit. Run `pre-commit install` to activate.',
};
}
installGitHook(config) {
const hooksDir = join(this.rootDir, '.git', 'hooks');
const hookPath = join(hooksDir, 'pre-commit');
if (!existsSync(join(this.rootDir, '.git'))) {
return {
success: false,
manager: 'git',
hookPath,
message: 'Not a git repository. Run `git init` first.',
};
}
// Ensure hooks directory exists
if (!existsSync(hooksDir)) {
mkdirSync(hooksDir, { recursive: true });
}
const command = this.buildCommand(config);
const hookContent = existsSync(hookPath)
? readFileSync(hookPath, 'utf8')
: '#!/usr/bin/env sh\n\n';
// Check if already installed
if (hookContent.includes(CCLINT_MARKER)) {
return {
success: true,
manager: 'git',
hookPath,
message: 'cclint hook already installed.',
};
}
// Add cclint hook
const newContent = `${hookContent.trimEnd()}\n\n${CCLINT_MARKER}\n${command}\n${CCLINT_END_MARKER}\n`;
writeFileSync(hookPath, newContent, 'utf8');
chmodSync(hookPath, 0o755);
return {
success: true,
manager: 'git',
hookPath,
message: 'Successfully installed cclint pre-commit hook.',
};
}
uninstallHusky() {
const hookPath = join(this.rootDir, '.husky', 'pre-commit');
if (!existsSync(hookPath)) {
return {
success: true,
manager: 'husky',
hookPath,
message: 'No pre-commit hook found.',
};
}
const content = readFileSync(hookPath, 'utf8');
if (!content.includes(CCLINT_MARKER)) {
return {
success: true,
manager: 'husky',
hookPath,
message: 'cclint hook not found in Husky.',
};
}
// Remove cclint section
const newContent = content
.replace(new RegExp(`\n*${CCLINT_MARKER}[\\s\\S]*?${CCLINT_END_MARKER}\n*`, 'g'), '\n')
.trimEnd() + '\n';
writeFileSync(hookPath, newContent, 'utf8');
return {
success: true,
manager: 'husky',
hookPath,
message: 'Successfully removed cclint hook from Husky.',
};
}
uninstallLefthook() {
const lefthookPath = existsSync(join(this.rootDir, 'lefthook.yml'))
? join(this.rootDir, 'lefthook.yml')
: join(this.rootDir, 'lefthook.yaml');
if (!existsSync(lefthookPath)) {
return {
success: true,
manager: 'lefthook',
hookPath: lefthookPath,
message: 'lefthook.yml not found.',
};
}
const content = readFileSync(lefthookPath, 'utf8');
if (!content.includes('cclint')) {
return {
success: true,
manager: 'lefthook',
hookPath: lefthookPath,
message: 'cclint hook not found in Lefthook.',
};
}
// Remove cclint command section (basic removal)
const newContent = content
.replace(/\s*cclint:\s*\n\s*glob:[^\n]*\n\s*run:[^\n]*\n?/g, '')
.trimEnd() + '\n';
writeFileSync(lefthookPath, newContent, 'utf8');
return {
success: true,
manager: 'lefthook',
hookPath: lefthookPath,
message: 'Successfully removed cclint hook from Lefthook.',
};
}
uninstallPreCommit() {
const preCommitPath = join(this.rootDir, '.pre-commit-config.yaml');
if (!existsSync(preCommitPath)) {
return {
success: true,
manager: 'pre-commit',
hookPath: preCommitPath,
message: '.pre-commit-config.yaml not found.',
};
}
const content = readFileSync(preCommitPath, 'utf8');
if (!content.includes('cclint')) {
return {
success: true,
manager: 'pre-commit',
hookPath: preCommitPath,
message: 'cclint hook not found in pre-commit.',
};
}
// Remove cclint repo section (basic removal)
const newContent = content
.replace(/\s*-\s*repo:\s*local\s*\n\s*hooks:\s*\n\s*-\s*id:\s*cclint[\s\S]*?(?=\n\s*-\s*repo:|\n*$)/g, '')
.trimEnd() + '\n';
writeFileSync(preCommitPath, newContent, 'utf8');
return {
success: true,
manager: 'pre-commit',
hookPath: preCommitPath,
message: 'Successfully removed cclint hook from pre-commit.',
};
}
uninstallGitHook() {
const hookPath = join(this.rootDir, '.git', 'hooks', 'pre-commit');
if (!existsSync(hookPath)) {
return {
success: true,
manager: 'git',
hookPath,
message: 'No pre-commit hook found.',
};
}
const content = readFileSync(hookPath, 'utf8');
if (!content.includes(CCLINT_MARKER)) {
return {
success: true,
manager: 'git',
hookPath,
message: 'cclint hook not found.',
};
}
// Remove cclint section
const newContent = content
.replace(new RegExp(`\n*${CCLINT_MARKER}[\\s\\S]*?${CCLINT_END_MARKER}\n*`, 'g'), '\n')
.trimEnd() + '\n';
// If only shebang remains, remove the file
if (newContent.trim() === '#!/usr/bin/env sh') {
unlinkSync(hookPath);
return {
success: true,
manager: 'git',
hookPath,
message: 'Successfully removed cclint pre-commit hook.',
};
}
writeFileSync(hookPath, newContent, 'utf8');
return {
success: true,
manager: 'git',
hookPath,
message: 'Successfully removed cclint hook from pre-commit.',
};
}
}
//# sourceMappingURL=HookManager.js.map