fix(websearch): use getCcsDir() for test isolation

Ensure all hook-related modules use getCcsDir() from environment.ts
for consistent test isolation. Prevents tests from touching the user's
real ~/.ccs/ directory during test runs.

Changes:
- hook-config.ts: Use getCcsDir() for hookConfigPath
- hook-installer.ts: Use getCcsDir() for HOOK_SOURCE_PATH
- profile-hook-injector.ts: Use getCcsDir() for hook sources
- CLAUDE.md: Add test isolation rules
This commit is contained in:
kaitranntt
2026-01-25 21:03:01 -05:00
parent ce59eb6269
commit b33674b3b2
4 changed files with 71 additions and 21 deletions
+13
View File
@@ -2,6 +2,19 @@
AI-facing guidance for Claude Code when working with this repository. AI-facing guidance for Claude Code when working with this repository.
## Critical Constraints (NEVER VIOLATE)
### Test Isolation (MANDATORY)
**NEVER touch the user's real `~/.ccs/` or `~/.claude/` directories during tests.**
- All code accessing CCS paths MUST use `getCcsDir()` from `src/utils/config-manager.ts`
- This function respects `CCS_HOME` env var for test isolation
- **WRONG:** `path.join(os.homedir(), '.ccs', ...)`
- **CORRECT:** `path.join(getCcsDir(), ...)`
Tests set `process.env.CCS_HOME` to a temp directory. Code using `os.homedir()` directly will modify the user's real files.
## Core Function ## Core Function
CLI wrapper for instant switching between multiple Claude accounts and alternative models (GLM, GLMT, Kimi). See README.md for user documentation. CLI wrapper for instant switching between multiple Claude accounts and alternative models (GLM, GLMT, Kimi). See README.md for user documentation.
+10 -5
View File
@@ -11,16 +11,21 @@ import * as path from 'path';
import * as os from 'os'; import * as os from 'os';
import { info, warn } from '../ui'; import { info, warn } from '../ui';
import { getWebSearchConfig } from '../../config/unified-config-loader'; import { getWebSearchConfig } from '../../config/unified-config-loader';
import { getCcsDir } from '../config-manager';
// Path to Claude settings.json // Path to Claude settings.json (intentionally uses real homedir for global settings)
const CLAUDE_SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json'); const CLAUDE_SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json');
// CCS hooks directory
const CCS_HOOKS_DIR = path.join(os.homedir(), '.ccs', 'hooks');
// Hook file name // Hook file name
const WEBSEARCH_HOOK = 'websearch-transformer.cjs'; const WEBSEARCH_HOOK = 'websearch-transformer.cjs';
/**
* Get CCS hooks directory (respects CCS_HOME for test isolation)
*/
function getCcsHooksDir(): string {
return path.join(getCcsDir(), 'hooks');
}
// Buffer time added to max provider timeout for hook timeout (seconds) // Buffer time added to max provider timeout for hook timeout (seconds)
const HOOK_TIMEOUT_BUFFER = 30; const HOOK_TIMEOUT_BUFFER = 30;
@@ -31,7 +36,7 @@ const MIN_HOOK_TIMEOUT = 60;
* Get path to WebSearch hook * Get path to WebSearch hook
*/ */
export function getHookPath(): string { export function getHookPath(): string {
return path.join(CCS_HOOKS_DIR, WEBSEARCH_HOOK); return path.join(getCcsHooksDir(), WEBSEARCH_HOOK);
} }
/** /**
+11 -6
View File
@@ -8,21 +8,25 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import * as os from 'os';
import { info, warn } from '../ui'; import { info, warn } from '../ui';
import { getWebSearchConfig } from '../../config/unified-config-loader'; import { getWebSearchConfig } from '../../config/unified-config-loader';
import { getHookPath, ensureHookConfig } from './hook-config'; import { getHookPath, ensureHookConfig } from './hook-config';
import { removeMigrationMarker } from './profile-hook-injector'; import { removeMigrationMarker } from './profile-hook-injector';
import { getCcsDir } from '../config-manager';
// Re-export from hook-config for backward compatibility // Re-export from hook-config for backward compatibility
export { getHookPath, getWebSearchHookConfig } from './hook-config'; export { getHookPath, getWebSearchHookConfig } from './hook-config';
// CCS hooks directory
const CCS_HOOKS_DIR = path.join(os.homedir(), '.ccs', 'hooks');
// Hook file name // Hook file name
const WEBSEARCH_HOOK = 'websearch-transformer.cjs'; const WEBSEARCH_HOOK = 'websearch-transformer.cjs';
/**
* Get CCS hooks directory (respects CCS_HOME for test isolation)
*/
function getCcsHooksDir(): string {
return path.join(getCcsDir(), 'hooks');
}
/** /**
* Check if WebSearch hook is installed * Check if WebSearch hook is installed
*/ */
@@ -50,8 +54,9 @@ export function installWebSearchHook(): boolean {
} }
// Ensure hooks directory exists // Ensure hooks directory exists
if (!fs.existsSync(CCS_HOOKS_DIR)) { const hooksDir = getCcsHooksDir();
fs.mkdirSync(CCS_HOOKS_DIR, { recursive: true, mode: 0o700 }); if (!fs.existsSync(hooksDir)) {
fs.mkdirSync(hooksDir, { recursive: true, mode: 0o700 });
} }
const hookPath = getHookPath(); const hookPath = getHookPath();
+37 -10
View File
@@ -9,17 +9,21 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import * as os from 'os';
import { info, warn } from '../ui'; import { info, warn } from '../ui';
import { getWebSearchHookConfig, getHookPath } from './hook-config'; import { getWebSearchHookConfig, getHookPath } from './hook-config';
import { getWebSearchConfig } from '../../config/unified-config-loader'; import { getWebSearchConfig } from '../../config/unified-config-loader';
import { removeHookConfig } from './hook-config'; import { removeHookConfig } from './hook-config';
import { getCcsDir } from '../config-manager';
// CCS directory // Valid profile name pattern (alphanumeric, dash, underscore only)
const CCS_DIR = path.join(os.homedir(), '.ccs'); const VALID_PROFILE_NAME = /^[a-zA-Z0-9_-]+$/;
// Migration marker file /**
const MIGRATION_MARKER = path.join(CCS_DIR, '.hook-migrated'); * Get migration marker path (respects CCS_HOME for test isolation)
*/
function getMigrationMarkerPath(): string {
return path.join(getCcsDir(), '.hook-migrated');
}
/** /**
* Check if CCS WebSearch hook exists in settings * Check if CCS WebSearch hook exists in settings
@@ -44,7 +48,8 @@ function hasCcsHook(settings: Record<string, unknown>): boolean {
* Migrate CCS hook from global settings to profile settings (one-time) * Migrate CCS hook from global settings to profile settings (one-time)
*/ */
function migrateGlobalHook(): void { function migrateGlobalHook(): void {
if (fs.existsSync(MIGRATION_MARKER)) { const markerPath = getMigrationMarkerPath();
if (fs.existsSync(markerPath)) {
return; // Already migrated return; // Already migrated
} }
@@ -53,8 +58,13 @@ function migrateGlobalHook(): void {
if (removed && process.env.CCS_DEBUG) { if (removed && process.env.CCS_DEBUG) {
console.error(info('Migrated WebSearch hook from global settings')); console.error(info('Migrated WebSearch hook from global settings'));
} }
// Ensure CCS dir exists before creating marker
const ccsDir = getCcsDir();
if (!fs.existsSync(ccsDir)) {
fs.mkdirSync(ccsDir, { recursive: true, mode: 0o700 });
}
// Create marker file // Create marker file
fs.writeFileSync(MIGRATION_MARKER, new Date().toISOString(), 'utf8'); fs.writeFileSync(markerPath, new Date().toISOString(), 'utf8');
} catch (error) { } catch (error) {
if (process.env.CCS_DEBUG) { if (process.env.CCS_DEBUG) {
console.error(warn(`Migration failed: ${(error as Error).message}`)); console.error(warn(`Migration failed: ${(error as Error).message}`));
@@ -70,6 +80,14 @@ function migrateGlobalHook(): void {
*/ */
export function ensureProfileHooks(profileName: string): boolean { export function ensureProfileHooks(profileName: string): boolean {
try { try {
// Validate profile name to prevent path traversal
if (!VALID_PROFILE_NAME.test(profileName)) {
if (process.env.CCS_DEBUG) {
console.error(warn(`Invalid profile name: ${profileName}`));
}
return false;
}
const wsConfig = getWebSearchConfig(); const wsConfig = getWebSearchConfig();
// Skip if WebSearch is disabled // Skip if WebSearch is disabled
@@ -80,7 +98,15 @@ export function ensureProfileHooks(profileName: string): boolean {
// One-time migration from global settings // One-time migration from global settings
migrateGlobalHook(); migrateGlobalHook();
const settingsPath = path.join(CCS_DIR, `${profileName}.settings.json`); // Get CCS directory (respects CCS_HOME for test isolation)
const ccsDir = getCcsDir();
// Ensure CCS dir exists
if (!fs.existsSync(ccsDir)) {
fs.mkdirSync(ccsDir, { recursive: true, mode: 0o700 });
}
const settingsPath = path.join(ccsDir, `${profileName}.settings.json`);
// Read existing settings or create empty // Read existing settings or create empty
let settings: Record<string, unknown> = {}; let settings: Record<string, unknown> = {};
@@ -193,8 +219,9 @@ function updateHookTimeoutIfNeeded(
*/ */
export function removeMigrationMarker(): void { export function removeMigrationMarker(): void {
try { try {
if (fs.existsSync(MIGRATION_MARKER)) { const markerPath = getMigrationMarkerPath();
fs.unlinkSync(MIGRATION_MARKER); if (fs.existsSync(markerPath)) {
fs.unlinkSync(markerPath);
} }
} catch { } catch {
// Ignore errors // Ignore errors