Skip to content

Commit 69a2c11

Browse files
committed
feat: add hooks management and translation for Claude Code plugins
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
1 parent 7a1ac5f commit 69a2c11

File tree

11 files changed

+1604
-14
lines changed

11 files changed

+1604
-14
lines changed

src/commands/plugin-uninstall.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,14 @@ import {
88
DIR_SKILLS,
99
FILE_AIPM_CONFIG,
1010
FILE_AIPM_CONFIG_LOCAL,
11+
FILE_HOOKS_JSON,
1112
PLUGIN_SUBDIRS,
1213
} from '../constants';
1314
import { getErrorMessage, isFileNotFoundError } from '../errors';
1415
import { loadTargetConfig, saveConfig } from '../helpers/aipm-config';
16+
import { writeJsonFile } from '../helpers/fs';
1517
import { resolveMarketplacePath } from '../helpers/git';
18+
import { readExistingHooks } from '../helpers/hooks-merger';
1619
import { defaultIO } from '../helpers/io';
1720
import {
1821
getMarketplaceType,
@@ -170,6 +173,49 @@ export async function pluginUninstall(options: unknown): Promise<void> {
170173
if (deletedCount > 0) {
171174
defaultIO.logSuccess(`Deleted plugin files from ${deletedCount} location(s) in .cursor/`);
172175
}
176+
177+
// Clean up hooks from .cursor/hooks.json by filtering out hooks from uninstalled plugin
178+
const cursorDir = join(cwd, DIR_CURSOR);
179+
const hookIdPrefix = `aipm/${marketplaceName}/${pluginName}/`;
180+
181+
// Read existing hooks and filter out hooks from uninstalled plugin
182+
const existingHooks = await readExistingHooks(cursorDir);
183+
184+
if (existingHooks) {
185+
const cleanedHooks: typeof existingHooks = {
186+
version: 1,
187+
hooks: {},
188+
};
189+
190+
// Preserve user hooks and AIPM hooks from other plugins
191+
for (const [eventName, hooks] of Object.entries(existingHooks.hooks)) {
192+
const filteredHooks = hooks.filter((hook) => {
193+
// Guard against non-object hook entries (null, undefined, primitives, arrays)
194+
if (!hook || typeof hook !== 'object' || Array.isArray(hook)) {
195+
return false; // Skip malformed entries
196+
}
197+
// Remove hooks from uninstalled plugin
198+
if (
199+
'x-managedBy' in hook &&
200+
hook['x-managedBy'] === 'aipm' &&
201+
'x-hookId' in hook &&
202+
typeof hook['x-hookId'] === 'string'
203+
) {
204+
return !hook['x-hookId'].startsWith(hookIdPrefix);
205+
}
206+
// Preserve user hooks and other AIPM hooks
207+
return true;
208+
});
209+
210+
if (filteredHooks.length > 0) {
211+
cleanedHooks.hooks[eventName] = filteredHooks;
212+
}
213+
}
214+
215+
// Write cleaned hooks.json
216+
const hooksPath = join(cursorDir, FILE_HOOKS_JSON);
217+
await writeJsonFile(hooksPath, cleanedHooks);
218+
}
173219
}
174220
}
175221
} catch (error: unknown) {

src/commands/sync.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { DIR_AIPM_NAMESPACE, DIR_MARKETPLACE, PLUGIN_SUBDIRS } from '../constant
66
import { getErrorMessage } from '../errors';
77
import { ensureDir, fileExists } from '../helpers/fs';
88
import { resolveMarketplacePath } from '../helpers/git';
9+
import { mergeHooks } from '../helpers/hooks-merger';
910
import { defaultIO } from '../helpers/io';
1011
import {
1112
getMarketplaceType,
@@ -15,7 +16,7 @@ import {
1516
} from '../helpers/marketplace';
1617
import { tryParsePluginId } from '../helpers/plugin';
1718
import { formatSyncResult, syncPluginToCursor } from '../helpers/sync-strategy';
18-
import type { IntegrationConfigSchema } from '../schema';
19+
import type { CursorHooksConfig, IntegrationConfigSchema } from '../schema';
1920

2021
const SyncOptionsSchema = z.object({
2122
cwd: z.string().optional(),
@@ -67,8 +68,15 @@ export async function sync(options: SyncOptions = {}): Promise<void> {
6768
.filter(([_, plugin]) => plugin.enabled)
6869
.map(([id, _]) => id);
6970

71+
// Initialize collectedPluginHooks early so we can clean up hooks even if no plugins are enabled
72+
const collectedPluginHooks: CursorHooksConfig[] = [];
73+
7074
if (enabledPlugins.length === 0) {
7175
defaultIO.logInfo('No enabled plugins found');
76+
// Still need to clean up hooks from disabled/uninstalled plugins
77+
if (!cmd.dryRun) {
78+
await mergeHooks(targetDir, collectedPluginHooks);
79+
}
7280
return;
7381
}
7482

@@ -165,6 +173,15 @@ export async function sync(options: SyncOptions = {}): Promise<void> {
165173

166174
// Remove disabled types after syncing
167175
const includeConfig = cursorIntegration?.include;
176+
177+
// Collect translated hooks for later merging (only if hooks are enabled)
178+
if (syncResult.translatedHooks) {
179+
const hooksEnabled = !includeConfig || includeConfig === 'all' || isSubdirEnabled('hooks', includeConfig);
180+
if (hooksEnabled) {
181+
collectedPluginHooks.push(syncResult.translatedHooks);
182+
}
183+
}
184+
168185
if (includeConfig && includeConfig !== 'all') {
169186
const disabledSubdirs = PLUGIN_SUBDIRS.filter((subdir) => !isSubdirEnabled(subdir, includeConfig));
170187

@@ -183,6 +200,12 @@ export async function sync(options: SyncOptions = {}): Promise<void> {
183200
installedCount++;
184201
}
185202

203+
// After syncing all plugins, merge hooks into .cursor/hooks.json
204+
// Always call mergeHooks (even with empty array) to clean up hooks from disabled/uninstalled plugins
205+
if (!cmd.dryRun) {
206+
await mergeHooks(targetDir, collectedPluginHooks);
207+
}
208+
186209
console.log('');
187210

188211
if (cmd.dryRun) {

src/constants.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,9 @@ export const FILE_CLAUDE_CONFIG = 'config.json';
5151
*/
5252
export const ENV_HOME = 'HOME';
5353
export const ENV_USERPROFILE = 'USERPROFILE';
54+
55+
/**
56+
* Hook-related constants
57+
*/
58+
export const CLAUDE_PLUGIN_ROOT_VAR = '${CLAUDE_PLUGIN_ROOT}';
59+
export const FILE_HOOKS_JSON = 'hooks.json';

src/helpers/hooks-merger.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { join } from 'node:path';
2+
import { FILE_HOOKS_JSON } from '../constants';
3+
import type { CursorHooksConfig } from '../schema';
4+
import { fileExists, readJsonFile, writeJsonFile } from './fs';
5+
import { defaultIO } from './io';
6+
7+
/**
8+
* Read existing Cursor hooks.json file
9+
*
10+
* @param cursorDir - The .cursor directory path
11+
* @returns Existing hooks configuration or null if file doesn't exist
12+
*/
13+
export async function readExistingHooks(cursorDir: string): Promise<CursorHooksConfig | null> {
14+
const hooksPath = join(cursorDir, FILE_HOOKS_JSON);
15+
16+
if (!(await fileExists(hooksPath))) {
17+
return null;
18+
}
19+
20+
try {
21+
// Read without strict schema validation to allow user hooks that don't match AIPM format
22+
const rawData = await readJsonFile<any>(hooksPath);
23+
24+
// Basic validation - must have version and hooks
25+
if (!rawData || typeof rawData !== 'object' || !rawData.hooks) {
26+
defaultIO.logInfo(`⚠️ Invalid ${FILE_HOOKS_JSON} format - will create new file`);
27+
return null;
28+
}
29+
30+
// Return as CursorHooksConfig (may contain non-AIPM hooks)
31+
return rawData as CursorHooksConfig;
32+
} catch (error) {
33+
// If file exists but is invalid JSON, log warning and return null
34+
// This allows AIPM to create a new hooks.json
35+
defaultIO.logInfo(`⚠️ Failed to parse existing ${FILE_HOOKS_JSON} - will create new file: ${error}`);
36+
return null;
37+
}
38+
}
39+
40+
/**
41+
* Preserve user hooks (hooks not managed by AIPM) and merge AIPM hooks
42+
*
43+
* @param existingHooks - Existing hooks configuration (may be null)
44+
* @param aipmHooks - Array of AIPM-managed hook configurations to merge
45+
* @returns Merged hooks configuration
46+
*/
47+
export function preserveUserHooks(
48+
existingHooks: CursorHooksConfig | null,
49+
aipmHooks: CursorHooksConfig[],
50+
): CursorHooksConfig {
51+
const result: CursorHooksConfig = {
52+
version: 1,
53+
hooks: {},
54+
};
55+
56+
// First, preserve user hooks (hooks without x-managedBy: "aipm")
57+
if (existingHooks) {
58+
for (const [eventName, hooks] of Object.entries(existingHooks.hooks)) {
59+
const userHooks = hooks.filter((hook) => {
60+
// Guard against non-object hook entries (null, undefined, primitives, arrays)
61+
if (!hook || typeof hook !== 'object' || Array.isArray(hook)) {
62+
return false; // Skip malformed entries
63+
}
64+
// Preserve hooks that don't have x-managedBy field or have a different value
65+
return !('x-managedBy' in hook) || hook['x-managedBy'] !== 'aipm';
66+
});
67+
68+
if (userHooks.length > 0) {
69+
result.hooks[eventName] = [...(result.hooks[eventName] || []), ...userHooks];
70+
}
71+
}
72+
}
73+
74+
// Then, merge all AIPM hooks
75+
for (const aipmHookConfig of aipmHooks) {
76+
for (const [eventName, hooks] of Object.entries(aipmHookConfig.hooks)) {
77+
if (!result.hooks[eventName]) {
78+
result.hooks[eventName] = [];
79+
}
80+
result.hooks[eventName].push(...hooks);
81+
}
82+
}
83+
84+
return result;
85+
}
86+
87+
/**
88+
* Merge hooks from multiple plugins into a single .cursor/hooks.json file
89+
*
90+
* @param cursorDir - The .cursor directory path
91+
* @param pluginHooks - Array of hook configurations from enabled plugins
92+
*/
93+
export async function mergeHooks(cursorDir: string, pluginHooks: CursorHooksConfig[]): Promise<void> {
94+
// Read existing hooks
95+
const existingHooks = await readExistingHooks(cursorDir);
96+
97+
// Merge: preserve user hooks + add AIPM hooks
98+
const mergedHooks = preserveUserHooks(existingHooks, pluginHooks);
99+
100+
// Write merged hooks.json
101+
// Don't use strict schema validation because user hooks may not have x-managedBy
102+
const hooksPath = join(cursorDir, FILE_HOOKS_JSON);
103+
await writeJsonFile(hooksPath, mergedHooks);
104+
}

src/helpers/hooks-translator.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { CLAUDE_PLUGIN_ROOT_VAR } from '../constants';
2+
import type { ClaudeCodeHook, CursorHook, CursorHooksConfig } from '../schema';
3+
import { defaultIO } from './io';
4+
5+
/**
6+
* Event mapping from Claude Code to Cursor hooks
7+
*/
8+
const EVENT_MAP: Record<string, string> = {
9+
SessionStart: 'beforeSubmitPrompt',
10+
UserPromptSubmit: 'beforeSubmitPrompt',
11+
PostToolUse: 'afterShellExecution', // Best approximation
12+
Stop: 'stop',
13+
SessionEnd: 'stop',
14+
};
15+
16+
/**
17+
* Translate Claude Code hooks.json format to Cursor hooks.json format
18+
*
19+
* @param claudeHook - The Claude Code hook configuration
20+
* @param marketplaceName - Name of the marketplace (e.g., "claude/thedotmack")
21+
* @param pluginName - Name of the plugin (e.g., "claude-mem")
22+
* @param pluginPath - The actual plugin path (global location, e.g., ~/.claude/plugins/...)
23+
* @returns Translated Cursor hooks configuration
24+
*/
25+
export function translateClaudeCodeHook(
26+
claudeHook: ClaudeCodeHook,
27+
marketplaceName: string,
28+
pluginName: string,
29+
pluginPath: string,
30+
): CursorHooksConfig {
31+
const result: CursorHooksConfig = {
32+
version: 1,
33+
hooks: {},
34+
};
35+
36+
// Resolve CLAUDE_PLUGIN_ROOT to the actual plugin path (global location)
37+
const pluginRootPath = pluginPath;
38+
39+
// Iterate through Claude Code hooks
40+
for (const [claudeEvent, hookGroups] of Object.entries(claudeHook.hooks)) {
41+
const cursorEvent = EVENT_MAP[claudeEvent];
42+
43+
if (!cursorEvent) {
44+
defaultIO.logInfo(
45+
`⚠️ Unknown Claude Code hook event '${claudeEvent}' - skipping (plugin: ${pluginName}@${marketplaceName})`,
46+
);
47+
continue;
48+
}
49+
50+
// Initialize array if it doesn't exist
51+
if (!result.hooks[cursorEvent]) {
52+
result.hooks[cursorEvent] = [];
53+
}
54+
55+
// Process each hook group (Claude Code supports matchers and nested arrays)
56+
for (let groupIndex = 0; groupIndex < hookGroups.length; groupIndex++) {
57+
const hookGroup = hookGroups[groupIndex];
58+
if (!hookGroup) {
59+
continue;
60+
}
61+
62+
// Process each hook in the group
63+
for (let hookIndex = 0; hookIndex < hookGroup.hooks.length; hookIndex++) {
64+
const hook = hookGroup.hooks[hookIndex];
65+
if (!hook) {
66+
continue;
67+
}
68+
69+
// Generate unique hook ID
70+
const hookName = `${claudeEvent.toLowerCase()}-${groupIndex}-${hookIndex}`;
71+
const hookId = `aipm/${marketplaceName}/${pluginName}/${hookName}`;
72+
73+
// Resolve CLAUDE_PLUGIN_ROOT variable in command
74+
// Use function to prevent special replacement pattern interpretation ($&, $1, etc.)
75+
const resolvedCommand = hook.command.replaceAll(CLAUDE_PLUGIN_ROOT_VAR, () => pluginRootPath);
76+
77+
// Create Cursor hook
78+
const cursorHook: CursorHook = {
79+
'x-managedBy': 'aipm',
80+
'x-hookId': hookId,
81+
command: resolvedCommand,
82+
};
83+
84+
result.hooks[cursorEvent].push(cursorHook);
85+
}
86+
}
87+
}
88+
89+
return result;
90+
}
91+
92+
/**
93+
* Count the total number of hooks in a Cursor hooks configuration
94+
*/
95+
export function countTranslatedHooks(config: CursorHooksConfig): number {
96+
let count = 0;
97+
for (const hooks of Object.values(config.hooks)) {
98+
count += hooks.length;
99+
}
100+
return count;
101+
}

0 commit comments

Comments
 (0)