diff --git a/src/api/services/openrouter-catalog.ts b/src/api/services/openrouter-catalog.ts index b6b2d6ff..bfcb784e 100644 --- a/src/api/services/openrouter-catalog.ts +++ b/src/api/services/openrouter-catalog.ts @@ -5,10 +5,12 @@ import * as fs from 'fs'; import * as path from 'path'; -import * as os from 'os'; +import { getCcsDir } from '../../utils/config-manager'; const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/models'; -const CACHE_FILE = path.join(os.homedir(), '.ccs', 'openrouter-models-cache.json'); +function getCacheFile() { + return path.join(getCcsDir(), 'openrouter-models-cache.json'); +} const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours export interface OpenRouterModel { @@ -30,8 +32,9 @@ interface CacheData { /** Check if cached data is valid */ function getCachedModels(): OpenRouterModel[] | null { try { - if (!fs.existsSync(CACHE_FILE)) return null; - const data = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')) as CacheData; + const cacheFile = getCacheFile(); + if (!fs.existsSync(cacheFile)) return null; + const data = JSON.parse(fs.readFileSync(cacheFile, 'utf8')) as CacheData; if (Date.now() - data.fetchedAt > CACHE_TTL_MS) return null; return data.models; } catch { @@ -42,10 +45,11 @@ function getCachedModels(): OpenRouterModel[] | null { /** Save models to cache */ function setCachedModels(models: OpenRouterModel[]): void { try { - const dir = path.dirname(CACHE_FILE); + const cacheFile = getCacheFile(); + const dir = path.dirname(cacheFile); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync( - CACHE_FILE, + cacheFile, JSON.stringify({ models, fetchedAt: Date.now(), diff --git a/src/ccs.ts b/src/ccs.ts index 9e302de7..851650bd 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -2,7 +2,13 @@ import { spawn, ChildProcess } from 'child_process'; import * as path from 'path'; import * as fs from 'fs'; import { detectClaudeCli } from './utils/claude-detector'; -import { getSettingsPath, loadSettings } from './utils/config-manager'; +import { + getSettingsPath, + loadSettings, + getCcsDir, + setGlobalConfigDir, + detectCloudSyncPath, +} from './utils/config-manager'; import { validateGlmKey, validateMiniMaxKey } from './utils/api-key-validator'; import { ErrorManager } from './utils/error-manager'; import { execClaudeWithCLIProxy, CLIProxyProvider } from './cliproxy'; @@ -15,7 +21,7 @@ import { import { getGlobalEnvConfig } from './config/unified-config-loader'; import { ensureProfileHooks as ensureImageAnalyzerHooks } from './utils/hooks/image-analyzer-profile-hook-injector'; import { getImageAnalysisHookEnv } from './utils/hooks'; -import { fail, info } from './utils/ui'; +import { fail, info, warn } from './utils/ui'; // Import centralized error handling import { handleError, runCleanup } from './errors'; @@ -80,7 +86,7 @@ async function execClaudeWithProxy( if (!apiKey || apiKey === 'YOUR_GLM_API_KEY_HERE') { console.error(fail('GLMT profile requires Z.AI API key')); - console.error(' Edit ~/.ccs/glmt.settings.json and set ANTHROPIC_AUTH_TOKEN'); + console.error(` Edit ${getCcsDir()}/glmt.settings.json and set ANTHROPIC_AUTH_TOKEN`); process.exit(1); } @@ -148,7 +154,7 @@ async function execClaudeWithProxy( console.error('Workarounds:'); console.error(' - Use non-thinking mode: ccs glm "prompt"'); console.error(' - Enable verbose logging: ccs glmt --verbose "prompt"'); - console.error(' - Check proxy logs in ~/.ccs/logs/ (if debug enabled)'); + console.error(` - Check proxy logs in ${getCcsDir()}/logs/ (if debug enabled)`); console.error(''); proxy.kill(); process.exit(1); @@ -259,7 +265,6 @@ async function showCachedUpdateNotification(): Promise { async function main(): Promise { const args = process.argv.slice(2); - const firstArg = args[0]; // Initialize UI colors early to ensure consistent colored output // Must happen before any status messages (ok, info, fail, etc.) @@ -268,6 +273,69 @@ async function main(): Promise { await initUI(); } + // Parse --config-dir flag (must happen before any config loading) + const configDirIdx = args.findIndex((a) => a === '--config-dir' || a.startsWith('--config-dir=')); + if (configDirIdx !== -1) { + const arg = args[configDirIdx]; + let configDirValue: string | undefined; + let spliceCount = 1; + + if (arg.startsWith('--config-dir=')) { + configDirValue = arg.split('=').slice(1).join('='); + } else { + configDirValue = args[configDirIdx + 1]; + spliceCount = 2; + } + + if (!configDirValue || configDirValue.startsWith('-')) { + console.error(fail('--config-dir requires a path argument')); + process.exit(1); + } + + try { + const stat = fs.statSync(configDirValue); + if (!stat.isDirectory()) { + console.error(fail(`Not a directory: ${configDirValue}`)); + process.exit(1); + } + } catch { + console.error(fail(`Config directory not found: ${configDirValue}`)); + console.error(info('Create the directory first, then copy your config files into it.')); + process.exit(1); + } + + setGlobalConfigDir(configDirValue); + + // Security warning: cloud sync paths expose OAuth tokens + const cloudService = detectCloudSyncPath(configDirValue); + if (cloudService) { + console.error(warn(`CCS directory is under ${cloudService}.`)); + console.error(' OAuth tokens in cliproxy/auth/ will be synced to cloud.'); + console.error(' Consider: CCS_DIR=/path/outside/cloud ccs ...'); + } + + // Remove consumed args so they don't leak to Claude CLI + args.splice(configDirIdx, spliceCount); + } else if (process.env.CCS_DIR) { + // Also warn for CCS_DIR env var pointing to cloud sync + const cloudService = detectCloudSyncPath(process.env.CCS_DIR); + if (cloudService) { + console.error(warn(`CCS directory is under ${cloudService}.`)); + console.error(' OAuth tokens in cliproxy/auth/ will be synced to cloud.'); + console.error(' Consider: CCS_DIR=/path/outside/cloud ccs ...'); + } + } else if (process.env.CCS_HOME) { + // Also warn for CCS_HOME env var pointing to cloud sync + const cloudService = detectCloudSyncPath(process.env.CCS_HOME); + if (cloudService) { + console.error(warn(`CCS directory is under ${cloudService}.`)); + console.error(' OAuth tokens in cliproxy/auth/ will be synced to cloud.'); + console.error(' Consider: CCS_DIR=/path/outside/cloud ccs ...'); + } + } + + const firstArg = args[0]; + // Trigger update check early for ALL commands except version/help/update // Only if TTY and not CI to avoid noise in automated environments const skipUpdateCheck = [ diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index 595bc07a..986868fb 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -13,8 +13,10 @@ import { spawn, ChildProcess } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; +import * as path from 'path'; import { ProgressIndicator } from '../../utils/progress-indicator'; import { ok, fail, info, warn } from '../../utils/ui'; +import { getCcsDir } from '../../utils/config-manager'; import { escapeShellArg } from '../../utils/shell-executor'; import { ensureCLIProxyBinary } from '../binary-manager'; import { @@ -655,7 +657,7 @@ export async function execClaudeWithCLIProxy( upstreamBaseUrl: postSanitizationBaseUrl, verbose, defaultEffort: 'medium', - traceFilePath: traceEnabled ? `${os.homedir()}/.ccs/codex-reasoning-proxy.log` : '', + traceFilePath: traceEnabled ? path.join(getCcsDir(), 'codex-reasoning-proxy.log') : '', modelMap: { defaultModel: initialEnvVars.ANTHROPIC_MODEL, opusModel: initialEnvVars.ANTHROPIC_DEFAULT_OPUS_MODEL, diff --git a/src/cliproxy/model-config.ts b/src/cliproxy/model-config.ts index 2da1cc63..1442a93a 100644 --- a/src/cliproxy/model-config.ts +++ b/src/cliproxy/model-config.ts @@ -7,15 +7,12 @@ import * as fs from 'fs'; import * as os from 'os'; -import * as path from 'path'; import { InteractivePrompt } from '../utils/prompt'; import { getProviderCatalog, supportsModelConfig, ModelEntry } from './model-catalog'; import { getProviderSettingsPath, getClaudeEnvVars } from './config-generator'; import { CLIProxyProvider } from './types'; import { initUI, color, bold, dim, ok, info, header } from '../utils/ui'; - -/** CCS directory */ -const CCS_DIR = path.join(os.homedir(), '.ccs'); +import { getCcsDir } from '../utils/config-manager'; /** * Check if provider has user settings configured @@ -185,8 +182,9 @@ export async function configureProviderModel( }; // Ensure CCS directory exists - if (!fs.existsSync(CCS_DIR)) { - fs.mkdirSync(CCS_DIR, { recursive: true }); + const ccsDir = getCcsDir(); + if (!fs.existsSync(ccsDir)) { + fs.mkdirSync(ccsDir, { recursive: true }); } // Write settings file diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 66938823..6c51f385 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -2,6 +2,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { initUI, box, color, dim, sectionHeader, subheader } from '../utils/ui'; import { isUnifiedMode } from '../config/unified-config-loader'; +import { getCcsDir, getCcsDirSource } from '../utils/config-manager'; // Get version from package.json (same as version-command.ts) const VERSION = JSON.parse( @@ -110,6 +111,10 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ); console.log(''); + // Resolve display path for dynamic sections + const [dirSource] = getCcsDirSource(); + const dirDisplay = dirSource === 'default' ? '~/.ccs' : getCcsDir(); + // Usage section console.log(subheader('Usage:')); console.log(` ${color('ccs', 'command')} [profile] [claude-args...]`); @@ -121,7 +126,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); // ═══════════════════════════════════════════════════════════════════════════ printMajorSection( 'API Key Profiles', - ['Configure in ~/.ccs/*.settings.json'], + [`Configure in ${dirDisplay}/*.settings.json`], [ ['ccs', 'Use default Claude account'], ['ccs glm', 'GLM 4.6 (API key required)'], @@ -268,6 +273,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); // Flags printSubSection('Flags', [ + ['--config-dir ', 'Use custom CCS config directory'], ['-h, --help', 'Show this help message'], ['-v, --version', 'Show version and installation info'], ['-sc, --shell-completion', 'Install shell auto-completion'], @@ -275,10 +281,10 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); // Configuration printConfigSection('Configuration', [ - ['Config File:', isUnifiedMode() ? '~/.ccs/config.yaml' : '~/.ccs/config.json'], - ['Profiles:', '~/.ccs/profiles.json'], - ['Instances:', '~/.ccs/instances/'], - ['Settings:', '~/.ccs/*.settings.json'], + ['Config File:', isUnifiedMode() ? `${dirDisplay}/config.yaml` : `${dirDisplay}/config.json`], + ['Profiles:', `${dirDisplay}/profiles.json`], + ['Instances:', `${dirDisplay}/instances/`], + ['Settings:', `${dirDisplay}/*.settings.json`], ]); // CLI Proxy management @@ -345,6 +351,13 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['', 'providers (agy, gemini, codex, kiro, ghcp).'], ]); + // CCS Environment Variables + printSubSection('Environment Variables', [ + ['CCS_DIR', 'Override CCS config directory (default: ~/.ccs)'], + ['CCS_HOME', 'Override home directory (legacy, appends .ccs)'], + ['CCS_DEBUG', 'Enable debug logging'], + ]); + // CLI Proxy env vars printSubSection('CLI Proxy Environment Variables', [ ['CCS_PROXY_HOST', 'Remote proxy hostname'], @@ -358,17 +371,17 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); // CLI Proxy paths console.log(subheader('CLI Proxy:')); - console.log(` Binary: ${color('~/.ccs/cliproxy/bin/cli-proxy-api-plus', 'path')}`); - console.log(` Config: ${color('~/.ccs/cliproxy/config.yaml', 'path')}`); - console.log(` Auth: ${color('~/.ccs/cliproxy/auth/', 'path')}`); + console.log(` Binary: ${color(`${dirDisplay}/cliproxy/bin/cli-proxy-api-plus`, 'path')}`); + console.log(` Config: ${color(`${dirDisplay}/cliproxy/config.yaml`, 'path')}`); + console.log(` Auth: ${color(`${dirDisplay}/cliproxy/auth/`, 'path')}`); console.log(` ${dim('Port: 8317 (default)')}`); console.log(''); // Shared Data console.log(subheader('Shared Data:')); - console.log(` Commands: ${color('~/.ccs/shared/commands/', 'path')}`); - console.log(` Skills: ${color('~/.ccs/shared/skills/', 'path')}`); - console.log(` Agents: ${color('~/.ccs/shared/agents/', 'path')}`); + console.log(` Commands: ${color(`${dirDisplay}/shared/commands/`, 'path')}`); + console.log(` Skills: ${color(`${dirDisplay}/shared/skills/`, 'path')}`); + console.log(` Agents: ${color(`${dirDisplay}/shared/agents/`, 'path')}`); console.log(` ${dim('Note: Symlinked across all profiles')}`); console.log(''); diff --git a/src/commands/version-command.ts b/src/commands/version-command.ts index 02304b46..c83cd9d1 100644 --- a/src/commands/version-command.ts +++ b/src/commands/version-command.ts @@ -6,9 +6,8 @@ import * as path from 'path'; import * as fs from 'fs'; -import * as os from 'os'; import { initUI, header, subheader, color, warn } from '../utils/ui'; -import { getActiveConfigPath } from '../utils/config-manager'; +import { getActiveConfigPath, getCcsDir } from '../utils/config-manager'; import { getVersion } from '../utils/version'; /** @@ -23,24 +22,24 @@ export async function handleVersionCommand(): Promise { const installLocation = process.argv[1] || '(not found)'; console.log(` ${color('Location:'.padEnd(17), 'info')} ${installLocation}`); - const ccsDir = path.join(os.homedir(), '.ccs'); + const ccsDir = getCcsDir(); console.log(` ${color('CCS Directory:'.padEnd(17), 'info')} ${ccsDir}`); const configPath = getActiveConfigPath(); console.log(` ${color('Config:'.padEnd(17), 'info')} ${configPath}`); - const profilesJson = path.join(os.homedir(), '.ccs', 'profiles.json'); + const profilesJson = path.join(ccsDir, 'profiles.json'); console.log(` ${color('Profiles:'.padEnd(17), 'info')} ${profilesJson}`); // Delegation status - const delegationSessionsPath = path.join(os.homedir(), '.ccs', 'delegation-sessions.json'); + const delegationSessionsPath = path.join(ccsDir, 'delegation-sessions.json'); const delegationConfigured = fs.existsSync(delegationSessionsPath); const readyProfiles: string[] = []; // Check for profiles with valid API keys for (const profile of ['glm', 'kimi']) { - const settingsPath = path.join(os.homedir(), '.ccs', `${profile}.settings.json`); + const settingsPath = path.join(ccsDir, `${profile}.settings.json`); if (fs.existsSync(settingsPath)) { try { const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); diff --git a/src/delegation/delegation-handler.ts b/src/delegation/delegation-handler.ts index 75c75f6f..cfe85138 100644 --- a/src/delegation/delegation-handler.ts +++ b/src/delegation/delegation-handler.ts @@ -6,6 +6,7 @@ import { ResultFormatter } from './result-formatter'; import { DelegationValidator } from '../utils/delegation-validator'; import { SettingsParser } from './settings-parser'; import { fail, warn } from '../utils/ui'; +import { getCcsDir } from '../utils/config-manager'; /** * Parse and validate a string flag value @@ -330,7 +331,7 @@ export class DelegationHandler { console.error(` ${validation.error}`); console.error(''); console.error(' Run: ccs doctor'); - console.error(` Or configure: ~/.ccs/${profile}.settings.json`); + console.error(` Or configure: ${getCcsDir()}/${profile}.settings.json`); process.exit(1); } } diff --git a/src/management/checks/env-check.ts b/src/management/checks/env-check.ts index c586b07a..95a34a61 100644 --- a/src/management/checks/env-check.ts +++ b/src/management/checks/env-check.ts @@ -4,6 +4,7 @@ import { getEnvironmentDiagnostics } from '../environment-diagnostics'; import { ok, warn } from '../../utils/ui'; +import { getCcsDir, getCcsDirSource } from '../../utils/config-manager'; import { HealthCheck, IHealthChecker, createSpinner } from './types'; const ora = createSpinner(); @@ -54,6 +55,12 @@ export class EnvironmentChecker implements IHealthChecker { } console.log(` ${''.padEnd(24)} Browser: ${diag.browserReason}`); + // Show CCS directory and source + const ccsDir = getCcsDir(); + const [dirSource] = getCcsDirSource(); + const sourceLabel = dirSource === 'default' ? '' : ` (via ${dirSource})`; + console.log(` ${''.padEnd(24)} CCS Dir: ${ccsDir}${sourceLabel}`); + results.addCheck( 'Environment', envStatus === 'OK' ? 'success' : 'warning', diff --git a/src/management/recovery-manager.ts b/src/management/recovery-manager.ts index 8977870c..ec7e3710 100644 --- a/src/management/recovery-manager.ts +++ b/src/management/recovery-manager.ts @@ -6,8 +6,8 @@ import * as fs from 'fs'; import * as path from 'path'; -import * as os from 'os'; import { info } from '../utils/ui'; +import { getCcsHome, getCcsDir } from '../utils/config-manager'; import { createEmptyUnifiedConfig, UNIFIED_CONFIG_VERSION } from '../config/unified-config-types'; import { saveUnifiedConfig, @@ -15,13 +15,6 @@ import { loadUnifiedConfig, } from '../config/unified-config-loader'; -/** - * Get CCS home directory (respects CCS_HOME env for test isolation) - */ -function getCcsHome(): string { - return process.env.CCS_HOME || os.homedir(); -} - /** * Recovery Manager Class */ @@ -35,7 +28,7 @@ class RecoveryManager { constructor() { this.homedir = getCcsHome(); - this.ccsDir = path.join(this.homedir, '.ccs'); + this.ccsDir = getCcsDir(); this.claudeDir = path.join(this.homedir, '.claude'); this.sharedDir = path.join(this.ccsDir, 'shared'); this.completionsDir = path.join(this.ccsDir, 'completions'); @@ -48,7 +41,7 @@ class RecoveryManager { ensureCcsDirectory(): boolean { if (!fs.existsSync(this.ccsDir)) { fs.mkdirSync(this.ccsDir, { recursive: true, mode: 0o755 }); - this.recovered.push('Created ~/.ccs/ directory'); + this.recovered.push(`Created ${this.ccsDir} directory`); return true; } return false; @@ -67,7 +60,7 @@ class RecoveryManager { return false; // Config exists and is valid } // Config exists but is corrupted - will be recreated below - this.recovered.push('Detected corrupted ~/.ccs/config.yaml'); + this.recovered.push(`Detected corrupted ${this.ccsDir}/config.yaml`); } // Check for legacy config.json - if exists, let autoMigrate handle it @@ -83,7 +76,7 @@ class RecoveryManager { try { saveUnifiedConfig(config); - this.recovered.push('Created ~/.ccs/config.yaml'); + this.recovered.push(`Created ${this.ccsDir}/config.yaml`); return true; } catch (_saveErr) { // Fallback: create minimal config.json for backward compat @@ -92,7 +85,7 @@ class RecoveryManager { const tmpPath = `${legacyConfigPath}.tmp`; fs.writeFileSync(tmpPath, JSON.stringify(fallbackConfig, null, 2) + '\n', 'utf8'); fs.renameSync(tmpPath, legacyConfigPath); - this.recovered.push('Created ~/.ccs/config.json (fallback)'); + this.recovered.push(`Created ${this.ccsDir}/config.json (fallback)`); return true; } catch (_fallbackErr) { // Both writes failed - log but don't crash @@ -111,7 +104,7 @@ class RecoveryManager { // Create ~/.claude/ if missing if (!fs.existsSync(this.claudeDir)) { fs.mkdirSync(this.claudeDir, { recursive: true, mode: 0o755 }); - this.recovered.push('Created ~/.claude/ directory'); + this.recovered.push(`Created ${this.claudeDir} directory`); } // Create settings.json if missing @@ -120,7 +113,7 @@ class RecoveryManager { fs.writeFileSync(tmpPath, '{}\n', 'utf8'); fs.renameSync(tmpPath, claudeSettingsPath); - this.recovered.push('Created ~/.claude/settings.json'); + this.recovered.push(`Created ${claudeSettingsPath}`); return true; } @@ -136,7 +129,7 @@ class RecoveryManager { // Create shared directory if (!fs.existsSync(this.sharedDir)) { fs.mkdirSync(this.sharedDir, { recursive: true, mode: 0o755 }); - this.recovered.push('Created ~/.ccs/shared/'); + this.recovered.push(`Created ${this.sharedDir}`); created = true; } @@ -197,7 +190,7 @@ class RecoveryManager { } if (installed) { - this.recovered.push('Installed shell completions to ~/.ccs/completions/'); + this.recovered.push(`Installed shell completions to ${this.completionsDir}`); } return installed; @@ -245,7 +238,7 @@ class RecoveryManager { this.recovered.forEach((msg) => console.log(` - ${msg}`)); // Show login hint if created Claude settings - if (this.recovered.some((msg) => msg.includes('~/.claude/settings.json'))) { + if (this.recovered.some((msg) => msg.includes('settings.json') && msg.includes('.claude'))) { console.log(''); console.log(info('Next step: Login to Claude CLI')); console.log(' Run: claude /login'); diff --git a/src/management/repair/auto-repair.ts b/src/management/repair/auto-repair.ts index b769bbc7..861a30b6 100644 --- a/src/management/repair/auto-repair.ts +++ b/src/management/repair/auto-repair.ts @@ -4,8 +4,8 @@ import * as fs from 'fs'; import * as path from 'path'; -import * as os from 'os'; import { ok, warn, fail, info, header, color } from '../../utils/ui'; +import { getCcsDir } from '../../utils/config-manager'; import { CLIPROXY_DEFAULT_PORT, configNeedsRegeneration, @@ -28,8 +28,6 @@ const ora = createSpinner(); * 4. OAuth callback ports blocked by CLIProxy */ export async function runAutoRepair(): Promise { - const homedir = os.homedir(); - console.log(''); console.log(header('AUTO-FIX MODE')); console.log(''); @@ -111,7 +109,7 @@ export async function runAutoRepair(): Promise { // Fix 4: Fix shared symlinks (settings.json broken by Claude CLI toggle thinking, etc.) const symlinkSpinner = ora('Checking shared settings.json symlink').start(); - const sharedSettings = path.join(homedir, '.ccs', 'shared', 'settings.json'); + const sharedSettings = path.join(getCcsDir(), 'shared', 'settings.json'); try { if (fs.existsSync(sharedSettings)) { const stats = fs.lstatSync(sharedSettings); diff --git a/src/management/shared-manager.ts b/src/management/shared-manager.ts index ca6d29be..754e42e7 100644 --- a/src/management/shared-manager.ts +++ b/src/management/shared-manager.ts @@ -62,7 +62,7 @@ class SharedManager { const resolvedTarget = path.resolve(path.dirname(target), targetLink); // Check if target points back to our shared dir or link path - const sharedDir = path.join(this.homeDir, '.ccs', 'shared'); + const sharedDir = path.join(getCcsDir(), 'shared'); if (resolvedTarget.startsWith(sharedDir) || resolvedTarget === linkPath) { console.log(warn(`Circular symlink detected: ${target} → ${resolvedTarget}`)); return true; diff --git a/src/utils/claude-dir-installer.ts b/src/utils/claude-dir-installer.ts index 789e66aa..38ed9690 100644 --- a/src/utils/claude-dir-installer.ts +++ b/src/utils/claude-dir-installer.ts @@ -197,12 +197,7 @@ export class ClaudeDirInstaller { cleanupDeprecated(silent = false): CleanupResult { const deprecatedFile = path.join(this.ccsClaudeDir, 'agents', 'ccs-delegator.md'); const userSymlinkFile = path.join(this.homeDir, '.claude', 'agents', 'ccs-delegator.md'); - const migrationMarker = path.join( - this.homeDir, - '.ccs', - '.migrations', - 'v435-delegator-cleanup' - ); + const migrationMarker = path.join(getCcsDir(), '.migrations', 'v435-delegator-cleanup'); const cleanedFiles: string[] = []; diff --git a/src/utils/config-manager.ts b/src/utils/config-manager.ts index 655f5b30..c00d9319 100644 --- a/src/utils/config-manager.ts +++ b/src/utils/config-manager.ts @@ -10,6 +10,18 @@ import { isUnifiedMode, loadOrCreateUnifiedConfig } from '../config/unified-conf // const { ErrorManager } = require('./error-manager'); // const RecoveryManager = require('./recovery-manager'); +// Module-level state for --config-dir CLI flag override +let _globalConfigDir: string | undefined; + +/** + * Set global config directory from --config-dir CLI flag. + * Must be called before any config loading. + * Pass undefined to clear (useful for tests). + */ +export function setGlobalConfigDir(dir: string | undefined): void { + _globalConfigDir = dir ? path.resolve(dir) : undefined; +} + /** * Get the CCS home directory (respects CCS_HOME env var for test isolation) * @returns Home directory path @@ -19,11 +31,62 @@ export function getCcsHome(): string { } /** - * Get the CCS directory path (~/.ccs) - * @returns Path to .ccs directory + * Resolve the CCS directory with source information. + * Single source of truth for precedence logic. + */ +function _resolveCcsDir(): { source: string; dir: string } { + if (_globalConfigDir) return { source: '--config-dir', dir: _globalConfigDir }; + if (process.env.CCS_DIR) return { source: 'CCS_DIR', dir: path.resolve(process.env.CCS_DIR) }; + if (process.env.CCS_HOME) + return { source: 'CCS_HOME', dir: path.join(path.resolve(process.env.CCS_HOME), '.ccs') }; + return { source: 'default', dir: path.join(os.homedir(), '.ccs') }; +} + +/** + * Get the CCS directory path. + * Precedence: --config-dir flag > CCS_DIR env > CCS_HOME env (legacy, appends .ccs) > ~/.ccs default + * @returns Path to CCS config directory */ export function getCcsDir(): string { - return path.join(getCcsHome(), '.ccs'); + return _resolveCcsDir().dir; +} + +/** + * Get which source determined the CCS directory (for diagnostics). + * @returns [source_label, resolved_path] tuple + */ +export function getCcsDirSource(): [string, string] { + const r = _resolveCcsDir(); + return [r.source, r.dir]; +} + +/** + * Cloud sync folder patterns for security warning. + */ +const CLOUD_SYNC_PATTERNS = [ + 'Dropbox', + 'OneDrive', + 'Google Drive', + 'iCloud Drive', + 'iCloudDrive', + 'pCloud', + 'MEGA', + 'Box Sync', +]; + +/** + * Check if a path is under a cloud-synced folder. + * @returns Detected service name or null + */ +export function detectCloudSyncPath(dir: string): string | null { + const normalized = dir.replace(/\\/g, '/').toLowerCase(); + const segments = normalized.split('/'); + for (const pattern of CLOUD_SYNC_PATTERNS) { + const patternLower = pattern.toLowerCase(); + // Exact path segment match to avoid false positives (e.g., "megauser" != "MEGA") + if (segments.some((s) => s === patternLower)) return pattern; + } + return null; } /** @@ -39,7 +102,7 @@ export function getCcsHooksDir(): string { * @deprecated Use getActiveConfigPath() for mode-aware config path */ export function getConfigPath(): string { - return process.env.CCS_CONFIG || path.join(getCcsHome(), '.ccs', 'config.json'); + return process.env.CCS_CONFIG || path.join(getCcsDir(), 'config.json'); } /** diff --git a/src/utils/delegation-validator.ts b/src/utils/delegation-validator.ts index 76be898b..93d669dd 100644 --- a/src/utils/delegation-validator.ts +++ b/src/utils/delegation-validator.ts @@ -2,9 +2,9 @@ import * as fs from 'fs'; import * as path from 'path'; -import * as os from 'os'; import { Settings } from '../types'; import { ValidationResult } from '../types/utils'; +import { getCcsDir } from './config-manager'; /** * Extended validation result for delegation profiles @@ -28,8 +28,7 @@ export class DelegationValidator { * @returns Validation result { valid: boolean, error?: string, settingsPath?: string } */ static validate(profileName: string): DelegationValidationResult { - const homeDir = os.homedir(); - const settingsPath = path.join(homeDir, '.ccs', `${profileName}.settings.json`); + const settingsPath = path.join(getCcsDir(), `${profileName}.settings.json`); // Check if profile directory exists if (!fs.existsSync(settingsPath)) { @@ -144,8 +143,7 @@ export class DelegationValidator { * @returns List of profile names ready for delegation */ static getReadyProfiles(): string[] { - const homeDir = os.homedir(); - const ccsDir = path.join(homeDir, '.ccs'); + const ccsDir = getCcsDir(); const configPath = path.join(ccsDir, 'config.yaml'); if (!fs.existsSync(ccsDir)) { diff --git a/src/utils/shell-completion.ts b/src/utils/shell-completion.ts index 68419571..ae2479eb 100644 --- a/src/utils/shell-completion.ts +++ b/src/utils/shell-completion.ts @@ -1,6 +1,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +import { getCcsDir } from './config-manager'; // import { execSync } from 'child_process'; // Currently unused type ShellType = 'bash' | 'zsh' | 'fish' | 'powershell' | null; @@ -24,7 +25,7 @@ export class ShellCompletionInstaller { constructor() { this.homeDir = os.homedir(); - this.ccsDir = path.join(this.homeDir, '.ccs'); + this.ccsDir = getCcsDir(); this.completionDir = path.join(this.ccsDir, 'completions'); this.scriptsDir = path.join(__dirname, '../../scripts/completion'); } diff --git a/src/web-server/health-service.ts b/src/web-server/health-service.ts index 21a8723d..69703484 100644 --- a/src/web-server/health-service.ts +++ b/src/web-server/health-service.ts @@ -89,7 +89,7 @@ export async function runHealthChecks(): Promise { const healthChecks: HealthCheck[] = []; healthChecks.push(checkPermissions(ccsDir)); healthChecks.push(checkCcsSymlinks()); - healthChecks.push(checkSettingsSymlinks(homedir, ccsDir, claudeDir)); + healthChecks.push(checkSettingsSymlinks(ccsDir, claudeDir)); groups.push({ id: 'system-health', name: 'System Health', icon: 'Shield', checks: healthChecks }); // Group 6: CLIProxy @@ -139,7 +139,7 @@ export function fixHealthIssue(checkId: string): { success: boolean; message: st switch (checkId) { case 'ccs-dir': fs.mkdirSync(ccsDir, { recursive: true }); - return { success: true, message: 'Created ~/.ccs directory' }; + return { success: true, message: `Created ${ccsDir} directory` }; case 'config-file': { // Use appropriate config based on unified mode diff --git a/src/web-server/health/config-checks.ts b/src/web-server/health/config-checks.ts index 5f7ca34a..17fb04ac 100644 --- a/src/web-server/health/config-checks.ts +++ b/src/web-server/health/config-checks.ts @@ -6,9 +6,8 @@ */ import * as fs from 'fs'; -import * as os from 'os'; import * as path from 'path'; -import { getConfigPath } from '../../utils/config-manager'; +import { getConfigPath, getCcsDir } from '../../utils/config-manager'; import { isUnifiedMode, hasUnifiedConfig } from '../../config/unified-config-loader'; import type { HealthCheck } from './types'; @@ -18,7 +17,7 @@ import type { HealthCheck } from './types'; export function checkConfigFile(): HealthCheck { // In unified mode, check config.yaml if (isUnifiedMode() || hasUnifiedConfig()) { - const ccsDir = path.join(os.homedir(), '.ccs'); + const ccsDir = getCcsDir(); const yamlPath = path.join(ccsDir, 'config.yaml'); if (!fs.existsSync(yamlPath)) { diff --git a/src/web-server/health/symlink-checks.ts b/src/web-server/health/symlink-checks.ts index a5e4e0aa..40b90b5f 100644 --- a/src/web-server/health/symlink-checks.ts +++ b/src/web-server/health/symlink-checks.ts @@ -49,13 +49,9 @@ export function checkCcsSymlinks(): HealthCheck { /** * Check settings symlinks */ -export function checkSettingsSymlinks( - homedir: string, - ccsDir: string, - claudeDir: string -): HealthCheck { +export function checkSettingsSymlinks(ccsDir: string, claudeDir: string): HealthCheck { try { - const sharedDir = `${homedir}/.ccs/shared`; + const sharedDir = `${ccsDir}/shared`; const sharedSettings = `${sharedDir}/settings.json`; const claudeSettings = `${claudeDir}/settings.json`; diff --git a/src/web-server/middleware/auth-middleware.ts b/src/web-server/middleware/auth-middleware.ts index 248e303c..044963d7 100644 --- a/src/web-server/middleware/auth-middleware.ts +++ b/src/web-server/middleware/auth-middleware.ts @@ -10,7 +10,7 @@ import { getDashboardAuthConfig } from '../../config/unified-config-loader'; import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; -import os from 'os'; +import { getCcsDir } from '../../utils/config-manager'; // Extend Express Request with session declare module 'express-session' { @@ -24,7 +24,9 @@ declare module 'express-session' { const PUBLIC_PATHS = ['/api/auth/login', '/api/auth/check', '/api/auth/setup', '/api/health']; /** Path to persistent session secret file */ -const SESSION_SECRET_PATH = path.join(os.homedir(), '.ccs', '.session-secret'); +function getSessionSecretPath() { + return path.join(getCcsDir(), '.session-secret'); +} /** * Generate or retrieve persistent session secret. @@ -36,10 +38,12 @@ function getSessionSecret(): string { return process.env.CCS_SESSION_SECRET; } + const secretPath = getSessionSecretPath(); + // 2. Try to read persisted secret try { - if (fs.existsSync(SESSION_SECRET_PATH)) { - const secret = fs.readFileSync(SESSION_SECRET_PATH, 'utf-8').trim(); + if (fs.existsSync(secretPath)) { + const secret = fs.readFileSync(secretPath, 'utf-8').trim(); if (secret.length >= 32) { return secret; } @@ -51,11 +55,11 @@ function getSessionSecret(): string { // 3. Generate and persist new random secret const newSecret = crypto.randomBytes(32).toString('hex'); try { - const dir = path.dirname(SESSION_SECRET_PATH); + const dir = path.dirname(secretPath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } - fs.writeFileSync(SESSION_SECRET_PATH, newSecret, { mode: 0o600 }); + fs.writeFileSync(secretPath, newSecret, { mode: 0o600 }); } catch (err) { // Log warning - sessions won't persist across restarts console.warn('[!] Failed to persist session secret:', (err as Error).message); diff --git a/src/web-server/usage/aggregator.ts b/src/web-server/usage/aggregator.ts index ce59ba07..5d30676e 100644 --- a/src/web-server/usage/aggregator.ts +++ b/src/web-server/usage/aggregator.ts @@ -7,7 +7,6 @@ import * as fs from 'fs'; import * as path from 'path'; -import * as os from 'os'; import { loadDailyUsageData, loadMonthlyUsageData, @@ -25,28 +24,32 @@ import { getCacheAge, } from './disk-cache'; import { ok, info, fail } from '../../utils/ui'; +import { getCcsDir } from '../../utils/config-manager'; // ============================================================================ // Multi-Instance Support - Aggregate usage from CCS profiles // ============================================================================ /** Path to CCS instances directory */ -const CCS_INSTANCES_DIR = path.join(os.homedir(), '.ccs', 'instances'); +function getCcsInstancesDir() { + return path.join(getCcsDir(), 'instances'); +} /** * Get list of CCS instance paths that have usage data * Only returns instances with existing projects/ directory */ function getInstancePaths(): string[] { - if (!fs.existsSync(CCS_INSTANCES_DIR)) { + const instancesDir = getCcsInstancesDir(); + if (!fs.existsSync(instancesDir)) { return []; } try { - const entries = fs.readdirSync(CCS_INSTANCES_DIR, { withFileTypes: true }); + const entries = fs.readdirSync(instancesDir, { withFileTypes: true }); return entries .filter((entry) => entry.isDirectory()) - .map((entry) => path.join(CCS_INSTANCES_DIR, entry.name)) + .map((entry) => path.join(instancesDir, entry.name)) .filter((instancePath) => { // Only include instances that have a projects directory const projectsPath = path.join(instancePath, 'projects'); diff --git a/src/web-server/usage/disk-cache.ts b/src/web-server/usage/disk-cache.ts index fd834da2..523169c9 100644 --- a/src/web-server/usage/disk-cache.ts +++ b/src/web-server/usage/disk-cache.ts @@ -10,14 +10,17 @@ import * as fs from 'fs'; import * as path from 'path'; -import * as os from 'os'; import type { DailyUsage, HourlyUsage, MonthlyUsage, SessionUsage } from './types'; import { ok, info, warn } from '../../utils/ui'; +import { getCcsDir } from '../../utils/config-manager'; // Cache configuration -const CCS_DIR = path.join(os.homedir(), '.ccs'); -const CACHE_DIR = path.join(CCS_DIR, 'cache'); -const CACHE_FILE = path.join(CACHE_DIR, 'usage.json'); +function getCacheDir() { + return path.join(getCcsDir(), 'cache'); +} +function getCacheFile() { + return path.join(getCacheDir(), 'usage.json'); +} const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes const STALE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days (max age for stale data) @@ -39,8 +42,9 @@ const CACHE_VERSION = 3; * Ensure ~/.ccs/cache directory exists */ function ensureCacheDir(): void { - if (!fs.existsSync(CACHE_DIR)) { - fs.mkdirSync(CACHE_DIR, { recursive: true }); + const dir = getCacheDir(); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); } } @@ -51,11 +55,12 @@ function ensureCacheDir(): void { */ export function readDiskCache(): UsageDiskCache | null { try { - if (!fs.existsSync(CACHE_FILE)) { + const cacheFile = getCacheFile(); + if (!fs.existsSync(cacheFile)) { return null; } - const data = fs.readFileSync(CACHE_FILE, 'utf-8'); + const data = fs.readFileSync(cacheFile, 'utf-8'); const cache: UsageDiskCache = JSON.parse(data); // Version check - invalidate if schema changed @@ -113,9 +118,10 @@ export function writeDiskCache( }; // Write atomically using temp file + rename - const tempFile = CACHE_FILE + '.tmp'; + const cacheFile = getCacheFile(); + const tempFile = cacheFile + '.tmp'; fs.writeFileSync(tempFile, JSON.stringify(cache), 'utf-8'); - fs.renameSync(tempFile, CACHE_FILE); + fs.renameSync(tempFile, cacheFile); console.log(ok('Disk cache updated')); } catch (err) { @@ -145,8 +151,9 @@ export function getCacheAge(cache: UsageDiskCache | null): string { */ export function clearDiskCache(): void { try { - if (fs.existsSync(CACHE_FILE)) { - fs.unlinkSync(CACHE_FILE); + const cacheFile = getCacheFile(); + if (fs.existsSync(cacheFile)) { + fs.unlinkSync(cacheFile); console.log(ok('Disk cache cleared')); } } catch (err) { diff --git a/tests/unit/config-dir-override.test.js b/tests/unit/config-dir-override.test.js new file mode 100644 index 00000000..29ca2435 --- /dev/null +++ b/tests/unit/config-dir-override.test.js @@ -0,0 +1,163 @@ +/** + * Config Directory Override Unit Tests + * + * Tests CCS_DIR env var and --config-dir flag precedence, + * getCcsDirSource() diagnostic, and cloud sync path detection. + */ + +const assert = require('assert'); +const path = require('path'); +const os = require('os'); + +describe('Config Directory Override', function () { + let originalCcsDir; + let originalCcsHome; + + beforeEach(function () { + originalCcsDir = process.env.CCS_DIR; + originalCcsHome = process.env.CCS_HOME; + delete process.env.CCS_DIR; + delete process.env.CCS_HOME; + // Reset module-level state + const { setGlobalConfigDir } = require('../../dist/utils/config-manager'); + setGlobalConfigDir(undefined); + }); + + afterEach(function () { + if (originalCcsDir !== undefined) process.env.CCS_DIR = originalCcsDir; + else delete process.env.CCS_DIR; + if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; + else delete process.env.CCS_HOME; + // Reset module-level state + const { setGlobalConfigDir } = require('../../dist/utils/config-manager'); + setGlobalConfigDir(undefined); + }); + + describe('getCcsDir() precedence', function () { + it('should return ~/.ccs by default', function () { + const { getCcsDir } = require('../../dist/utils/config-manager'); + const expected = path.join(os.homedir(), '.ccs'); + assert.strictEqual(getCcsDir(), expected); + }); + + it('should use CCS_HOME with .ccs appended (legacy behavior)', function () { + process.env.CCS_HOME = '/tmp/test-home'; + const { getCcsDir } = require('../../dist/utils/config-manager'); + assert.strictEqual(getCcsDir(), '/tmp/test-home/.ccs'); + }); + + it('should use CCS_DIR directly (no .ccs append)', function () { + process.env.CCS_DIR = '/tmp/my-ccs-config'; + const { getCcsDir } = require('../../dist/utils/config-manager'); + assert.strictEqual(getCcsDir(), path.resolve('/tmp/my-ccs-config')); + }); + + it('should give CCS_DIR precedence over CCS_HOME', function () { + process.env.CCS_DIR = '/tmp/ccs-dir'; + process.env.CCS_HOME = '/tmp/ccs-home'; + const { getCcsDir } = require('../../dist/utils/config-manager'); + assert.strictEqual(getCcsDir(), path.resolve('/tmp/ccs-dir')); + }); + + it('should give setGlobalConfigDir() highest precedence', function () { + process.env.CCS_DIR = '/tmp/env-dir'; + process.env.CCS_HOME = '/tmp/env-home'; + const { getCcsDir, setGlobalConfigDir } = require('../../dist/utils/config-manager'); + setGlobalConfigDir('/tmp/flag-dir'); + assert.strictEqual(getCcsDir(), path.resolve('/tmp/flag-dir')); + }); + + it('should resolve relative paths in setGlobalConfigDir()', function () { + const { getCcsDir, setGlobalConfigDir } = require('../../dist/utils/config-manager'); + setGlobalConfigDir('relative/path'); + assert.strictEqual(getCcsDir(), path.resolve('relative/path')); + }); + + it('should clear override when setGlobalConfigDir(undefined) is called', function () { + const { getCcsDir, setGlobalConfigDir } = require('../../dist/utils/config-manager'); + setGlobalConfigDir('/tmp/override'); + assert.strictEqual(getCcsDir(), path.resolve('/tmp/override')); + setGlobalConfigDir(undefined); + const expected = path.join(os.homedir(), '.ccs'); + assert.strictEqual(getCcsDir(), expected); + }); + }); + + describe('getCcsDirSource()', function () { + it('should return "default" when no overrides set', function () { + const { getCcsDirSource } = require('../../dist/utils/config-manager'); + const [source] = getCcsDirSource(); + assert.strictEqual(source, 'default'); + }); + + it('should return "CCS_DIR" when CCS_DIR is set', function () { + process.env.CCS_DIR = '/tmp/test'; + const { getCcsDirSource } = require('../../dist/utils/config-manager'); + const [source] = getCcsDirSource(); + assert.strictEqual(source, 'CCS_DIR'); + }); + + it('should return "CCS_HOME" when only CCS_HOME is set', function () { + process.env.CCS_HOME = '/tmp/home'; + const { getCcsDirSource } = require('../../dist/utils/config-manager'); + const [source] = getCcsDirSource(); + assert.strictEqual(source, 'CCS_HOME'); + }); + + it('should return "--config-dir" when setGlobalConfigDir() is set', function () { + const { getCcsDirSource, setGlobalConfigDir } = require('../../dist/utils/config-manager'); + setGlobalConfigDir('/tmp/flag-test'); + const [source, dir] = getCcsDirSource(); + assert.strictEqual(source, '--config-dir'); + assert.strictEqual(dir, path.resolve('/tmp/flag-test')); + }); + }); + + describe('detectCloudSyncPath()', function () { + it('should detect Dropbox', function () { + const { detectCloudSyncPath } = require('../../dist/utils/config-manager'); + assert.strictEqual(detectCloudSyncPath('/Users/kai/Dropbox/ccs'), 'Dropbox'); + }); + + it('should detect OneDrive on Windows paths', function () { + const { detectCloudSyncPath } = require('../../dist/utils/config-manager'); + assert.strictEqual(detectCloudSyncPath('C:\\Users\\kai\\OneDrive\\ccs'), 'OneDrive'); + }); + + it('should detect iCloud Drive', function () { + const { detectCloudSyncPath } = require('../../dist/utils/config-manager'); + assert.strictEqual( + detectCloudSyncPath('/Users/kai/Library/Mobile Documents/iCloud Drive/ccs'), + 'iCloud Drive' + ); + }); + + it('should detect Google Drive', function () { + const { detectCloudSyncPath } = require('../../dist/utils/config-manager'); + assert.strictEqual(detectCloudSyncPath('/Users/kai/Google Drive/ccs'), 'Google Drive'); + }); + + it('should detect cloud paths case-insensitively', function () { + const { detectCloudSyncPath } = require('../../dist/utils/config-manager'); + assert.strictEqual(detectCloudSyncPath('/Users/kai/dropbox/ccs'), 'Dropbox'); + assert.strictEqual(detectCloudSyncPath('C:\\Users\\kai\\onedrive\\ccs'), 'OneDrive'); + assert.strictEqual(detectCloudSyncPath('/Users/kai/google drive/ccs'), 'Google Drive'); + }); + + it('should return null for regular paths', function () { + const { detectCloudSyncPath } = require('../../dist/utils/config-manager'); + assert.strictEqual(detectCloudSyncPath('/home/kai/.ccs'), null); + }); + + it('should return null for default ~/.ccs path', function () { + const { detectCloudSyncPath } = require('../../dist/utils/config-manager'); + assert.strictEqual(detectCloudSyncPath(path.join(os.homedir(), '.ccs')), null); + }); + + it('should not false-positive on substrings (e.g., megauser, Dropbox-api)', function () { + const { detectCloudSyncPath } = require('../../dist/utils/config-manager'); + assert.strictEqual(detectCloudSyncPath('/home/megauser/.ccs'), null); + assert.strictEqual(detectCloudSyncPath('/home/kai/Dropbox-api-client/ccs'), null); + }); + }); +});