From 258dee148bd27eca8c28d89d13800c63057658cd Mon Sep 17 00:00:00 2001 From: Yong-yuan-X <2463436064@qq.com> Date: Wed, 24 Jun 2026 23:42:57 +0800 Subject: [PATCH] fix: remove project-scope hooks from user settings --- src/__tests__/hooks-cmd.test.ts | 91 +++++++++++++++++++++++++++++++-- src/hooks-cmd.ts | 53 ++++++++++++------- 2 files changed, 123 insertions(+), 21 deletions(-) diff --git a/src/__tests__/hooks-cmd.test.ts b/src/__tests__/hooks-cmd.test.ts index 87e34f3..6c4a273 100644 --- a/src/__tests__/hooks-cmd.test.ts +++ b/src/__tests__/hooks-cmd.test.ts @@ -1,3 +1,4 @@ +import path from 'node:path'; import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; // ── Mocks ──────────────────────────────────────────────── @@ -54,6 +55,18 @@ const mockTeamConfig = { }, }; +function mockHome(home: string): () => void { + const originalHome = process.env.HOME; + process.env.HOME = home; + return () => { + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + }; +} + // ── Setup ──────────────────────────────────────────────── beforeEach(() => { @@ -92,7 +105,8 @@ describe('hooksInject', () => { await expect(hooksInject({})).rejects.toThrow('not initialized'); }); - it('should use project scope baseDir when project config detected', async () => { + it('should inject into project and user base dirs when project config detected', async () => { + const restoreHome = mockHome('/home/testuser'); const projectConfig = { ...mockLocalConfig, scope: 'project', @@ -100,12 +114,23 @@ describe('hooksInject', () => { }; mockedAutoDetectInit.mockResolvedValue({ localConfig: projectConfig, teamConfig: mockTeamConfig }); - await hooksInject({}); + try { + await hooksInject({}); + } finally { + restoreHome(); + } - expect(mockedInjectHooksToAllTools).toHaveBeenCalledWith( + expect(mockedInjectHooksToAllTools).toHaveBeenCalledTimes(2); + expect(mockedInjectHooksToAllTools).toHaveBeenNthCalledWith( + 1, mockTeamConfig.toolPaths, '/path/to/project', ); + expect(mockedInjectHooksToAllTools).toHaveBeenNthCalledWith( + 2, + mockTeamConfig.toolPaths, + '/home/testuser', + ); }); }); @@ -133,6 +158,66 @@ describe('hooksRemove', () => { expect(mockedLog.success).toHaveBeenCalled(); }); + it('should remove hooks from project and user base dirs when project config detected', async () => { + const restoreHome = mockHome('/home/testuser'); + const projectConfig = { + ...mockLocalConfig, + scope: 'project', + projectRoot: '/path/to/project', + }; + mockedAutoDetectInit.mockResolvedValue({ localConfig: projectConfig, teamConfig: mockTeamConfig }); + + try { + await hooksRemove({}); + } finally { + restoreHome(); + } + + expect(mockedRemoveHooks).toHaveBeenCalledTimes(6); + expect(mockedRemoveHooks).toHaveBeenCalledWith( + path.join('/path/to/project', '.claude/settings.json'), + 'claude', + ); + expect(mockedRemoveHooks).toHaveBeenCalledWith( + path.join('/path/to/project', '.claude-internal/settings.json'), + 'claude-internal', + ); + expect(mockedRemoveHooks).toHaveBeenCalledWith( + path.join('/path/to/project', '.cursor/hooks.json'), + 'cursor', + ); + expect(mockedRemoveHooks).toHaveBeenCalledWith( + path.join('/home/testuser', '.claude/settings.json'), + 'claude', + ); + expect(mockedRemoveHooks).toHaveBeenCalledWith( + path.join('/home/testuser', '.claude-internal/settings.json'), + 'claude-internal', + ); + expect(mockedRemoveHooks).toHaveBeenCalledWith( + path.join('/home/testuser', '.cursor/hooks.json'), + 'cursor', + ); + }); + + it('should not duplicate hook operations when HOME equals projectRoot', async () => { + const restoreHome = mockHome('/path/to/project'); + const projectConfig = { + ...mockLocalConfig, + scope: 'project', + projectRoot: '/path/to/project', + }; + mockedAutoDetectInit.mockResolvedValue({ localConfig: projectConfig, teamConfig: mockTeamConfig }); + + try { + await hooksRemove({}); + } finally { + restoreHome(); + } + + expect(mockedRemoveHooks).toHaveBeenCalledTimes(3); + }); + it('should propagate error when not initialized', async () => { mockedAutoDetectInit.mockRejectedValue(new Error('teamai is not initialized')); diff --git a/src/hooks-cmd.ts b/src/hooks-cmd.ts index 2a1876d..a661cae 100644 --- a/src/hooks-cmd.ts +++ b/src/hooks-cmd.ts @@ -2,9 +2,39 @@ import path from 'node:path'; import { autoDetectInit } from './config.js'; import { injectHooksToAllTools, removeHooks } from './hooks.js'; import { log } from './utils/logger.js'; -import type { GlobalOptions } from './types.js'; +import type { GlobalOptions, LocalConfig } from './types.js'; import { resolveBaseDir } from './types.js'; +function resolveHookBaseDirs(localConfig: LocalConfig): string[] { + const baseDir = resolveBaseDir(localConfig) ?? ''; + if (localConfig.scope !== 'project') { + return [baseDir]; + } + + const userBaseDir = process.env.HOME ?? ''; + if (!userBaseDir || userBaseDir === baseDir) { + return [baseDir]; + } + + return [baseDir, userBaseDir]; +} + +async function removeHooksFromAllTools( + toolPaths: Record, + baseDir: string, +): Promise { + for (const [tool, paths] of Object.entries(toolPaths)) { + if (paths.settings) { + const settingsPath = path.join(baseDir, paths.settings); + try { + await removeHooks(settingsPath, tool); + } catch (e) { + log.warn(`Failed to remove hooks from ${tool}: ${(e as Error).message}`); + } + } + } +} + /** * Handler for `teamai hooks inject`. * Loads config and injects teamai hooks into all configured AI tool settings. @@ -12,13 +42,8 @@ import { resolveBaseDir } from './types.js'; export async function hooksInject(options: GlobalOptions): Promise { const { localConfig, teamConfig } = await autoDetectInit(); - const baseDir = resolveBaseDir(localConfig); - await injectHooksToAllTools(teamConfig.toolPaths, baseDir); - - // Project scope: also inject into user scope (~/) so hooks work from subdirectories - if (localConfig.scope === 'project') { - const userBaseDir = process.env.HOME ?? ''; - await injectHooksToAllTools(teamConfig.toolPaths, userBaseDir); + for (const baseDir of resolveHookBaseDirs(localConfig)) { + await injectHooksToAllTools(teamConfig.toolPaths, baseDir); } if (!options.silent) { @@ -33,16 +58,8 @@ export async function hooksInject(options: GlobalOptions): Promise { export async function hooksRemove(_options: GlobalOptions): Promise { const { localConfig, teamConfig } = await autoDetectInit(); - const baseDir = resolveBaseDir(localConfig); - for (const [tool, paths] of Object.entries(teamConfig.toolPaths)) { - if (paths.settings) { - const settingsPath = path.join(baseDir, paths.settings); - try { - await removeHooks(settingsPath, tool); - } catch (e) { - log.warn(`Failed to remove hooks from ${tool}: ${(e as Error).message}`); - } - } + for (const baseDir of resolveHookBaseDirs(localConfig)) { + await removeHooksFromAllTools(teamConfig.toolPaths, baseDir); } log.success('Hooks removed from all AI tool settings');