Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 88 additions & 3 deletions src/__tests__/hooks-cmd.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import path from 'node:path';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';

// ── Mocks ────────────────────────────────────────────────
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -92,20 +105,32 @@ 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',
projectRoot: '/path/to/project',
};
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',
);
});
});

Expand Down Expand Up @@ -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'));

Expand Down
53 changes: 35 additions & 18 deletions src/hooks-cmd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,48 @@ 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<string, { settings?: string }>,
baseDir: string,
): Promise<void> {
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.
*/
export async function hooksInject(options: GlobalOptions): Promise<void> {
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) {
Expand All @@ -33,16 +58,8 @@ export async function hooksInject(options: GlobalOptions): Promise<void> {
export async function hooksRemove(_options: GlobalOptions): Promise<void> {
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');
Expand Down
Loading