From 9159aa52cbe99c0820c30a19843043ea141c1106 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sun, 25 Jan 2026 20:03:52 -0500 Subject: [PATCH 01/15] feat(websearch): add removeHookConfig function - add removeHookConfig() to remove CCS hook from settings.json - only removes hooks matching CCS pattern (.ccs/hooks/websearch-transformer) - preserves user-defined WebSearch hooks - cleans up empty hooks object after removal - export from barrel file Closes #317 --- src/utils/websearch/hook-config.ts | 63 ++++++++++++++++++++++++++++++ src/utils/websearch/index.ts | 3 ++ 2 files changed, 66 insertions(+) diff --git a/src/utils/websearch/hook-config.ts b/src/utils/websearch/hook-config.ts index 5a806783..954e843b 100644 --- a/src/utils/websearch/hook-config.ts +++ b/src/utils/websearch/hook-config.ts @@ -178,3 +178,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/index.ts b/src/utils/websearch/index.ts index 8c8826f1..b9a9dfa4 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'; From fc4d987d205bb2bd86d2cf2698858f052a740cce Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sun, 25 Jan 2026 20:04:04 -0500 Subject: [PATCH 02/15] feat(websearch): call removeHookConfig on uninstall - uninstallWebSearchHook() now calls removeHookConfig() - ensures settings.json cleanup when hook file is removed --- src/utils/websearch/hook-installer.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/utils/websearch/hook-installer.ts b/src/utils/websearch/hook-installer.ts index 4fc4d66b..a9efbf06 100644 --- a/src/utils/websearch/hook-installer.ts +++ b/src/utils/websearch/hook-installer.ts @@ -11,7 +11,7 @@ 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, removeHookConfig } from './hook-config'; // Re-export from hook-config for backward compatibility export { getHookPath, getWebSearchHookConfig } from './hook-config'; @@ -115,7 +115,8 @@ export function uninstallWebSearchHook(): boolean { } } - // TODO: Optionally remove from settings.json + // Remove from settings.json + removeHookConfig(); return true; } catch (error) { From c44a5c221f2046b84e6a556f0ffed706964dac6f Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sun, 25 Jan 2026 20:05:31 -0500 Subject: [PATCH 03/15] feat(cli): implement --uninstall handler - add handleUninstallCommand() with proper cleanup flow - remove WebSearch hook (file + settings.json) - remove symlinks from ~/.claude/ - display summary with removal count - preserve ~/.ccs/ directory --- src/commands/install-command.ts | 34 ++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/src/commands/install-command.ts b/src/commands/install-command.ts index 5bdcfbf2..8842dc0a 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,34 @@ 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 + settings.json) + const hookRemoved = uninstallWebSearchHook(); + if (hookRemoved) { + console.log(ok('Removed WebSearch hook')); + removed++; + } + + // 2. Remove symlinks from ~/.claude/ + const symlinkManager = new ClaudeSymlinkManager(); + symlinkManager.uninstall(); + 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); } From 4f28de9c90cbd8b6bacbd6cf73b3f664db64eee3 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sun, 25 Jan 2026 20:05:43 -0500 Subject: [PATCH 04/15] feat(npm): add postuninstall script - add scripts/postuninstall.js for npm uninstall cleanup - register in package.json scripts.postuninstall - runs ccs --uninstall on npm uninstall -g --- package.json | 1 + scripts/postuninstall.js | 60 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 scripts/postuninstall.js diff --git a/package.json b/package.json index 7e48c2cf..eb34e0af 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..d7e1181e --- /dev/null +++ b/scripts/postuninstall.js @@ -0,0 +1,60 @@ +#!/usr/bin/env node +/** + * CCS Postuninstall Script + * + * Cleans up WebSearch hook from ~/.claude/settings.json after npm uninstall. + * Self-contained, no external dependencies. + */ + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json'); + +function cleanupHook() { + try { + if (!fs.existsSync(SETTINGS_PATH)) { + return; // Nothing to clean + } + + const content = fs.readFileSync(SETTINGS_PATH, 'utf8'); + let settings; + try { + settings = JSON.parse(content); + } catch { + return; // Malformed JSON, don't touch + } + + const hooks = settings.hooks; + if (!hooks?.PreToolUse) { + return; // No hooks to remove + } + + const originalLength = hooks.PreToolUse.length; + hooks.PreToolUse = hooks.PreToolUse.filter((h) => { + if (h.matcher !== 'WebSearch') return true; + const command = h.hooks?.[0]?.command; + if (!command) return true; + return !command.includes('.ccs/hooks/websearch-transformer'); + }); + + if (hooks.PreToolUse.length === originalLength) { + return; // Nothing changed + } + + // Clean up empty structures + if (hooks.PreToolUse.length === 0) { + delete hooks.PreToolUse; + } + if (Object.keys(hooks).length === 0) { + delete settings.hooks; + } + + fs.writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8'); + } catch { + // Silent fail - not critical + } +} + +cleanupHook(); From 6838ac0fa1ae0578cf84a451ed628cd8ded31562 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sun, 25 Jan 2026 20:05:58 -0500 Subject: [PATCH 05/15] test(uninstall): add hook cleanup tests - add Section 7: SETTINGS.JSON HOOK CLEANUP tests - test CCS hook removal from settings.json - test user-defined hooks preserved - test mixed CCS and user hooks handling - test missing settings.json handling - update special-commands.test.js expectations --- tests/native/unix/uninstall.sh | 78 +++++++++++++++++++++ tests/native/windows/uninstall.ps1 | 108 +++++++++++++++++++++++++++++ tests/npm/special-commands.test.js | 5 +- 3 files changed, 188 insertions(+), 3 deletions(-) diff --git a/tests/native/unix/uninstall.sh b/tests/native/unix/uninstall.sh index b4f5273e..13d34b48 100755 --- a/tests/native/unix/uninstall.sh +++ b/tests/native/unix/uninstall.sh @@ -281,6 +281,84 @@ test_case "Edge case: Missing parent directories" "Handles missing .claude direc [[ \$exit_code -eq 0 ]] " +# ============================================================================ +# TEST SECTION 7: SETTINGS.JSON HOOK CLEANUP +# ============================================================================ + +echo "" +echo "========================================" +echo "SECTION 7: SETTINGS.JSON HOOK CLEANUP" +echo "========================================" + +test_case "Hook cleanup: Removes CCS WebSearch hook from settings.json" "Hook removed" bash -c " + # Setup: Create settings.json with CCS hook + mkdir -p '$TEST_CLAUDE_DIR' + cat > '$TEST_CLAUDE_DIR/settings.json' << 'EOFINNER' +{ + \"hooks\": { + \"PreToolUse\": [ + { \"matcher\": \"WebSearch\", \"hooks\": [{ \"command\": \"node ~/.ccs/hooks/websearch-transformer.cjs\" }] } + ] + } +} +EOFINNER + # Install then uninstall + HOME='$TEST_HOME' '$CCS_PATH' --install > /dev/null 2>&1 + HOME='$TEST_HOME' '$CCS_PATH' --uninstall > /dev/null 2>&1 + # Check hook removed + ! grep -q 'websearch-transformer' '$TEST_CLAUDE_DIR/settings.json' +" + +test_case "Hook cleanup: Preserves user-defined WebSearch hooks" "User hooks kept" 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 + # Check user hook preserved + grep -q 'my-custom-hook' '$TEST_CLAUDE_DIR/settings.json' +" + +test_case "Hook cleanup: Handles mixed CCS and user hooks" "Only CCS hook removed" bash -c " + # Setup: Create settings.json with both CCS and user hooks + 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\" }] }, + { \"matcher\": \"Bash\", \"hooks\": [{ \"command\": \"bash-hook.sh\" }] } + ] + } +} +EOFINNER + # Uninstall + HOME='$TEST_HOME' '$CCS_PATH' --uninstall > /dev/null 2>&1 + # Check CCS hook removed + ! grep -q 'websearch-transformer' '$TEST_CLAUDE_DIR/settings.json' && + # Check user WebSearch hook preserved + grep -q 'my-custom-hook' '$TEST_CLAUDE_DIR/settings.json' && + # Check other hooks preserved + grep -q 'bash-hook' '$TEST_CLAUDE_DIR/settings.json' +" + +test_case "Hook cleanup: 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..82f79348 100644 --- a/tests/native/windows/uninstall.ps1 +++ b/tests/native/windows/uninstall.ps1 @@ -425,6 +425,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', () => { From 242ab7645384516db0c05c7139ae652733acf271 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sun, 25 Jan 2026 20:24:47 -0500 Subject: [PATCH 06/15] feat(websearch): add per-profile hook injection module - ensureProfileHooks() injects hooks into profile settings - migrateGlobalHook() one-time migration from global settings - removeMigrationMarker() cleanup for uninstall - export from barrel files for module access --- src/utils/websearch-manager.ts | 3 + src/utils/websearch/index.ts | 3 + src/utils/websearch/profile-hook-injector.ts | 202 +++++++++++++++++++ 3 files changed, 208 insertions(+) create mode 100644 src/utils/websearch/profile-hook-injector.ts 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/index.ts b/src/utils/websearch/index.ts index b9a9dfa4..4bcfd134 100644 --- a/src/utils/websearch/index.ts +++ b/src/utils/websearch/index.ts @@ -55,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..7742b9f3 --- /dev/null +++ b/src/utils/websearch/profile-hook-injector.ts @@ -0,0 +1,202 @@ +/** + * 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 * as os from 'os'; +import { info, warn } from '../ui'; +import { getWebSearchHookConfig, getHookPath } from './hook-config'; +import { getWebSearchConfig } from '../../config/unified-config-loader'; +import { removeHookConfig } from './hook-config'; + +// CCS directory +const CCS_DIR = path.join(os.homedir(), '.ccs'); + +// Migration marker file +const MIGRATION_MARKER = path.join(CCS_DIR, '.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 { + if (fs.existsSync(MIGRATION_MARKER)) { + return; // Already migrated + } + + try { + const removed = removeHookConfig(); + if (removed && process.env.CCS_DEBUG) { + console.error(info('Migrated WebSearch hook from global settings')); + } + // Create marker file + fs.writeFileSync(MIGRATION_MARKER, 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 { + const wsConfig = getWebSearchConfig(); + + // Skip if WebSearch is disabled + if (!wsConfig.enabled) { + return false; + } + + // One-time migration from global settings + migrateGlobalHook(); + + const settingsPath = path.join(CCS_DIR, `${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 { + return false; + } +} + +/** + * Remove migration marker (called during uninstall) + */ +export function removeMigrationMarker(): void { + try { + if (fs.existsSync(MIGRATION_MARKER)) { + fs.unlinkSync(MIGRATION_MARKER); + } + } catch { + // Ignore errors + } +} From 0099ab5a1c6d3201850b44bc51b06ceb8847f1d2 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sun, 25 Jan 2026 20:25:01 -0500 Subject: [PATCH 07/15] feat(cli): inject hooks into profile settings on launch - call ensureProfileHooks() before CLIProxy execution - call ensureProfileHooks() before Copilot execution - call ensureProfileHooks() before Settings-based execution - account-based profiles skip (use native WebSearch) --- src/ccs.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) 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(); From ba1fb7eeb3855db50eff5cfb5999d77ffd66f17f Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sun, 25 Jan 2026 20:26:27 -0500 Subject: [PATCH 08/15] refactor(uninstall): stop modifying global settings.json - remove removeHookConfig() call from uninstallWebSearchHook() - add removeMigrationMarker() cleanup - update postuninstall.js to only clean CCS files - global ~/.claude/settings.json is never touched --- scripts/postuninstall.js | 56 +++++++++------------------ src/utils/websearch/hook-installer.ts | 13 +++++-- 2 files changed, 28 insertions(+), 41 deletions(-) diff --git a/scripts/postuninstall.js b/scripts/postuninstall.js index d7e1181e..4cd31d00 100644 --- a/scripts/postuninstall.js +++ b/scripts/postuninstall.js @@ -2,7 +2,9 @@ /** * CCS Postuninstall Script * - * Cleans up WebSearch hook from ~/.claude/settings.json after npm uninstall. + * 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. */ @@ -10,51 +12,29 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); -const SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json'); +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 cleanupHook() { +function cleanupCcsFiles() { try { - if (!fs.existsSync(SETTINGS_PATH)) { - return; // Nothing to clean + // Remove WebSearch hook file + const hookPath = path.join(HOOKS_DIR, 'websearch-transformer.cjs'); + if (fs.existsSync(hookPath)) { + fs.unlinkSync(hookPath); } - const content = fs.readFileSync(SETTINGS_PATH, 'utf8'); - let settings; - try { - settings = JSON.parse(content); - } catch { - return; // Malformed JSON, don't touch + // Remove migration marker (so fresh install re-runs migration) + if (fs.existsSync(MIGRATION_MARKER)) { + fs.unlinkSync(MIGRATION_MARKER); } - const hooks = settings.hooks; - if (!hooks?.PreToolUse) { - return; // No hooks to remove - } - - const originalLength = hooks.PreToolUse.length; - hooks.PreToolUse = hooks.PreToolUse.filter((h) => { - if (h.matcher !== 'WebSearch') return true; - const command = h.hooks?.[0]?.command; - if (!command) return true; - return !command.includes('.ccs/hooks/websearch-transformer'); - }); - - if (hooks.PreToolUse.length === originalLength) { - return; // Nothing changed - } - - // Clean up empty structures - if (hooks.PreToolUse.length === 0) { - delete hooks.PreToolUse; - } - if (Object.keys(hooks).length === 0) { - delete settings.hooks; - } - - fs.writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8'); + // 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 } } -cleanupHook(); +cleanupCcsFiles(); diff --git a/src/utils/websearch/hook-installer.ts b/src/utils/websearch/hook-installer.ts index a9efbf06..8a0c3234 100644 --- a/src/utils/websearch/hook-installer.ts +++ b/src/utils/websearch/hook-installer.ts @@ -11,7 +11,8 @@ 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, removeHookConfig } from './hook-config'; +import { getHookPath, ensureHookConfig } from './hook-config'; +import { removeMigrationMarker } from './profile-hook-injector'; // Re-export from hook-config for backward compatibility export { getHookPath, getWebSearchHookConfig } from './hook-config'; @@ -102,6 +103,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,8 +119,11 @@ export function uninstallWebSearchHook(): boolean { } } - // Remove from settings.json - removeHookConfig(); + // 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) { From ce59eb6269ad65efb27e373b592596355f9dc313 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sun, 25 Jan 2026 20:26:40 -0500 Subject: [PATCH 09/15] test(uninstall): update tests for per-profile hook behavior - verify uninstall does NOT touch global settings.json - verify migration marker is cleaned up - verify user hooks in global settings preserved --- tests/native/unix/uninstall.sh | 57 +++++++++++++--------------------- 1 file changed, 22 insertions(+), 35 deletions(-) diff --git a/tests/native/unix/uninstall.sh b/tests/native/unix/uninstall.sh index 13d34b48..a9f46c43 100755 --- a/tests/native/unix/uninstall.sh +++ b/tests/native/unix/uninstall.sh @@ -282,34 +282,15 @@ test_case "Edge case: Missing parent directories" "Handles missing .claude direc " # ============================================================================ -# TEST SECTION 7: SETTINGS.JSON HOOK CLEANUP +# TEST SECTION 7: PER-PROFILE HOOK BEHAVIOR (Global settings untouched) # ============================================================================ echo "" echo "========================================" -echo "SECTION 7: SETTINGS.JSON HOOK CLEANUP" +echo "SECTION 7: PER-PROFILE HOOK BEHAVIOR" echo "========================================" -test_case "Hook cleanup: Removes CCS WebSearch hook from settings.json" "Hook removed" bash -c " - # Setup: Create settings.json with CCS hook - mkdir -p '$TEST_CLAUDE_DIR' - cat > '$TEST_CLAUDE_DIR/settings.json' << 'EOFINNER' -{ - \"hooks\": { - \"PreToolUse\": [ - { \"matcher\": \"WebSearch\", \"hooks\": [{ \"command\": \"node ~/.ccs/hooks/websearch-transformer.cjs\" }] } - ] - } -} -EOFINNER - # Install then uninstall - HOME='$TEST_HOME' '$CCS_PATH' --install > /dev/null 2>&1 - HOME='$TEST_HOME' '$CCS_PATH' --uninstall > /dev/null 2>&1 - # Check hook removed - ! grep -q 'websearch-transformer' '$TEST_CLAUDE_DIR/settings.json' -" - -test_case "Hook cleanup: Preserves user-defined WebSearch hooks" "User hooks kept" bash -c " +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' @@ -323,35 +304,41 @@ test_case "Hook cleanup: Preserves user-defined WebSearch hooks" "User hooks kep EOFINNER # Uninstall HOME='$TEST_HOME' '$CCS_PATH' --uninstall > /dev/null 2>&1 - # Check user hook preserved + # Verify global settings unchanged grep -q 'my-custom-hook' '$TEST_CLAUDE_DIR/settings.json' " -test_case "Hook cleanup: Handles mixed CCS and user hooks" "Only CCS hook removed" bash -c " - # Setup: Create settings.json with both CCS and user hooks +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\" }] }, - { \"matcher\": \"Bash\", \"hooks\": [{ \"command\": \"bash-hook.sh\" }] } + { \"matcher\": \"WebSearch\", \"hooks\": [{ \"command\": \"my-custom-hook.sh\" }] } ] } } EOFINNER - # Uninstall + # Uninstall - should NOT modify global settings HOME='$TEST_HOME' '$CCS_PATH' --uninstall > /dev/null 2>&1 - # Check CCS hook removed - ! grep -q 'websearch-transformer' '$TEST_CLAUDE_DIR/settings.json' && - # Check user WebSearch hook preserved - grep -q 'my-custom-hook' '$TEST_CLAUDE_DIR/settings.json' && - # Check other hooks preserved - grep -q 'bash-hook' '$TEST_CLAUDE_DIR/settings.json' + # 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 "Hook cleanup: Handles missing settings.json" "No error when settings.json missing" bash -c " +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 From b33674b3b225dd8d07fa48f916c3400cd0685dec Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sun, 25 Jan 2026 21:03:01 -0500 Subject: [PATCH 10/15] 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 --- CLAUDE.md | 13 ++++++ src/utils/websearch/hook-config.ts | 15 ++++--- src/utils/websearch/hook-installer.ts | 17 ++++--- src/utils/websearch/profile-hook-injector.ts | 47 +++++++++++++++----- 4 files changed, 71 insertions(+), 21 deletions(-) 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/src/utils/websearch/hook-config.ts b/src/utils/websearch/hook-config.ts index 954e843b..00cc4391 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) + */ +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); } /** diff --git a/src/utils/websearch/hook-installer.ts b/src/utils/websearch/hook-installer.ts index 8a0c3234..c54a3d61 100644 --- a/src/utils/websearch/hook-installer.ts +++ b/src/utils/websearch/hook-installer.ts @@ -8,21 +8,25 @@ 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 { removeMigrationMarker } from './profile-hook-injector'; +import { getCcsDir } from '../config-manager'; // 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'; +/** + * Get CCS hooks directory (respects CCS_HOME for test isolation) + */ +function getCcsHooksDir(): string { + return path.join(getCcsDir(), 'hooks'); +} + /** * Check if WebSearch hook is installed */ @@ -50,8 +54,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(); diff --git a/src/utils/websearch/profile-hook-injector.ts b/src/utils/websearch/profile-hook-injector.ts index 7742b9f3..2f13e6aa 100644 --- a/src/utils/websearch/profile-hook-injector.ts +++ b/src/utils/websearch/profile-hook-injector.ts @@ -9,17 +9,21 @@ import * as fs from 'fs'; import * as path from 'path'; -import * as os from 'os'; 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'; -// CCS directory -const CCS_DIR = path.join(os.homedir(), '.ccs'); +// Valid profile name pattern (alphanumeric, dash, underscore only) +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 @@ -44,7 +48,8 @@ function hasCcsHook(settings: Record): boolean { * Migrate CCS hook from global settings to profile settings (one-time) */ function migrateGlobalHook(): void { - if (fs.existsSync(MIGRATION_MARKER)) { + const markerPath = getMigrationMarkerPath(); + if (fs.existsSync(markerPath)) { return; // Already migrated } @@ -53,8 +58,13 @@ function migrateGlobalHook(): void { 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(MIGRATION_MARKER, new Date().toISOString(), 'utf8'); + fs.writeFileSync(markerPath, new Date().toISOString(), 'utf8'); } catch (error) { if (process.env.CCS_DEBUG) { console.error(warn(`Migration failed: ${(error as Error).message}`)); @@ -70,6 +80,14 @@ function migrateGlobalHook(): void { */ 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 @@ -80,7 +98,15 @@ export function ensureProfileHooks(profileName: string): boolean { // One-time migration from global settings 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 let settings: Record = {}; @@ -193,8 +219,9 @@ function updateHookTimeoutIfNeeded( */ export function removeMigrationMarker(): void { try { - if (fs.existsSync(MIGRATION_MARKER)) { - fs.unlinkSync(MIGRATION_MARKER); + const markerPath = getMigrationMarkerPath(); + if (fs.existsSync(markerPath)) { + fs.unlinkSync(markerPath); } } catch { // Ignore errors From 21b18d0c4e7dbdf9e7070458ec5c5fb54ac6a410 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sun, 25 Jan 2026 21:13:55 -0500 Subject: [PATCH 11/15] refactor(websearch): address PR review recommendations - Add comment in postuninstall.js explaining intentional os.homedir() - Fix comment in install-command.ts (doesn't touch global settings.json) - Consolidate duplicate getCcsHooksDir() - export from hook-config.ts - Add debug logging to silent catch blocks in profile-hook-injector.ts --- scripts/postuninstall.js | 2 ++ src/commands/install-command.ts | 2 +- src/utils/websearch/hook-config.ts | 2 +- src/utils/websearch/hook-installer.ts | 10 +--------- src/utils/websearch/profile-hook-injector.ts | 11 ++++++++--- 5 files changed, 13 insertions(+), 14 deletions(-) diff --git a/scripts/postuninstall.js b/scripts/postuninstall.js index 4cd31d00..5058e4ae 100644 --- a/scripts/postuninstall.js +++ b/scripts/postuninstall.js @@ -12,6 +12,8 @@ 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'); diff --git a/src/commands/install-command.ts b/src/commands/install-command.ts index 8842dc0a..9959c735 100644 --- a/src/commands/install-command.ts +++ b/src/commands/install-command.ts @@ -35,7 +35,7 @@ export async function handleUninstallCommand(): Promise { let removed = 0; - // 1. Remove WebSearch hook (file + settings.json) + // 1. Remove WebSearch hook file + migration marker (does NOT touch global settings.json) const hookRemoved = uninstallWebSearchHook(); if (hookRemoved) { console.log(ok('Removed WebSearch hook')); diff --git a/src/utils/websearch/hook-config.ts b/src/utils/websearch/hook-config.ts index 00cc4391..72ca82e5 100644 --- a/src/utils/websearch/hook-config.ts +++ b/src/utils/websearch/hook-config.ts @@ -22,7 +22,7 @@ const WEBSEARCH_HOOK = 'websearch-transformer.cjs'; /** * Get CCS hooks directory (respects CCS_HOME for test isolation) */ -function getCcsHooksDir(): string { +export function getCcsHooksDir(): string { return path.join(getCcsDir(), 'hooks'); } diff --git a/src/utils/websearch/hook-installer.ts b/src/utils/websearch/hook-installer.ts index c54a3d61..21da5a5f 100644 --- a/src/utils/websearch/hook-installer.ts +++ b/src/utils/websearch/hook-installer.ts @@ -10,9 +10,8 @@ import * as fs from 'fs'; import * as path from 'path'; 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'; -import { getCcsDir } from '../config-manager'; // Re-export from hook-config for backward compatibility export { getHookPath, getWebSearchHookConfig } from './hook-config'; @@ -20,13 +19,6 @@ export { getHookPath, getWebSearchHookConfig } from './hook-config'; // Hook file name 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 */ diff --git a/src/utils/websearch/profile-hook-injector.ts b/src/utils/websearch/profile-hook-injector.ts index 2f13e6aa..5bdc7ea5 100644 --- a/src/utils/websearch/profile-hook-injector.ts +++ b/src/utils/websearch/profile-hook-injector.ts @@ -209,7 +209,10 @@ function updateHookTimeoutIfNeeded( } return true; - } catch { + } catch (error) { + if (process.env.CCS_DEBUG) { + console.error(warn(`updateHookTimeoutIfNeeded failed: ${(error as Error).message}`)); + } return false; } } @@ -223,7 +226,9 @@ export function removeMigrationMarker(): void { if (fs.existsSync(markerPath)) { fs.unlinkSync(markerPath); } - } catch { - // Ignore errors + } catch (error) { + if (process.env.CCS_DEBUG) { + console.error(warn(`removeMigrationMarker failed: ${(error as Error).message}`)); + } } } From fca8dbd6cfdcb8a229051b840733b0769e61368a Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sun, 25 Jan 2026 21:31:20 -0500 Subject: [PATCH 12/15] feat(websearch): inject hooks on profile creation Add hook injection at profile creation time (not just runtime): - profile-writer.ts: inject on API profile create/update - variant-settings.ts: inject on CLIProxy variant create Hooks now present immediately after profile creation. --- src/api/services/profile-writer.ts | 8 ++++++++ src/cliproxy/services/variant-settings.ts | 7 +++++++ 2 files changed, 15 insertions(+) diff --git a/src/api/services/profile-writer.ts b/src/api/services/profile-writer.ts index 48c34ff1..6995161b 100644 --- a/src/api/services/profile-writer.ts +++ b/src/api/services/profile-writer.ts @@ -12,6 +12,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 +44,10 @@ function createSettingsFile( }; fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8'); + + // Inject WebSearch hooks into profile settings + ensureProfileHooks(name); + return settingsPath; } @@ -101,6 +106,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/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; } From 9b61f5318eedfd1f1a25ec5f3f3a39619174567b Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sun, 25 Jan 2026 21:38:27 -0500 Subject: [PATCH 13/15] fix(isolation): use getCcsDir() for test isolation Replace os.homedir() with getCcsDir() in 11 source files to ensure tests don't touch the user's real ~/.ccs/ directory. The getCcsDir() function from config-manager.ts respects CCS_HOME env var for test isolation. Files fixed: - src/auth/profile-detector.ts - src/auth/profile-registry.ts - src/delegation/headless-executor.ts - src/delegation/session-manager.ts - src/glmt/glmt-transformer.ts - src/management/checks/config-check.ts - src/management/checks/profile-check.ts - src/management/checks/system-check.ts - src/management/instance-manager.ts - src/management/shared-manager.ts - src/utils/update-checker.ts --- src/auth/profile-detector.ts | 7 ++++--- src/auth/profile-registry.ts | 4 ++-- src/delegation/headless-executor.ts | 4 ++-- src/delegation/session-manager.ts | 4 ++-- src/glmt/glmt-transformer.ts | 4 ++-- src/management/checks/config-check.ts | 3 ++- src/management/checks/profile-check.ts | 8 ++++---- src/management/checks/system-check.ts | 5 ++--- src/management/instance-manager.ts | 4 ++-- src/management/shared-manager.ts | 6 ++++-- src/utils/update-checker.ts | 4 ++-- 11 files changed, 28 insertions(+), 25 deletions(-) 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/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/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/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'; From 6a2c82917dea1fb7c5c7a9e4e134ac3227e0edaa Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sun, 25 Jan 2026 21:42:17 -0500 Subject: [PATCH 14/15] fix(isolation): add getCcsDir/getCcsHome to more files Fix test isolation in symlink managers and checkers to prevent tests from touching user's real ~/.ccs/ and ~/.claude/ directories. Files fixed: - src/api/services/profile-writer.ts - use getCcsDir() - src/management/checks/symlink-check.ts - use functions instead of module-level constants - src/utils/claude-dir-installer.ts - use getCcsDir() for CCS paths - src/utils/claude-symlink-manager.ts - use getCcsHome()/getCcsDir() --- src/api/services/profile-writer.ts | 3 +-- src/management/checks/symlink-check.ts | 21 +++++++++++++++++---- src/utils/claude-dir-installer.ts | 6 +++--- src/utils/claude-symlink-manager.ts | 7 ++++--- 4 files changed, 25 insertions(+), 12 deletions(-) diff --git a/src/api/services/profile-writer.ts b/src/api/services/profile-writer.ts index 6995161b..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'; @@ -83,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); 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/utils/claude-dir-installer.ts b/src/utils/claude-dir-installer.ts index 05ac1de0..d6ea1492 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 } 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 = getCcsDir().replace(/\.ccs$/, ''); // Get home dir from CCS dir + this.ccsClaudeDir = path.join(getCcsDir(), '.claude'); } /** diff --git a/src/utils/claude-symlink-manager.ts b/src/utils/claude-symlink-manager.ts index 4902d698..7bdc22e0 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) From cd7a1121d4f0072a4b15dc11b4864ec3aad26758 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sun, 25 Jan 2026 21:50:25 -0500 Subject: [PATCH 15/15] fix: address PR review feedback - ClaudeSymlinkManager.uninstall() now returns count for accurate reporting - install-command.ts uses return value instead of always incrementing - claude-dir-installer.ts uses getCcsHome() directly instead of string manipulation - Add TODO comments to tests explaining --install is a no-op --- src/commands/install-command.ts | 6 ++++-- src/utils/claude-dir-installer.ts | 4 ++-- src/utils/claude-symlink-manager.ts | 5 ++++- tests/native/unix/uninstall.sh | 2 ++ tests/native/windows/uninstall.ps1 | 2 ++ 5 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/commands/install-command.ts b/src/commands/install-command.ts index 9959c735..8cc67a19 100644 --- a/src/commands/install-command.ts +++ b/src/commands/install-command.ts @@ -44,8 +44,10 @@ export async function handleUninstallCommand(): Promise { // 2. Remove symlinks from ~/.claude/ const symlinkManager = new ClaudeSymlinkManager(); - symlinkManager.uninstall(); - removed++; + const symlinksRemoved = symlinkManager.uninstall(); + if (symlinksRemoved > 0) { + removed++; + } // 3. Summary console.log(''); diff --git a/src/utils/claude-dir-installer.ts b/src/utils/claude-dir-installer.ts index d6ea1492..789e66aa 100644 --- a/src/utils/claude-dir-installer.ts +++ b/src/utils/claude-dir-installer.ts @@ -6,7 +6,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { ok, fail, warn, info } from './ui'; -import { getCcsDir } from './config-manager'; +import { getCcsDir, getCcsHome } from './config-manager'; // Ora fallback type for when ora is not available interface OraSpinner { @@ -60,7 +60,7 @@ export class ClaudeDirInstaller { private ccsClaudeDir: string; constructor() { - this.homeDir = getCcsDir().replace(/\.ccs$/, ''); // Get home dir from CCS dir + 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 7bdc22e0..4bc2d92f 100644 --- a/src/utils/claude-symlink-manager.ts +++ b/src/utils/claude-symlink-manager.ts @@ -280,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) { @@ -316,6 +317,8 @@ export class ClaudeSymlinkManager { } else { console.log(info('No delegation commands or skills to remove')); } + + return removed; } /** diff --git a/tests/native/unix/uninstall.sh b/tests/native/unix/uninstall.sh index a9f46c43..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" diff --git a/tests/native/windows/uninstall.ps1 b/tests/native/windows/uninstall.ps1 index 82f79348..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