diff --git a/CLAUDE.md b/CLAUDE.md index e4705720..f8a9d07b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/package.json b/package.json index 9111b2bc..e10fb1ce 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/postuninstall.js b/scripts/postuninstall.js new file mode 100644 index 00000000..5058e4ae --- /dev/null +++ b/scripts/postuninstall.js @@ -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(); diff --git a/src/api/services/profile-writer.ts b/src/api/services/profile-writer.ts index 48c34ff1..ebac9446 100644 --- a/src/api/services/profile-writer.ts +++ b/src/api/services/profile-writer.ts @@ -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', diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index a3fad69b..bde28265 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -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'); } /** diff --git a/src/auth/profile-registry.ts b/src/auth/profile-registry.ts index 151781ee..e076e8eb 100644 --- a/src/auth/profile-registry.ts +++ b/src/auth/profile-registry.ts @@ -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'); } /** diff --git a/src/ccs.ts b/src/ccs.ts index f56a6a43..8fce1c51 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -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 { 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 { }); } 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 { } 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(); diff --git a/src/cliproxy/services/variant-settings.ts b/src/cliproxy/services/variant-settings.ts index 646a1a76..872ae910 100644 --- a/src/cliproxy/services/variant-settings.ts +++ b/src/cliproxy/services/variant-settings.ts @@ -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; } diff --git a/src/commands/install-command.ts b/src/commands/install-command.ts index 5bdcfbf2..8cc67a19 100644 --- a/src/commands/install-command.ts +++ b/src/commands/install-command.ts @@ -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 { export async function handleUninstallCommand(): Promise { 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); } diff --git a/src/delegation/headless-executor.ts b/src/delegation/headless-executor.ts index a2eceec1..b282936a 100644 --- a/src/delegation/headless-executor.ts +++ b/src/delegation/headless-executor.ts @@ -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)) { diff --git a/src/delegation/session-manager.ts b/src/delegation/session-manager.ts index b9fba1f6..c407daad 100644 --- a/src/delegation/session-manager.ts +++ b/src/delegation/session-manager.ts @@ -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'); } /** diff --git a/src/glmt/glmt-transformer.ts b/src/glmt/glmt-transformer.ts index ad1a404d..5c4a41e7 100644 --- a/src/glmt/glmt-transformer.ts +++ b/src/glmt/glmt-transformer.ts @@ -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({ diff --git a/src/management/checks/config-check.ts b/src/management/checks/config-check.ts index e56846a8..c05d8457 100644 --- a/src/management/checks/config-check.ts +++ b/src/management/checks/config-check.ts @@ -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 { diff --git a/src/management/checks/profile-check.ts b/src/management/checks/profile-check.ts index e29a0143..d1cd555b 100644 --- a/src/management/checks/profile-check.ts +++ b/src/management/checks/profile-check.ts @@ -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 { diff --git a/src/management/checks/symlink-check.ts b/src/management/checks/symlink-check.ts index 0b943295..0d4a6fa7 100644 --- a/src/management/checks/symlink-check.ts +++ b/src/management/checks/symlink-check.ts @@ -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'); diff --git a/src/management/checks/system-check.ts b/src/management/checks/system-check.ts index 65b1b39e..f25c60ca 100644 --- a/src/management/checks/system-check.ts +++ b/src/management/checks/system-check.ts @@ -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 { diff --git a/src/management/instance-manager.ts b/src/management/instance-manager.ts index 27ecd8d2..2063a39c 100644 --- a/src/management/instance-manager.ts +++ b/src/management/instance-manager.ts @@ -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(); } diff --git a/src/management/shared-manager.ts b/src/management/shared-manager.ts index 65ea5ed1..ca6d29be 100644 --- a/src/management/shared-manager.ts +++ b/src/management/shared-manager.ts @@ -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' }, diff --git a/src/utils/claude-dir-installer.ts b/src/utils/claude-dir-installer.ts index 05ac1de0..789e66aa 100644 --- a/src/utils/claude-dir-installer.ts +++ b/src/utils/claude-dir-installer.ts @@ -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'); } /** diff --git a/src/utils/claude-symlink-manager.ts b/src/utils/claude-symlink-manager.ts index 4902d698..4bc2d92f 100644 --- a/src/utils/claude-symlink-manager.ts +++ b/src/utils/claude-symlink-manager.ts @@ -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; } /** diff --git a/src/utils/update-checker.ts b/src/utils/update-checker.ts index c4a0e41d..019e0c6e 100644 --- a/src/utils/update-checker.ts +++ b/src/utils/update-checker.ts @@ -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'; diff --git a/src/utils/websearch-manager.ts b/src/utils/websearch-manager.ts index ad1f0777..88a916b5 100644 --- a/src/utils/websearch-manager.ts +++ b/src/utils/websearch-manager.ts @@ -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'; diff --git a/src/utils/websearch/hook-config.ts b/src/utils/websearch/hook-config.ts index 5a806783..72ca82e5 100644 --- a/src/utils/websearch/hook-config.ts +++ b/src/utils/websearch/hook-config.ts @@ -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; + try { + settings = JSON.parse(content); + } catch { + return false; // Malformed JSON, don't touch + } + + const hooks = settings.hooks as Record | 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; + if (hook.matcher !== 'WebSearch') return true; // Keep non-WebSearch hooks + + const hookArray = hook.hooks as Array> | 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; + } +} diff --git a/src/utils/websearch/hook-installer.ts b/src/utils/websearch/hook-installer.ts index 4fc4d66b..21da5a5f 100644 --- a/src/utils/websearch/hook-installer.ts +++ b/src/utils/websearch/hook-installer.ts @@ -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) { diff --git a/src/utils/websearch/index.ts b/src/utils/websearch/index.ts index 8c8826f1..4bcfd134 100644 --- a/src/utils/websearch/index.ts +++ b/src/utils/websearch/index.ts @@ -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'; diff --git a/src/utils/websearch/profile-hook-injector.ts b/src/utils/websearch/profile-hook-injector.ts new file mode 100644 index 00000000..5bdc7ea5 --- /dev/null +++ b/src/utils/websearch/profile-hook-injector.ts @@ -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): boolean { + const hooks = settings.hooks as Record | undefined; + if (!hooks?.PreToolUse) return false; + + return hooks.PreToolUse.some((h: unknown) => { + const hook = h as Record; + if (hook.matcher !== 'WebSearch') return false; + + const hookArray = hook.hooks as Array> | 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 = {}; + 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; + 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, + settingsPath: string +): boolean { + try { + const hooks = settings.hooks as Record; + const hookConfig = getWebSearchHookConfig(); + const expectedHookPath = getHookPath(); + const expectedCommand = `node "${expectedHookPath}"`; + const expectedHooks = (hookConfig.PreToolUse as Array>)[0] + .hooks as Array>; + const expectedTimeout = expectedHooks[0].timeout as number; + + let needsUpdate = false; + + for (const h of hooks.PreToolUse) { + const hook = h as Record; + if (hook.matcher !== 'WebSearch') continue; + + const hookArray = hook.hooks as Array>; + 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}`)); + } + } +} diff --git a/tests/native/unix/uninstall.sh b/tests/native/unix/uninstall.sh index b4f5273e..241e5089 100755 --- a/tests/native/unix/uninstall.sh +++ b/tests/native/unix/uninstall.sh @@ -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 # ============================================================================ diff --git a/tests/native/windows/uninstall.ps1 b/tests/native/windows/uninstall.ps1 index 978c8b24..b9ac0785 100644 --- a/tests/native/windows/uninstall.ps1 +++ b/tests/native/windows/uninstall.ps1 @@ -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 # ============================================================================ diff --git a/tests/npm/special-commands.test.js b/tests/npm/special-commands.test.js index 601612eb..84f3f4d6 100644 --- a/tests/npm/special-commands.test.js +++ b/tests/npm/special-commands.test.js @@ -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', () => {