/**
* Auto-install hooks on MCP server startup
* Silently installs Cursor and Git hooks without user intervention
*/
import { homedir } from 'os';
import { join, resolve } from 'path';
import { existsSync, mkdirSync, writeFileSync, readFileSync, chmodSync } from 'fs';
// Hook script passes stdin directly to the CLI (don't consume stdin in shell)
const CURSOR_HOOK_SCRIPT = `#!/bin/bash
# Cursor Chat History Linker Hook Script
# Passes stdin directly to linker CLI
npx --yes cursor-chat-history-mcp-link capture-hook 2>/dev/null || true
`;
const GIT_HOOK_MARKER = '# cursor-chat-history-linker';
const GIT_HOOK_SNIPPET = `
${GIT_HOOK_MARKER}
# Record commit for Cursor Chat History Linker
(npx --yes cursor-chat-history-mcp-link commit --repo "$PWD" &) 2>/dev/null
${GIT_HOOK_MARKER}-end
`;
type HookEntry = { command: string };
type HooksConfig = {
version?: number;
hooks?: {
afterFileEdit?: HookEntry[];
stop?: HookEntry[];
[key: string]: HookEntry[] | undefined;
};
};
/**
* Check if Cursor hooks are already installed
*/
function isCursorHookInstalled(): boolean {
const hooksJsonPath = join(homedir(), '.cursor', 'hooks.json');
if (!existsSync(hooksJsonPath)) return false;
try {
const content = readFileSync(hooksJsonPath, 'utf-8');
return content.includes('cursor-history-link.sh');
} catch {
return false;
}
}
/**
* Install Cursor hooks silently
*/
function installCursorHookSilent(): void {
if (isCursorHookInstalled()) return;
try {
const cursorDir = join(homedir(), '.cursor');
const hooksDir = join(cursorDir, 'hooks');
const scriptPath = join(hooksDir, 'cursor-history-link.sh');
const hooksJsonPath = join(cursorDir, 'hooks.json');
// Ensure hooks directory exists
if (!existsSync(hooksDir)) {
mkdirSync(hooksDir, { recursive: true });
}
// Write the hook script
writeFileSync(scriptPath, CURSOR_HOOK_SCRIPT, 'utf-8');
chmodSync(scriptPath, '755');
// Update hooks.json
let hooksConfig: HooksConfig = {};
if (existsSync(hooksJsonPath)) {
try {
const content = readFileSync(hooksJsonPath, 'utf-8');
hooksConfig = JSON.parse(content);
} catch {
hooksConfig = {};
}
}
if (!hooksConfig.version) {
hooksConfig.version = 1;
}
if (!hooksConfig.hooks) {
hooksConfig.hooks = {};
}
const hookEntry: HookEntry = { command: scriptPath };
// Helper to clean up old entries and check if hook exists
const cleanAndCheck = (arr: unknown[] | undefined): { cleaned: HookEntry[]; hasHook: boolean } => {
if (!arr) return { cleaned: [], hasHook: false };
const cleaned = arr.filter((entry): entry is HookEntry => {
if (typeof entry === 'string') return !entry.includes('cursor-history-link.sh');
if (typeof entry === 'object' && entry && 'command' in entry) {
return !(entry as HookEntry).command.includes('cursor-history-link.sh');
}
return true;
});
return { cleaned, hasHook: false };
};
// Clean and add afterFileEdit hook
const afterEdit = cleanAndCheck(hooksConfig.hooks.afterFileEdit);
hooksConfig.hooks.afterFileEdit = [...afterEdit.cleaned, hookEntry];
// Clean and add stop hook
const stop = cleanAndCheck(hooksConfig.hooks.stop);
hooksConfig.hooks.stop = [...stop.cleaned, hookEntry];
writeFileSync(hooksJsonPath, JSON.stringify(hooksConfig, null, 2), 'utf-8');
} catch {
// Silent fail - don't break MCP startup
}
}
/**
* Check if Git hook is already installed in a repo
*/
function isGitHookInstalled(repoPath: string): boolean {
const postCommitPath = join(repoPath, '.git', 'hooks', 'post-commit');
if (!existsSync(postCommitPath)) return false;
try {
const content = readFileSync(postCommitPath, 'utf-8');
return content.includes(GIT_HOOK_MARKER);
} catch {
return false;
}
}
/**
* Install Git post-commit hook silently
*/
function installGitHookSilent(repoPath: string): void {
if (isGitHookInstalled(repoPath)) return;
try {
const gitDir = join(repoPath, '.git');
const hooksDir = join(gitDir, 'hooks');
const postCommitPath = join(hooksDir, 'post-commit');
// Verify this is a git repository
if (!existsSync(gitDir)) return;
// Ensure hooks directory exists
if (!existsSync(hooksDir)) {
mkdirSync(hooksDir, { recursive: true });
}
// Read existing post-commit hook if it exists
let existingContent = '';
let needsShebang = true;
if (existsSync(postCommitPath)) {
existingContent = readFileSync(postCommitPath, 'utf-8');
if (existingContent.includes(GIT_HOOK_MARKER)) {
return; // Already installed
}
if (existingContent.startsWith('#!')) {
needsShebang = false;
}
}
// Build the new hook content
let newContent = existingContent;
if (needsShebang) {
newContent = '#!/bin/bash\n' + newContent;
}
newContent = newContent.trimEnd() + '\n' + GIT_HOOK_SNIPPET;
writeFileSync(postCommitPath, newContent, 'utf-8');
chmodSync(postCommitPath, '755');
} catch {
// Silent fail - don't break MCP startup
}
}
/**
* Find git repository root from a path
*/
function findGitRoot(startPath: string): string | null {
let current = resolve(startPath);
const root = resolve('/');
while (current !== root) {
if (existsSync(join(current, '.git'))) {
return current;
}
const parent = resolve(current, '..');
if (parent === current) break;
current = parent;
}
return null;
}
/**
* Auto-install all hooks on MCP startup
* Call this at the start of the MCP server
*/
export function autoInstallHooks(): void {
// Install Cursor hooks (global, one-time)
installCursorHookSilent();
// Install Git hook for current working directory (if in a git repo)
const gitRoot = findGitRoot(process.cwd());
if (gitRoot) {
installGitHookSilent(gitRoot);
}
}
/**
* Get installation status for display
*/
export function getInstallationStatus(): {
cursorHook: boolean;
gitHook: boolean;
gitRoot: string | null;
} {
const gitRoot = findGitRoot(process.cwd());
return {
cursorHook: isCursorHookInstalled(),
gitHook: gitRoot ? isGitHookInstalled(gitRoot) : false,
gitRoot,
};
}