diff --git a/src/ccs.ts b/src/ccs.ts index 5150ff00..cf11ea69 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -15,7 +15,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'; @@ -259,7 +259,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 +267,55 @@ 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); + } + + if (!fs.existsSync(configDirValue)) { + 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); + } + + const { setGlobalConfigDir, detectCloudSyncPath } = await import('./utils/config-manager'); + 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.'); + } + + // 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 { detectCloudSyncPath } = await import('./utils/config-manager'); + 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.'); + } + } + + 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/commands/help-command.ts b/src/commands/help-command.ts index 2097453f..4f2c1d59 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( @@ -259,17 +260,20 @@ 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'], ]); // Configuration + const [dirSource] = getCcsDirSource(); + const dirDisplay = dirSource === 'default' ? '~/.ccs' : getCcsDir(); 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 @@ -336,6 +340,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'], @@ -349,17 +360,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/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/utils/config-manager.ts b/src/utils/config-manager.ts index 655f5b30..ff8c6b6b 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,54 @@ export function getCcsHome(): string { } /** - * Get the CCS directory path (~/.ccs) - * @returns Path to .ccs directory + * 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'); + if (_globalConfigDir) return _globalConfigDir; + if (process.env.CCS_DIR) return path.resolve(process.env.CCS_DIR); + if (process.env.CCS_HOME) return path.join(process.env.CCS_HOME, '.ccs'); + return path.join(os.homedir(), '.ccs'); +} + +/** + * Get which source determined the CCS directory (for diagnostics). + * @returns [source_label, resolved_path] tuple + */ +export function getCcsDirSource(): [string, string] { + if (_globalConfigDir) return ['--config-dir', _globalConfigDir]; + if (process.env.CCS_DIR) return ['CCS_DIR', path.resolve(process.env.CCS_DIR)]; + if (process.env.CCS_HOME) return ['CCS_HOME', path.join(process.env.CCS_HOME, '.ccs')]; + return ['default', path.join(os.homedir(), '.ccs')]; +} + +/** + * 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, '/'); + for (const pattern of CLOUD_SYNC_PATTERNS) { + if (normalized.includes(pattern)) { + return pattern; + } + } + return null; } /** @@ -39,7 +94,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/tests/unit/config-dir-override.test.js b/tests/unit/config-dir-override.test.js new file mode 100644 index 00000000..5e075f81 --- /dev/null +++ b/tests/unit/config-dir-override.test.js @@ -0,0 +1,113 @@ +/** + * 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; + }); + + 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; + }); + + 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')); + }); + }); + + 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'); + }); + }); + + 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 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); + }); + }); +});