mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 04:18:05 +00:00
Merge pull request #371 from kaitranntt/kai/fix/uninstall-hooks-cleanup
fix: uninstall hooks cleanup + test isolation
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { ProfileMetadata } from '../types';
|
||||
import {
|
||||
loadOrCreateUnifiedConfig,
|
||||
saveUnifiedConfig,
|
||||
isUnifiedMode,
|
||||
} from '../config/unified-config-loader';
|
||||
import { getCcsDir } from '../utils/config-manager';
|
||||
|
||||
/**
|
||||
* Profile Registry (Simplified)
|
||||
@@ -43,7 +43,7 @@ export class ProfileRegistry {
|
||||
private profilesPath: string;
|
||||
|
||||
constructor() {
|
||||
this.profilesPath = path.join(os.homedir(), '.ccs', 'profiles.json');
|
||||
this.profilesPath = path.join(getCcsDir(), 'profiles.json');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+10
@@ -11,6 +11,7 @@ import {
|
||||
installWebSearchHook,
|
||||
displayWebSearchStatus,
|
||||
getWebSearchHookEnv,
|
||||
ensureProfileHooks,
|
||||
} from './utils/websearch-manager';
|
||||
import { getGlobalEnvConfig } from './config/unified-config-loader';
|
||||
import { fail, info } from './utils/ui';
|
||||
@@ -518,6 +519,9 @@ async function main(): Promise<void> {
|
||||
|
||||
if (profileInfo.type === 'cliproxy') {
|
||||
// CLIPROXY FLOW: OAuth-based profiles (gemini, codex, agy, qwen) or user-defined variants
|
||||
// Inject WebSearch hook into profile settings before launch
|
||||
ensureProfileHooks(profileInfo.name);
|
||||
|
||||
const provider = profileInfo.provider || (profileInfo.name as CLIProxyProvider);
|
||||
const customSettingsPath = profileInfo.settingsPath; // undefined for hardcoded profiles
|
||||
const variantPort = profileInfo.port; // variant-specific port for isolation
|
||||
@@ -527,6 +531,9 @@ async function main(): Promise<void> {
|
||||
});
|
||||
} else if (profileInfo.type === 'copilot') {
|
||||
// COPILOT FLOW: GitHub Copilot subscription via copilot-api proxy
|
||||
// Inject WebSearch hook into profile settings before launch
|
||||
ensureProfileHooks(profileInfo.name);
|
||||
|
||||
const { executeCopilotProfile } = await import('./copilot');
|
||||
const copilotConfig = profileInfo.copilotConfig;
|
||||
if (!copilotConfig) {
|
||||
@@ -538,6 +545,9 @@ async function main(): Promise<void> {
|
||||
} else if (profileInfo.type === 'settings') {
|
||||
// Settings-based profiles (glm, glmt, kimi) are third-party providers
|
||||
// WebSearch is server-side tool - third-party providers have no access
|
||||
// Inject WebSearch hook into profile settings before launch
|
||||
ensureProfileHooks(profileInfo.name);
|
||||
|
||||
ensureMcpWebSearch();
|
||||
installWebSearchHook();
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { getCcsDir } from '../../utils/config-manager';
|
||||
import { expandPath } from '../../utils/helpers';
|
||||
import { getClaudeEnvVars, CLIPROXY_DEFAULT_PORT } from '../config-generator';
|
||||
import { CLIProxyProvider } from '../types';
|
||||
import { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector';
|
||||
|
||||
/** Environment settings structure */
|
||||
interface SettingsEnv {
|
||||
@@ -105,6 +106,9 @@ export function createSettingsFile(
|
||||
ensureDir(ccsDir);
|
||||
writeSettings(settingsPath, settings);
|
||||
|
||||
// Inject WebSearch hooks into variant settings
|
||||
ensureProfileHooks(`${provider}-${name}`);
|
||||
|
||||
return settingsPath;
|
||||
}
|
||||
|
||||
@@ -127,6 +131,9 @@ export function createSettingsFileUnified(
|
||||
ensureDir(ccsDir);
|
||||
writeSettings(settingsPath, settings);
|
||||
|
||||
// Inject WebSearch hooks into variant settings
|
||||
ensureProfileHooks(`${provider}-${name}`);
|
||||
|
||||
return settingsPath;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
* Handle --install and --uninstall commands for CCS.
|
||||
*/
|
||||
|
||||
import { info, color, initUI } from '../utils/ui';
|
||||
import { info, ok, color, box, initUI } from '../utils/ui';
|
||||
import { uninstallWebSearchHook } from '../utils/websearch';
|
||||
import { ClaudeSymlinkManager } from '../utils/claude-symlink-manager';
|
||||
|
||||
/**
|
||||
* Handle install command
|
||||
@@ -28,12 +30,36 @@ export async function handleInstallCommand(): Promise<void> {
|
||||
export async function handleUninstallCommand(): Promise<void> {
|
||||
await initUI();
|
||||
console.log('');
|
||||
console.log(info('Feature not available'));
|
||||
console.log(box('Uninstalling CCS', { borderColor: 'cyan' }));
|
||||
console.log('');
|
||||
console.log('The --uninstall flag is currently under development.');
|
||||
console.log('.claude/ integration testing is not complete.');
|
||||
|
||||
let removed = 0;
|
||||
|
||||
// 1. Remove WebSearch hook file + migration marker (does NOT touch global settings.json)
|
||||
const hookRemoved = uninstallWebSearchHook();
|
||||
if (hookRemoved) {
|
||||
console.log(ok('Removed WebSearch hook'));
|
||||
removed++;
|
||||
}
|
||||
|
||||
// 2. Remove symlinks from ~/.claude/
|
||||
const symlinkManager = new ClaudeSymlinkManager();
|
||||
const symlinksRemoved = symlinkManager.uninstall();
|
||||
if (symlinksRemoved > 0) {
|
||||
removed++;
|
||||
}
|
||||
|
||||
// 3. Summary
|
||||
console.log('');
|
||||
console.log(`For updates: ${color('https://github.com/kaitranntt/ccs/issues', 'path')}`);
|
||||
if (removed > 0) {
|
||||
console.log(ok('Uninstall complete!'));
|
||||
console.log('');
|
||||
console.log(info('~/.ccs/ directory preserved'));
|
||||
console.log(info('To reinstall: ccs --install'));
|
||||
} else {
|
||||
console.log(info('Nothing to uninstall'));
|
||||
}
|
||||
console.log('');
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -7,6 +7,7 @@ import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { ok, fail, warn, info } from '../../utils/ui';
|
||||
import { HealthCheck, IHealthChecker, createSpinner } from './types';
|
||||
import { getCcsDir } from '../../utils/config-manager';
|
||||
|
||||
const ora = createSpinner();
|
||||
|
||||
@@ -20,7 +21,7 @@ export class ConfigFilesChecker implements IHealthChecker {
|
||||
private readonly ccsDir: string;
|
||||
|
||||
constructor() {
|
||||
this.ccsDir = path.join(os.homedir(), '.ccs');
|
||||
this.ccsDir = getCcsDir();
|
||||
}
|
||||
|
||||
run(results: HealthCheck): void {
|
||||
|
||||
@@ -4,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 {
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -11,16 +11,21 @@ import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { info, warn } from '../ui';
|
||||
import { getWebSearchConfig } from '../../config/unified-config-loader';
|
||||
import { getCcsDir } from '../config-manager';
|
||||
|
||||
// Path to Claude settings.json
|
||||
// Path to Claude settings.json (intentionally uses real homedir for global settings)
|
||||
const CLAUDE_SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json');
|
||||
|
||||
// CCS hooks directory
|
||||
const CCS_HOOKS_DIR = path.join(os.homedir(), '.ccs', 'hooks');
|
||||
|
||||
// Hook file name
|
||||
const WEBSEARCH_HOOK = 'websearch-transformer.cjs';
|
||||
|
||||
/**
|
||||
* Get CCS hooks directory (respects CCS_HOME for test isolation)
|
||||
*/
|
||||
export function getCcsHooksDir(): string {
|
||||
return path.join(getCcsDir(), 'hooks');
|
||||
}
|
||||
|
||||
// Buffer time added to max provider timeout for hook timeout (seconds)
|
||||
const HOOK_TIMEOUT_BUFFER = 30;
|
||||
|
||||
@@ -31,7 +36,7 @@ const MIN_HOOK_TIMEOUT = 60;
|
||||
* Get path to WebSearch hook
|
||||
*/
|
||||
export function getHookPath(): string {
|
||||
return path.join(CCS_HOOKS_DIR, WEBSEARCH_HOOK);
|
||||
return path.join(getCcsHooksDir(), WEBSEARCH_HOOK);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -178,3 +183,66 @@ export function ensureHookConfig(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove CCS WebSearch hook from ~/.claude/settings.json
|
||||
* Only removes hooks matching: matcher='WebSearch' AND command contains '.ccs/hooks/websearch-transformer'
|
||||
* Preserves user-defined WebSearch hooks
|
||||
*/
|
||||
export function removeHookConfig(): boolean {
|
||||
try {
|
||||
if (!fs.existsSync(CLAUDE_SETTINGS_PATH)) {
|
||||
return true; // Nothing to remove
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(CLAUDE_SETTINGS_PATH, 'utf8');
|
||||
let settings: Record<string, unknown>;
|
||||
try {
|
||||
settings = JSON.parse(content);
|
||||
} catch {
|
||||
return false; // Malformed JSON, don't touch
|
||||
}
|
||||
|
||||
const hooks = settings.hooks as Record<string, unknown[]> | undefined;
|
||||
if (!hooks?.PreToolUse) {
|
||||
return true; // No hooks to remove
|
||||
}
|
||||
|
||||
const originalLength = hooks.PreToolUse.length;
|
||||
hooks.PreToolUse = hooks.PreToolUse.filter((h: unknown) => {
|
||||
const hook = h as Record<string, unknown>;
|
||||
if (hook.matcher !== 'WebSearch') return true; // Keep non-WebSearch hooks
|
||||
|
||||
const hookArray = hook.hooks as Array<Record<string, unknown>> | undefined;
|
||||
if (!hookArray?.[0]?.command) return true; // Keep malformed entries
|
||||
|
||||
const command = hookArray[0].command as string;
|
||||
return !command.includes('.ccs/hooks/websearch-transformer'); // Remove if CCS hook
|
||||
});
|
||||
|
||||
if (hooks.PreToolUse.length === originalLength) {
|
||||
return true; // Nothing changed
|
||||
}
|
||||
|
||||
// Clean up empty hooks object
|
||||
if (hooks.PreToolUse.length === 0) {
|
||||
delete hooks.PreToolUse;
|
||||
}
|
||||
if (Object.keys(hooks).length === 0) {
|
||||
delete settings.hooks;
|
||||
}
|
||||
|
||||
fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8');
|
||||
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(info('Removed WebSearch hook from settings.json'));
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(warn(`Failed to remove hook config: ${(error as Error).message}`));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -41,6 +41,9 @@ export {
|
||||
uninstallWebSearchHook,
|
||||
} from './hook-installer';
|
||||
|
||||
// Hook Config (removal)
|
||||
export { removeHookConfig } from './hook-config';
|
||||
|
||||
// Hook Environment
|
||||
export { getWebSearchHookEnv } from './hook-env';
|
||||
|
||||
@@ -52,3 +55,6 @@ export {
|
||||
getWebSearchReadiness,
|
||||
displayWebSearchStatus,
|
||||
} from './status';
|
||||
|
||||
// Profile Hook Injection
|
||||
export { ensureProfileHooks, removeMigrationMarker } from './profile-hook-injector';
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Profile Hook Injector
|
||||
*
|
||||
* Injects WebSearch hooks into per-profile settings files.
|
||||
* This replaces the global ~/.claude/settings.json approach.
|
||||
*
|
||||
* @module utils/websearch/profile-hook-injector
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { info, warn } from '../ui';
|
||||
import { getWebSearchHookConfig, getHookPath } from './hook-config';
|
||||
import { getWebSearchConfig } from '../../config/unified-config-loader';
|
||||
import { removeHookConfig } from './hook-config';
|
||||
import { getCcsDir } from '../config-manager';
|
||||
|
||||
// Valid profile name pattern (alphanumeric, dash, underscore only)
|
||||
const VALID_PROFILE_NAME = /^[a-zA-Z0-9_-]+$/;
|
||||
|
||||
/**
|
||||
* Get migration marker path (respects CCS_HOME for test isolation)
|
||||
*/
|
||||
function getMigrationMarkerPath(): string {
|
||||
return path.join(getCcsDir(), '.hook-migrated');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if CCS WebSearch hook exists in settings
|
||||
*/
|
||||
function hasCcsHook(settings: Record<string, unknown>): boolean {
|
||||
const hooks = settings.hooks as Record<string, unknown[]> | undefined;
|
||||
if (!hooks?.PreToolUse) return false;
|
||||
|
||||
return hooks.PreToolUse.some((h: unknown) => {
|
||||
const hook = h as Record<string, unknown>;
|
||||
if (hook.matcher !== 'WebSearch') return false;
|
||||
|
||||
const hookArray = hook.hooks as Array<Record<string, unknown>> | undefined;
|
||||
if (!hookArray?.[0]?.command) return false;
|
||||
|
||||
const command = hookArray[0].command as string;
|
||||
return command.includes('.ccs/hooks/websearch-transformer');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate CCS hook from global settings to profile settings (one-time)
|
||||
*/
|
||||
function migrateGlobalHook(): void {
|
||||
const markerPath = getMigrationMarkerPath();
|
||||
if (fs.existsSync(markerPath)) {
|
||||
return; // Already migrated
|
||||
}
|
||||
|
||||
try {
|
||||
const removed = removeHookConfig();
|
||||
if (removed && process.env.CCS_DEBUG) {
|
||||
console.error(info('Migrated WebSearch hook from global settings'));
|
||||
}
|
||||
// Ensure CCS dir exists before creating marker
|
||||
const ccsDir = getCcsDir();
|
||||
if (!fs.existsSync(ccsDir)) {
|
||||
fs.mkdirSync(ccsDir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
// Create marker file
|
||||
fs.writeFileSync(markerPath, new Date().toISOString(), 'utf8');
|
||||
} catch (error) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(warn(`Migration failed: ${(error as Error).message}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure WebSearch hook is configured in profile's settings file
|
||||
*
|
||||
* @param profileName - Name of the profile (e.g., 'agy', 'gemini', 'glm')
|
||||
* @returns true if hook is configured (existing or newly added)
|
||||
*/
|
||||
export function ensureProfileHooks(profileName: string): boolean {
|
||||
try {
|
||||
// Validate profile name to prevent path traversal
|
||||
if (!VALID_PROFILE_NAME.test(profileName)) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(warn(`Invalid profile name: ${profileName}`));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const wsConfig = getWebSearchConfig();
|
||||
|
||||
// Skip if WebSearch is disabled
|
||||
if (!wsConfig.enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// One-time migration from global settings
|
||||
migrateGlobalHook();
|
||||
|
||||
// Get CCS directory (respects CCS_HOME for test isolation)
|
||||
const ccsDir = getCcsDir();
|
||||
|
||||
// Ensure CCS dir exists
|
||||
if (!fs.existsSync(ccsDir)) {
|
||||
fs.mkdirSync(ccsDir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
const settingsPath = path.join(ccsDir, `${profileName}.settings.json`);
|
||||
|
||||
// Read existing settings or create empty
|
||||
let settings: Record<string, unknown> = {};
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(settingsPath, 'utf8');
|
||||
settings = JSON.parse(content);
|
||||
} catch {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(warn(`Malformed ${profileName}.settings.json - creating fresh hooks`));
|
||||
}
|
||||
// Continue with empty settings, will add hooks
|
||||
}
|
||||
}
|
||||
|
||||
// Check if CCS hook already present
|
||||
if (hasCcsHook(settings)) {
|
||||
// Update timeout if needed
|
||||
return updateHookTimeoutIfNeeded(settings, settingsPath);
|
||||
}
|
||||
|
||||
// Get hook config
|
||||
const hookConfig = getWebSearchHookConfig();
|
||||
|
||||
// Ensure hooks structure exists
|
||||
if (!settings.hooks) {
|
||||
settings.hooks = {};
|
||||
}
|
||||
|
||||
const settingsHooks = settings.hooks as Record<string, unknown[]>;
|
||||
if (!settingsHooks.PreToolUse) {
|
||||
settingsHooks.PreToolUse = [];
|
||||
}
|
||||
|
||||
// Add CCS hook
|
||||
const preToolUseHooks = hookConfig.PreToolUse as unknown[];
|
||||
settingsHooks.PreToolUse.push(...preToolUseHooks);
|
||||
|
||||
// Write updated settings
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
|
||||
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(info(`Added WebSearch hook to ${profileName}.settings.json`));
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(warn(`Failed to inject hook: ${(error as Error).message}`));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update hook timeout if it differs from current config
|
||||
*/
|
||||
function updateHookTimeoutIfNeeded(
|
||||
settings: Record<string, unknown>,
|
||||
settingsPath: string
|
||||
): boolean {
|
||||
try {
|
||||
const hooks = settings.hooks as Record<string, unknown[]>;
|
||||
const hookConfig = getWebSearchHookConfig();
|
||||
const expectedHookPath = getHookPath();
|
||||
const expectedCommand = `node "${expectedHookPath}"`;
|
||||
const expectedHooks = (hookConfig.PreToolUse as Array<Record<string, unknown>>)[0]
|
||||
.hooks as Array<Record<string, unknown>>;
|
||||
const expectedTimeout = expectedHooks[0].timeout as number;
|
||||
|
||||
let needsUpdate = false;
|
||||
|
||||
for (const h of hooks.PreToolUse) {
|
||||
const hook = h as Record<string, unknown>;
|
||||
if (hook.matcher !== 'WebSearch') continue;
|
||||
|
||||
const hookArray = hook.hooks as Array<Record<string, unknown>>;
|
||||
if (!hookArray?.[0]?.command) continue;
|
||||
|
||||
const command = hookArray[0].command as string;
|
||||
if (!command.includes('.ccs/hooks/websearch-transformer')) continue;
|
||||
|
||||
// Found CCS hook - check if needs update
|
||||
if (hookArray[0].command !== expectedCommand) {
|
||||
hookArray[0].command = expectedCommand;
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (hookArray[0].timeout !== expectedTimeout) {
|
||||
hookArray[0].timeout = expectedTimeout;
|
||||
needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(info('Updated WebSearch hook timeout in profile settings'));
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(warn(`updateHookTimeoutIfNeeded failed: ${(error as Error).message}`));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove migration marker (called during uninstall)
|
||||
*/
|
||||
export function removeMigrationMarker(): void {
|
||||
try {
|
||||
const markerPath = getMigrationMarkerPath();
|
||||
if (fs.existsSync(markerPath)) {
|
||||
fs.unlinkSync(markerPath);
|
||||
}
|
||||
} catch (error) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(warn(`removeMigrationMarker failed: ${(error as Error).message}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
# ============================================================================
|
||||
|
||||
@@ -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
|
||||
# ============================================================================
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user