mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 08:17:22 +00:00
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:
+50
-2
@@ -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 = [
|
||||
|
||||
@@ -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('');
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user