Merge pull request #371 from kaitranntt/kai/fix/uninstall-hooks-cleanup

fix: uninstall hooks cleanup + test isolation
This commit is contained in:
Kai (Tam Nhu) Tran
2026-01-25 21:53:00 -05:00
committed by GitHub
29 changed files with 677 additions and 59 deletions
+13
View File
@@ -2,6 +2,19 @@
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
CLI wrapper for instant switching between multiple Claude accounts and alternative models (GLM, GLMT, Kimi). See README.md for user documentation.
+1
View File
@@ -51,6 +51,7 @@
"preferGlobal": true,
"scripts": {
"preinstall": "node scripts/preinstall.js",
"postuninstall": "node scripts/postuninstall.js",
"build": "tsc && node scripts/add-shebang.js",
"build:watch": "tsc --watch",
"build:server": "tsc && node scripts/add-shebang.js",
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env node
/**
* CCS Postuninstall Script
*
* Cleans up CCS-specific files after npm uninstall.
* Does NOT touch global ~/.claude/settings.json (hooks are per-profile now).
*
* Self-contained, no external dependencies.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
// Note: Uses os.homedir() directly because this script runs during npm uninstall,
// not in test context. CCS_HOME isolation is for src/ code only.
const CCS_DIR = path.join(os.homedir(), '.ccs');
const HOOKS_DIR = path.join(CCS_DIR, 'hooks');
const MIGRATION_MARKER = path.join(CCS_DIR, '.hook-migrated');
function cleanupCcsFiles() {
try {
// Remove WebSearch hook file
const hookPath = path.join(HOOKS_DIR, 'websearch-transformer.cjs');
if (fs.existsSync(hookPath)) {
fs.unlinkSync(hookPath);
}
// Remove migration marker (so fresh install re-runs migration)
if (fs.existsSync(MIGRATION_MARKER)) {
fs.unlinkSync(MIGRATION_MARKER);
}
// Note: Do NOT touch ~/.claude/settings.json
// Per-profile hooks in ~/.ccs/*.settings.json will be cleaned up
// when the user removes ~/.ccs/ directory.
} catch {
// Silent fail - not critical
}
}
cleanupCcsFiles();
+9 -2
View File
@@ -3,7 +3,6 @@
* Supports both unified YAML config and legacy JSON config.
*/
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { getCcsDir, getConfigPath, loadConfigSafe } from '../../utils/config-manager';
import { expandPath } from '../../utils/helpers';
@@ -12,6 +11,7 @@ import {
saveUnifiedConfig,
isUnifiedMode,
} from '../../config/unified-config-loader';
import { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector';
import type { ModelMapping, CreateApiProfileResult, RemoveApiProfileResult } from './profile-types';
/** Check if URL is an OpenRouter endpoint */
@@ -43,6 +43,10 @@ function createSettingsFile(
};
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
// Inject WebSearch hooks into profile settings
ensureProfileHooks(name);
return settingsPath;
}
@@ -78,7 +82,7 @@ function createApiProfileUnified(
apiKey: string,
models: ModelMapping
): void {
const ccsDir = path.join(os.homedir(), '.ccs');
const ccsDir = getCcsDir();
const settingsFile = `${name}.settings.json`;
const settingsPath = path.join(ccsDir, settingsFile);
@@ -101,6 +105,9 @@ function createApiProfileUnified(
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
// Inject WebSearch hooks into profile settings
ensureProfileHooks(name);
const config = loadOrCreateUnifiedConfig();
config.profiles[name] = {
type: 'api',
+4 -3
View File
@@ -11,11 +11,11 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { findSimilarStrings, expandPath } from '../utils/helpers';
import { Config, Settings, ProfileMetadata } from '../types';
import { UnifiedConfig, CopilotConfig } from '../config/unified-config-types';
import { loadUnifiedConfig, isUnifiedMode } from '../config/unified-config-loader';
import { getCcsDir } from '../utils/config-manager';
export type ProfileType = 'settings' | 'account' | 'cliproxy' | 'copilot' | 'default';
@@ -84,8 +84,9 @@ class ProfileDetector {
private readonly profilesPath: string;
constructor() {
this.configPath = path.join(os.homedir(), '.ccs', 'config.json');
this.profilesPath = path.join(os.homedir(), '.ccs', 'profiles.json');
const ccsDir = getCcsDir();
this.configPath = path.join(ccsDir, 'config.json');
this.profilesPath = path.join(ccsDir, 'profiles.json');
}
/**
+2 -2
View File
@@ -1,12 +1,12 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { ProfileMetadata } from '../types';
import {
loadOrCreateUnifiedConfig,
saveUnifiedConfig,
isUnifiedMode,
} from '../config/unified-config-loader';
import { getCcsDir } from '../utils/config-manager';
/**
* Profile Registry (Simplified)
@@ -43,7 +43,7 @@ export class ProfileRegistry {
private profilesPath: string;
constructor() {
this.profilesPath = path.join(os.homedir(), '.ccs', 'profiles.json');
this.profilesPath = path.join(getCcsDir(), 'profiles.json');
}
/**
+10
View File
@@ -11,6 +11,7 @@ import {
installWebSearchHook,
displayWebSearchStatus,
getWebSearchHookEnv,
ensureProfileHooks,
} from './utils/websearch-manager';
import { getGlobalEnvConfig } from './config/unified-config-loader';
import { fail, info } from './utils/ui';
@@ -518,6 +519,9 @@ async function main(): Promise<void> {
if (profileInfo.type === 'cliproxy') {
// CLIPROXY FLOW: OAuth-based profiles (gemini, codex, agy, qwen) or user-defined variants
// Inject WebSearch hook into profile settings before launch
ensureProfileHooks(profileInfo.name);
const provider = profileInfo.provider || (profileInfo.name as CLIProxyProvider);
const customSettingsPath = profileInfo.settingsPath; // undefined for hardcoded profiles
const variantPort = profileInfo.port; // variant-specific port for isolation
@@ -527,6 +531,9 @@ async function main(): Promise<void> {
});
} else if (profileInfo.type === 'copilot') {
// COPILOT FLOW: GitHub Copilot subscription via copilot-api proxy
// Inject WebSearch hook into profile settings before launch
ensureProfileHooks(profileInfo.name);
const { executeCopilotProfile } = await import('./copilot');
const copilotConfig = profileInfo.copilotConfig;
if (!copilotConfig) {
@@ -538,6 +545,9 @@ async function main(): Promise<void> {
} else if (profileInfo.type === 'settings') {
// Settings-based profiles (glm, glmt, kimi) are third-party providers
// WebSearch is server-side tool - third-party providers have no access
// Inject WebSearch hook into profile settings before launch
ensureProfileHooks(profileInfo.name);
ensureMcpWebSearch();
installWebSearchHook();
@@ -13,6 +13,7 @@ import { getCcsDir } from '../../utils/config-manager';
import { expandPath } from '../../utils/helpers';
import { getClaudeEnvVars, CLIPROXY_DEFAULT_PORT } from '../config-generator';
import { CLIProxyProvider } from '../types';
import { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector';
/** Environment settings structure */
interface SettingsEnv {
@@ -105,6 +106,9 @@ export function createSettingsFile(
ensureDir(ccsDir);
writeSettings(settingsPath, settings);
// Inject WebSearch hooks into variant settings
ensureProfileHooks(`${provider}-${name}`);
return settingsPath;
}
@@ -127,6 +131,9 @@ export function createSettingsFileUnified(
ensureDir(ccsDir);
writeSettings(settingsPath, settings);
// Inject WebSearch hooks into variant settings
ensureProfileHooks(`${provider}-${name}`);
return settingsPath;
}
+31 -5
View File
@@ -4,7 +4,9 @@
* Handle --install and --uninstall commands for CCS.
*/
import { info, color, initUI } from '../utils/ui';
import { info, ok, color, box, initUI } from '../utils/ui';
import { uninstallWebSearchHook } from '../utils/websearch';
import { ClaudeSymlinkManager } from '../utils/claude-symlink-manager';
/**
* Handle install command
@@ -28,12 +30,36 @@ export async function handleInstallCommand(): Promise<void> {
export async function handleUninstallCommand(): Promise<void> {
await initUI();
console.log('');
console.log(info('Feature not available'));
console.log(box('Uninstalling CCS', { borderColor: 'cyan' }));
console.log('');
console.log('The --uninstall flag is currently under development.');
console.log('.claude/ integration testing is not complete.');
let removed = 0;
// 1. Remove WebSearch hook file + migration marker (does NOT touch global settings.json)
const hookRemoved = uninstallWebSearchHook();
if (hookRemoved) {
console.log(ok('Removed WebSearch hook'));
removed++;
}
// 2. Remove symlinks from ~/.claude/
const symlinkManager = new ClaudeSymlinkManager();
const symlinksRemoved = symlinkManager.uninstall();
if (symlinksRemoved > 0) {
removed++;
}
// 3. Summary
console.log('');
console.log(`For updates: ${color('https://github.com/kaitranntt/ccs/issues', 'path')}`);
if (removed > 0) {
console.log(ok('Uninstall complete!'));
console.log('');
console.log(info('~/.ccs/ directory preserved'));
console.log(info('To reinstall: ccs --install'));
} else {
console.log(info('Nothing to uninstall'));
}
console.log('');
process.exit(0);
}
+2 -2
View File
@@ -7,7 +7,6 @@
import { spawn } from 'child_process';
import * as path from 'path';
import * as os from 'os';
import * as fs from 'fs';
import { SessionManager } from './session-manager';
import { SettingsParser } from './settings-parser';
@@ -15,6 +14,7 @@ import { ui, warn, info } from '../utils/ui';
import { type ExecutionOptions, type ExecutionResult, type StreamMessage } from './executor/types';
import { StreamBuffer, formatToolVerbose } from './executor/stream-parser';
import { buildExecutionResult } from './executor/result-aggregator';
import { getCcsDir } from '../utils/config-manager';
// Re-export types for consumers
export type { ExecutionOptions, ExecutionResult, StreamMessage } from './executor/types';
@@ -63,7 +63,7 @@ export class HeadlessExecutor {
}
// Get settings path for profile
const settingsPath = path.join(os.homedir(), '.ccs', `${profile}.settings.json`);
const settingsPath = path.join(getCcsDir(), `${profile}.settings.json`);
// Validate settings file exists
if (!fs.existsSync(settingsPath)) {
+2 -2
View File
@@ -6,7 +6,7 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { getCcsDir } from '../utils/config-manager';
interface SessionData {
sessionId: string;
@@ -39,7 +39,7 @@ class SessionManager {
private readonly sessionsPath: string;
constructor() {
this.sessionsPath = path.join(os.homedir(), '.ccs', 'delegation-sessions.json');
this.sessionsPath = path.join(getCcsDir(), 'delegation-sessions.json');
}
/**
+2 -2
View File
@@ -10,8 +10,8 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { DeltaAccumulator } from './delta-accumulator';
import { getCcsDir } from '../utils/config-manager';
import {
RequestTransformer,
StreamParser,
@@ -48,7 +48,7 @@ export class GlmtTransformer {
const debugEnabled = process.env.CCS_DEBUG === '1';
this.debugLog = config.debugLog ?? debugEnabled;
this.debugLogDir = config.debugLogDir || path.join(os.homedir(), '.ccs', 'logs');
this.debugLogDir = config.debugLogDir || path.join(getCcsDir(), 'logs');
// Initialize pipeline components
this.requestTransformer = new RequestTransformer({
+2 -1
View File
@@ -7,6 +7,7 @@ import * as path from 'path';
import * as os from 'os';
import { ok, fail, warn, info } from '../../utils/ui';
import { HealthCheck, IHealthChecker, createSpinner } from './types';
import { getCcsDir } from '../../utils/config-manager';
const ora = createSpinner();
@@ -20,7 +21,7 @@ export class ConfigFilesChecker implements IHealthChecker {
private readonly ccsDir: string;
constructor() {
this.ccsDir = path.join(os.homedir(), '.ccs');
this.ccsDir = getCcsDir();
}
run(results: HealthCheck): void {
+4 -4
View File
@@ -4,9 +4,9 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { ok, fail, warn, info } from '../../utils/ui';
import { HealthCheck, IHealthChecker, createSpinner } from './types';
import { getCcsDir } from '../../utils/config-manager';
const ora = createSpinner();
@@ -18,7 +18,7 @@ export class ProfilesChecker implements IHealthChecker {
private readonly ccsDir: string;
constructor() {
this.ccsDir = path.join(os.homedir(), '.ccs');
this.ccsDir = getCcsDir();
}
run(results: HealthCheck): void {
@@ -130,7 +130,7 @@ export class InstancesChecker implements IHealthChecker {
private readonly ccsDir: string;
constructor() {
this.ccsDir = path.join(os.homedir(), '.ccs');
this.ccsDir = getCcsDir();
}
run(results: HealthCheck): void {
@@ -169,7 +169,7 @@ export class DelegationChecker implements IHealthChecker {
private readonly ccsDir: string;
constructor() {
this.ccsDir = path.join(os.homedir(), '.ccs');
this.ccsDir = getCcsDir();
}
run(results: HealthCheck): void {
+17 -4
View File
@@ -7,11 +7,22 @@ import * as path from 'path';
import * as os from 'os';
import { ok, fail, warn } from '../../utils/ui';
import { HealthCheck, IHealthChecker, createSpinner } from './types';
import { getCcsDir } from '../../utils/config-manager';
const ora = createSpinner();
const homedir = os.homedir();
const ccsDir = path.join(homedir, '.ccs');
const claudeDir = path.join(homedir, '.claude');
// Get paths at runtime to respect CCS_HOME for test isolation
function getHomedir(): string {
return os.homedir();
}
function getCcsDirPath(): string {
return getCcsDir();
}
function getClaudeDir(): string {
return path.join(getHomedir(), '.claude');
}
/**
* Check file permissions on ~/.ccs/
@@ -21,7 +32,7 @@ export class PermissionsChecker implements IHealthChecker {
run(results: HealthCheck): void {
const spinner = ora('Checking permissions').start();
const testFile = path.join(ccsDir, '.permission-test');
const testFile = path.join(getCcsDirPath(), '.permission-test');
try {
fs.writeFileSync(testFile, 'test', 'utf8');
@@ -113,6 +124,8 @@ export class SettingsSymlinksChecker implements IHealthChecker {
run(results: HealthCheck): void {
const spinner = ora('Checking settings.json symlinks').start();
const label = 'settings.json';
const ccsDir = getCcsDirPath();
const claudeDir = getClaudeDir();
const sharedSettings = path.join(ccsDir, 'shared', 'settings.json');
const claudeSettings = path.join(claudeDir, 'settings.json');
+2 -3
View File
@@ -3,13 +3,12 @@
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { spawn } from 'child_process';
import { getClaudeCliInfo } from '../../utils/claude-detector';
import { escapeShellArg } from '../../utils/shell-executor';
import { ok, fail } from '../../utils/ui';
import { HealthCheck, IHealthChecker, createSpinner } from './types';
import { getCcsDir } from '../../utils/config-manager';
const ora = createSpinner();
@@ -99,7 +98,7 @@ export class CcsDirectoryChecker implements IHealthChecker {
private readonly ccsDir: string;
constructor() {
this.ccsDir = path.join(os.homedir(), '.ccs');
this.ccsDir = getCcsDir();
}
run(results: HealthCheck): void {
+2 -2
View File
@@ -8,8 +8,8 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import SharedManager from './shared-manager';
import { getCcsDir } from '../utils/config-manager';
/**
* Instance Manager Class
@@ -19,7 +19,7 @@ class InstanceManager {
private readonly sharedManager: SharedManager;
constructor() {
this.instancesDir = path.join(os.homedir(), '.ccs', 'instances');
this.instancesDir = path.join(getCcsDir(), 'instances');
this.sharedManager = new SharedManager();
}
+4 -2
View File
@@ -10,6 +10,7 @@ import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { ok, info, warn } from '../utils/ui';
import { getCcsDir } from '../utils/config-manager';
interface SharedItem {
name: string;
@@ -28,9 +29,10 @@ class SharedManager {
constructor() {
this.homeDir = os.homedir();
this.sharedDir = path.join(this.homeDir, '.ccs', 'shared');
const ccsDir = getCcsDir();
this.sharedDir = path.join(ccsDir, 'shared');
this.claudeDir = path.join(this.homeDir, '.claude');
this.instancesDir = path.join(this.homeDir, '.ccs', 'instances');
this.instancesDir = path.join(ccsDir, 'instances');
this.sharedItems = [
{ name: 'commands', type: 'directory' },
{ name: 'skills', type: 'directory' },
+3 -3
View File
@@ -5,8 +5,8 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { ok, fail, warn, info } from './ui';
import { getCcsDir, getCcsHome } from './config-manager';
// Ora fallback type for when ora is not available
interface OraSpinner {
@@ -60,8 +60,8 @@ export class ClaudeDirInstaller {
private ccsClaudeDir: string;
constructor() {
this.homeDir = os.homedir();
this.ccsClaudeDir = path.join(this.homeDir, '.ccs', '.claude');
this.homeDir = getCcsHome();
this.ccsClaudeDir = path.join(getCcsDir(), '.claude');
}
/**
+8 -4
View File
@@ -14,8 +14,8 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { ok, fail, warn, info, color } from './ui';
import { getCcsDir, getCcsHome } from './config-manager';
// Ora fallback type for when ora is not available
interface OraSpinner {
@@ -71,8 +71,9 @@ export class ClaudeSymlinkManager {
private ccsItems: CcsItem[];
constructor() {
this.homeDir = os.homedir();
this.ccsClaudeDir = path.join(this.homeDir, '.ccs', '.claude');
// Use getCcsHome() for test isolation - respects CCS_HOME env var
this.homeDir = getCcsHome();
this.ccsClaudeDir = path.join(getCcsDir(), '.claude');
this.userClaudeDir = path.join(this.homeDir, '.claude');
// CCS items to symlink (selective, item-level)
@@ -279,8 +280,9 @@ export class ClaudeSymlinkManager {
/**
* Uninstall CCS items from ~/.claude/ (remove symlinks or copied files)
* Safe: only removes items that are CCS symlinks or valid copies
* @returns number of items removed
*/
uninstall(): void {
uninstall(): number {
let removed = 0;
for (const item of this.ccsItems) {
@@ -315,6 +317,8 @@ export class ClaudeSymlinkManager {
} else {
console.log(info('No delegation commands or skills to remove'));
}
return removed;
}
/**
+2 -2
View File
@@ -4,10 +4,10 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import * as https from 'https';
import { getCcsDir } from './config-manager';
const CACHE_DIR = path.join(os.homedir(), '.ccs', 'cache');
const CACHE_DIR = path.join(getCcsDir(), 'cache');
const UPDATE_CHECK_FILE = path.join(CACHE_DIR, 'update-check.json');
const CHECK_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours
const GITHUB_API_URL = 'https://api.github.com/repos/kaitranntt/ccs/releases/latest';
+3
View File
@@ -60,6 +60,9 @@ export {
displayWebSearchStatus,
} from './websearch/status';
// Re-export profile hook injection
export { ensureProfileHooks, removeMigrationMarker } from './websearch/profile-hook-injector';
// Import for local use
import { clearGeminiCliCache, clearGrokCliCache, clearOpenCodeCliCache } from './websearch';
+73 -5
View File
@@ -11,16 +11,21 @@ import * as path from 'path';
import * as os from 'os';
import { info, warn } from '../ui';
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');
// CCS hooks directory
const CCS_HOOKS_DIR = path.join(os.homedir(), '.ccs', 'hooks');
// Hook file name
const WEBSEARCH_HOOK = 'websearch-transformer.cjs';
/**
* Get CCS hooks directory (respects CCS_HOME for test isolation)
*/
export function getCcsHooksDir(): string {
return path.join(getCcsDir(), 'hooks');
}
// Buffer time added to max provider timeout for hook timeout (seconds)
const HOOK_TIMEOUT_BUFFER = 30;
@@ -31,7 +36,7 @@ const MIN_HOOK_TIMEOUT = 60;
* Get path to WebSearch hook
*/
export function getHookPath(): string {
return path.join(CCS_HOOKS_DIR, WEBSEARCH_HOOK);
return path.join(getCcsHooksDir(), WEBSEARCH_HOOK);
}
/**
@@ -178,3 +183,66 @@ export function ensureHookConfig(): boolean {
return false;
}
}
/**
* Remove CCS WebSearch hook from ~/.claude/settings.json
* Only removes hooks matching: matcher='WebSearch' AND command contains '.ccs/hooks/websearch-transformer'
* Preserves user-defined WebSearch hooks
*/
export function removeHookConfig(): boolean {
try {
if (!fs.existsSync(CLAUDE_SETTINGS_PATH)) {
return true; // Nothing to remove
}
const content = fs.readFileSync(CLAUDE_SETTINGS_PATH, 'utf8');
let settings: Record<string, unknown>;
try {
settings = JSON.parse(content);
} catch {
return false; // Malformed JSON, don't touch
}
const hooks = settings.hooks as Record<string, unknown[]> | undefined;
if (!hooks?.PreToolUse) {
return true; // No hooks to remove
}
const originalLength = hooks.PreToolUse.length;
hooks.PreToolUse = hooks.PreToolUse.filter((h: unknown) => {
const hook = h as Record<string, unknown>;
if (hook.matcher !== 'WebSearch') return true; // Keep non-WebSearch hooks
const hookArray = hook.hooks as Array<Record<string, unknown>> | undefined;
if (!hookArray?.[0]?.command) return true; // Keep malformed entries
const command = hookArray[0].command as string;
return !command.includes('.ccs/hooks/websearch-transformer'); // Remove if CCS hook
});
if (hooks.PreToolUse.length === originalLength) {
return true; // Nothing changed
}
// Clean up empty hooks object
if (hooks.PreToolUse.length === 0) {
delete hooks.PreToolUse;
}
if (Object.keys(hooks).length === 0) {
delete settings.hooks;
}
fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8');
if (process.env.CCS_DEBUG) {
console.error(info('Removed WebSearch hook from settings.json'));
}
return true;
} catch (error) {
if (process.env.CCS_DEBUG) {
console.error(warn(`Failed to remove hook config: ${(error as Error).message}`));
}
return false;
}
}
+13 -8
View File
@@ -8,17 +8,14 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { info, warn } from '../ui';
import { getWebSearchConfig } from '../../config/unified-config-loader';
import { getHookPath, ensureHookConfig } from './hook-config';
import { getHookPath, ensureHookConfig, getCcsHooksDir } from './hook-config';
import { removeMigrationMarker } from './profile-hook-injector';
// Re-export from hook-config for backward compatibility
export { getHookPath, getWebSearchHookConfig } from './hook-config';
// CCS hooks directory
const CCS_HOOKS_DIR = path.join(os.homedir(), '.ccs', 'hooks');
// Hook file name
const WEBSEARCH_HOOK = 'websearch-transformer.cjs';
@@ -49,8 +46,9 @@ export function installWebSearchHook(): boolean {
}
// Ensure hooks directory exists
if (!fs.existsSync(CCS_HOOKS_DIR)) {
fs.mkdirSync(CCS_HOOKS_DIR, { recursive: true, mode: 0o700 });
const hooksDir = getCcsHooksDir();
if (!fs.existsSync(hooksDir)) {
fs.mkdirSync(hooksDir, { recursive: true, mode: 0o700 });
}
const hookPath = getHookPath();
@@ -102,6 +100,9 @@ export function installWebSearchHook(): boolean {
/**
* Uninstall WebSearch hook from ~/.ccs/hooks/
*
* Note: Does NOT touch global ~/.claude/settings.json.
* Profile-specific hooks are removed when ~/.ccs/ is deleted.
*
* @returns true if hook uninstalled successfully
*/
export function uninstallWebSearchHook(): boolean {
@@ -115,7 +116,11 @@ export function uninstallWebSearchHook(): boolean {
}
}
// TODO: Optionally remove from settings.json
// Remove migration marker (so fresh install re-runs migration)
removeMigrationMarker();
// Note: Do NOT call removeHookConfig() - global settings should not be touched.
// Per-profile hooks in ~/.ccs/*.settings.json are cleaned up when ~/.ccs/ is deleted.
return true;
} catch (error) {
+6
View File
@@ -41,6 +41,9 @@ export {
uninstallWebSearchHook,
} from './hook-installer';
// Hook Config (removal)
export { removeHookConfig } from './hook-config';
// Hook Environment
export { getWebSearchHookEnv } from './hook-env';
@@ -52,3 +55,6 @@ export {
getWebSearchReadiness,
displayWebSearchStatus,
} from './status';
// Profile Hook Injection
export { ensureProfileHooks, removeMigrationMarker } from './profile-hook-injector';
@@ -0,0 +1,234 @@
/**
* Profile Hook Injector
*
* Injects WebSearch hooks into per-profile settings files.
* This replaces the global ~/.claude/settings.json approach.
*
* @module utils/websearch/profile-hook-injector
*/
import * as fs from 'fs';
import * as path from 'path';
import { info, warn } from '../ui';
import { getWebSearchHookConfig, getHookPath } from './hook-config';
import { getWebSearchConfig } from '../../config/unified-config-loader';
import { removeHookConfig } from './hook-config';
import { getCcsDir } from '../config-manager';
// Valid profile name pattern (alphanumeric, dash, underscore only)
const VALID_PROFILE_NAME = /^[a-zA-Z0-9_-]+$/;
/**
* 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
*/
function hasCcsHook(settings: Record<string, unknown>): boolean {
const hooks = settings.hooks as Record<string, unknown[]> | undefined;
if (!hooks?.PreToolUse) return false;
return hooks.PreToolUse.some((h: unknown) => {
const hook = h as Record<string, unknown>;
if (hook.matcher !== 'WebSearch') return false;
const hookArray = hook.hooks as Array<Record<string, unknown>> | undefined;
if (!hookArray?.[0]?.command) return false;
const command = hookArray[0].command as string;
return command.includes('.ccs/hooks/websearch-transformer');
});
}
/**
* Migrate CCS hook from global settings to profile settings (one-time)
*/
function migrateGlobalHook(): void {
const markerPath = getMigrationMarkerPath();
if (fs.existsSync(markerPath)) {
return; // Already migrated
}
try {
const removed = removeHookConfig();
if (removed && process.env.CCS_DEBUG) {
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
fs.writeFileSync(markerPath, new Date().toISOString(), 'utf8');
} catch (error) {
if (process.env.CCS_DEBUG) {
console.error(warn(`Migration failed: ${(error as Error).message}`));
}
}
}
/**
* Ensure WebSearch hook is configured in profile's settings file
*
* @param profileName - Name of the profile (e.g., 'agy', 'gemini', 'glm')
* @returns true if hook is configured (existing or newly added)
*/
export function ensureProfileHooks(profileName: string): boolean {
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();
// Skip if WebSearch is disabled
if (!wsConfig.enabled) {
return false;
}
// One-time migration from global settings
migrateGlobalHook();
// 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
let settings: Record<string, unknown> = {};
if (fs.existsSync(settingsPath)) {
try {
const content = fs.readFileSync(settingsPath, 'utf8');
settings = JSON.parse(content);
} catch {
if (process.env.CCS_DEBUG) {
console.error(warn(`Malformed ${profileName}.settings.json - creating fresh hooks`));
}
// Continue with empty settings, will add hooks
}
}
// Check if CCS hook already present
if (hasCcsHook(settings)) {
// Update timeout if needed
return updateHookTimeoutIfNeeded(settings, settingsPath);
}
// Get hook config
const hookConfig = getWebSearchHookConfig();
// Ensure hooks structure exists
if (!settings.hooks) {
settings.hooks = {};
}
const settingsHooks = settings.hooks as Record<string, unknown[]>;
if (!settingsHooks.PreToolUse) {
settingsHooks.PreToolUse = [];
}
// Add CCS hook
const preToolUseHooks = hookConfig.PreToolUse as unknown[];
settingsHooks.PreToolUse.push(...preToolUseHooks);
// Write updated settings
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
if (process.env.CCS_DEBUG) {
console.error(info(`Added WebSearch hook to ${profileName}.settings.json`));
}
return true;
} catch (error) {
if (process.env.CCS_DEBUG) {
console.error(warn(`Failed to inject hook: ${(error as Error).message}`));
}
return false;
}
}
/**
* Update hook timeout if it differs from current config
*/
function updateHookTimeoutIfNeeded(
settings: Record<string, unknown>,
settingsPath: string
): boolean {
try {
const hooks = settings.hooks as Record<string, unknown[]>;
const hookConfig = getWebSearchHookConfig();
const expectedHookPath = getHookPath();
const expectedCommand = `node "${expectedHookPath}"`;
const expectedHooks = (hookConfig.PreToolUse as Array<Record<string, unknown>>)[0]
.hooks as Array<Record<string, unknown>>;
const expectedTimeout = expectedHooks[0].timeout as number;
let needsUpdate = false;
for (const h of hooks.PreToolUse) {
const hook = h as Record<string, unknown>;
if (hook.matcher !== 'WebSearch') continue;
const hookArray = hook.hooks as Array<Record<string, unknown>>;
if (!hookArray?.[0]?.command) continue;
const command = hookArray[0].command as string;
if (!command.includes('.ccs/hooks/websearch-transformer')) continue;
// Found CCS hook - check if needs update
if (hookArray[0].command !== expectedCommand) {
hookArray[0].command = expectedCommand;
needsUpdate = true;
}
if (hookArray[0].timeout !== expectedTimeout) {
hookArray[0].timeout = expectedTimeout;
needsUpdate = true;
}
}
if (needsUpdate) {
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
if (process.env.CCS_DEBUG) {
console.error(info('Updated WebSearch hook timeout in profile settings'));
}
}
return true;
} catch (error) {
if (process.env.CCS_DEBUG) {
console.error(warn(`updateHookTimeoutIfNeeded failed: ${(error as Error).message}`));
}
return false;
}
}
/**
* Remove migration marker (called during uninstall)
*/
export function removeMigrationMarker(): void {
try {
const markerPath = getMigrationMarkerPath();
if (fs.existsSync(markerPath)) {
fs.unlinkSync(markerPath);
}
} catch (error) {
if (process.env.CCS_DEBUG) {
console.error(warn(`removeMigrationMarker failed: ${(error as Error).message}`));
}
}
}
+67
View File
@@ -118,6 +118,8 @@ test_case "Empty uninstall: Reports 0 items removed" "Zero removed count" bash -
# ============================================================================
# Install first so we can test uninstall
# NOTE: --install is currently a no-op (under development), but we call it
# to test the install/uninstall cycle works without errors
echo ""
echo -e "${YELLOW}Setting up for install/uninstall cycle test...${NC}"
bash -c "HOME='$TEST_HOME' '$CCS_PATH' --install > /dev/null 2>&1"
@@ -281,6 +283,71 @@ test_case "Edge case: Missing parent directories" "Handles missing .claude direc
[[ \$exit_code -eq 0 ]]
"
# ============================================================================
# TEST SECTION 7: PER-PROFILE HOOK BEHAVIOR (Global settings untouched)
# ============================================================================
echo ""
echo "========================================"
echo "SECTION 7: PER-PROFILE HOOK BEHAVIOR"
echo "========================================"
test_case "Per-profile: Uninstall does NOT touch global settings.json" "Global settings unchanged" bash -c "
# Setup: Create settings.json with user hook
mkdir -p '$TEST_CLAUDE_DIR'
cat > '$TEST_CLAUDE_DIR/settings.json' << 'EOFINNER'
{
\"hooks\": {
\"PreToolUse\": [
{ \"matcher\": \"WebSearch\", \"hooks\": [{ \"command\": \"my-custom-hook.sh\" }] }
]
}
}
EOFINNER
# Uninstall
HOME='$TEST_HOME' '$CCS_PATH' --uninstall > /dev/null 2>&1
# Verify global settings unchanged
grep -q 'my-custom-hook' '$TEST_CLAUDE_DIR/settings.json'
"
test_case "Per-profile: Uninstall preserves user hooks in global settings" "User hooks in global preserved" bash -c "
# Setup: Create settings.json with CCS hook (old format - shouldn't be touched)
mkdir -p '$TEST_CLAUDE_DIR'
cat > '$TEST_CLAUDE_DIR/settings.json' << 'EOFINNER'
{
\"hooks\": {
\"PreToolUse\": [
{ \"matcher\": \"WebSearch\", \"hooks\": [{ \"command\": \"node ~/.ccs/hooks/websearch-transformer.cjs\" }] },
{ \"matcher\": \"WebSearch\", \"hooks\": [{ \"command\": \"my-custom-hook.sh\" }] }
]
}
}
EOFINNER
# Uninstall - should NOT modify global settings
HOME='$TEST_HOME' '$CCS_PATH' --uninstall > /dev/null 2>&1
# Both hooks should still be in global settings (not touched)
grep -q 'websearch-transformer' '$TEST_CLAUDE_DIR/settings.json' &&
grep -q 'my-custom-hook' '$TEST_CLAUDE_DIR/settings.json'
"
test_case "Per-profile: Migration marker cleanup on uninstall" "Marker file removed" bash -c "
# Setup: Create ~/.ccs/.hook-migrated marker
mkdir -p '$TEST_HOME/.ccs'
echo '2026-01-25T00:00:00.000Z' > '$TEST_HOME/.ccs/.hook-migrated'
# Uninstall
HOME='$TEST_HOME' '$CCS_PATH' --uninstall > /dev/null 2>&1
# Verify marker removed
[[ ! -f '$TEST_HOME/.ccs/.hook-migrated' ]]
"
test_case "Per-profile: Handles missing settings.json" "No error when settings.json missing" bash -c "
# Ensure settings.json doesn't exist
rm -f '$TEST_CLAUDE_DIR/settings.json'
HOME='$TEST_HOME' '$CCS_PATH' --uninstall > /dev/null 2>&1
exit_code=\$?
[[ \$exit_code -eq 0 ]]
"
# ============================================================================
# SUMMARY
# ============================================================================
+110
View File
@@ -191,6 +191,8 @@ Test-Case "Empty uninstall: Reports 0 items removed" "Zero removed count" {
# ============================================================================
# Install first so we can test uninstall
# NOTE: --install is currently a no-op (under development), but we call it
# to test the install/uninstall cycle works without errors
Write-Host ""
Write-ColorOutput "Setting up for install/uninstall cycle test..." "Yellow"
$originalHome = $env:HOME
@@ -425,6 +427,114 @@ Test-Case "Edge case: Missing parent directories" "Handles missing .claude direc
}
}
# ============================================================================
# TEST SECTION 7: SETTINGS.JSON HOOK CLEANUP
# ============================================================================
Write-Host ""
Write-ColorOutput "========================================" "Yellow"
Write-ColorOutput "SECTION 7: SETTINGS.JSON HOOK CLEANUP" "Yellow"
Write-ColorOutput "========================================" "Yellow"
Test-Case "Hook cleanup: Removes CCS WebSearch hook from settings.json" "Hook removed" {
# Setup: Create settings.json with CCS hook
New-Item -ItemType Directory -Path $TestClaudeDir -Force | Out-Null
$settingsPath = Join-Path $TestClaudeDir "settings.json"
$settingsContent = @'
{
"hooks": {
"PreToolUse": [
{ "matcher": "WebSearch", "hooks": [{ "command": "node ~/.ccs/hooks/websearch-transformer.cjs" }] }
]
}
}
'@
Set-Content -Path $settingsPath -Value $settingsContent
# Install then uninstall
$env:HOME = $TestHome
try {
& $CcsPath --install | Out-Null
& $CcsPath --uninstall | Out-Null
} catch { }
# Check hook removed
$content = Get-Content -Path $settingsPath -Raw
-not ($content -match "websearch-transformer")
}
Test-Case "Hook cleanup: Preserves user-defined WebSearch hooks" "User hooks kept" {
# Setup: Create settings.json with user hook
New-Item -ItemType Directory -Path $TestClaudeDir -Force | Out-Null
$settingsPath = Join-Path $TestClaudeDir "settings.json"
$settingsContent = @'
{
"hooks": {
"PreToolUse": [
{ "matcher": "WebSearch", "hooks": [{ "command": "my-custom-hook.sh" }] }
]
}
}
'@
Set-Content -Path $settingsPath -Value $settingsContent
# Uninstall
$env:HOME = $TestHome
try {
& $CcsPath --uninstall | Out-Null
} catch { }
# Check user hook preserved
$content = Get-Content -Path $settingsPath -Raw
$content -match "my-custom-hook"
}
Test-Case "Hook cleanup: Handles mixed CCS and user hooks" "Only CCS hook removed" {
# Setup: Create settings.json with both CCS and user hooks
New-Item -ItemType Directory -Path $TestClaudeDir -Force | Out-Null
$settingsPath = Join-Path $TestClaudeDir "settings.json"
$settingsContent = @'
{
"hooks": {
"PreToolUse": [
{ "matcher": "WebSearch", "hooks": [{ "command": "node ~/.ccs/hooks/websearch-transformer.cjs" }] },
{ "matcher": "WebSearch", "hooks": [{ "command": "my-custom-hook.sh" }] },
{ "matcher": "Bash", "hooks": [{ "command": "bash-hook.sh" }] }
]
}
}
'@
Set-Content -Path $settingsPath -Value $settingsContent
# Uninstall
$env:HOME = $TestHome
try {
& $CcsPath --uninstall | Out-Null
} catch { }
# Check CCS hook removed, user hooks preserved
$content = Get-Content -Path $settingsPath -Raw
(-not ($content -match "websearch-transformer")) -and
($content -match "my-custom-hook") -and
($content -match "bash-hook")
}
Test-Case "Hook cleanup: Handles missing settings.json" "No error when settings.json missing" {
# Ensure settings.json doesn't exist
$settingsPath = Join-Path $TestClaudeDir "settings.json"
if (Test-Path $settingsPath) {
Remove-Item $settingsPath -Force
}
$env:HOME = $TestHome
try {
& $CcsPath --uninstall | Out-Null
return $true
} catch {
return $false
}
}
# ============================================================================
# SUMMARY
# ============================================================================
+2 -3
View File
@@ -39,9 +39,8 @@ describe('integration: special commands', () => {
it('handles --uninstall command', () => {
const output = execSync(`node ${ccsPath} --uninstall`, { encoding: 'utf8' });
assert(output.includes('Feature not available'));
assert(output.includes('under development'));
assert(output.includes('.claude/ integration testing'));
assert(output.includes('Uninstalling CCS'));
assert(output.includes('[OK] Uninstall complete!') || output.includes('Nothing to uninstall'));
});
describe('ccs update command flags', () => {