Merge pull request #508 from kaitranntt/kai/feat/507-config-dir-override

feat(config): add CCS_DIR env var and --config-dir flag
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-02-11 12:11:34 +07:00
commit 355a127910
22 files changed
+421 -109

No files matched your search

+10 -6
View File
@@ -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(),
+73 -5
View File
@@ -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<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 +273,69 @@ 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);
}
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 = [
+3 -1
View File
@@ -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,
+4 -6
View File
@@ -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
+24 -11
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(
@@ -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 <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'],
@@ -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('');
+5 -6
View File
@@ -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<void> {
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'));
+2 -1
View File
@@ -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);
}
}
+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',
+11 -18
View File
@@ -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');
+2 -4
View File
@@ -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<void> {
const homedir = os.homedir();
console.log('');
console.log(header('AUTO-FIX MODE'));
console.log('');
@@ -111,7 +109,7 @@ export async function runAutoRepair(): Promise<void> {
// 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);
+1 -1
View File
@@ -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;
+1 -6
View File
@@ -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[] = [];
+67 -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,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');
}
/**
+3 -5
View File
@@ -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)) {
+2 -1
View File
@@ -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');
}
+2 -2
View File
@@ -89,7 +89,7 @@ export async function runHealthChecks(): Promise<HealthReport> {
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
+2 -3
View File
@@ -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)) {
+2 -6
View File
@@ -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`;
+10 -6
View File
@@ -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);
+8 -5
View File
@@ -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');
+19 -12
View File
@@ -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) {
+163
View File
@@ -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);
});
});
});