feat(config): add CCS_DIR env var and --config-dir flag for config directory override

Allow users to relocate the entire ~/.ccs/ directory via CCS_DIR env var
or --config-dir CLI flag. Precedence: --config-dir > CCS_DIR > CCS_HOME > default.
Includes cloud sync path detection warning and doctor diagnostics.

Closes #507
This commit is contained in:
Tam Nhu Tran
2026-02-11 10:55:00 +07:00
parent 372b04e6a9
commit 7a0e6a4112
5 changed files with 250 additions and 16 deletions
+50 -2
View File
@@ -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<boolean> {
async function main(): Promise<void> {
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<void> {
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 = [
+21 -10
View File
@@ -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 <path>', '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('');
+7
View File
@@ -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',
+59 -4
View File
@@ -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');
}
/**
+113
View File
@@ -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);
});
});
});