Merge pull request #596 from kaitranntt/feat/refactor-hardcode-dx-one-pr

refactor(core): centralize hardcoded paths and command/provider sources
This commit is contained in:
Kai (Tam Nhu) Tran
2026-02-21 10:40:51 +07:00
committed by GitHub
30 changed files with 1364 additions and 681 deletions
+71 -113
View File
@@ -433,56 +433,6 @@ async function main(): Promise<void> {
console.warn('[!] Recovery failed:', (err as Error).message);
}
// Special case: version command (check BEFORE profile detection)
if (firstArg === 'version' || firstArg === '--version' || firstArg === '-v') {
await handleVersionCommand();
return;
}
// Special case: help command
if (firstArg === '--help' || firstArg === '-h' || firstArg === 'help') {
await handleHelpCommand();
return;
}
// Special case: install command
if (firstArg === '--install') {
handleInstallCommand();
return;
}
// Special case: uninstall command
if (firstArg === '--uninstall') {
handleUninstallCommand();
return;
}
// Special case: shell completion installer
if (firstArg === '--shell-completion' || firstArg === '-sc') {
await handleShellCompletionCommand(args.slice(1));
return;
}
// Special case: doctor command
if (firstArg === 'doctor' || firstArg === '--doctor') {
const restArgs = args.slice(args.indexOf(firstArg) + 1);
await handleDoctorCommand(restArgs);
return;
}
// Special case: sync command
if (firstArg === 'sync' || firstArg === '--sync') {
await handleSyncCommand();
return;
}
// Special case: cleanup command
if (firstArg === 'cleanup' || firstArg === '--cleanup') {
const { handleCleanupCommand } = await import('./commands/cleanup-command');
await handleCleanupCommand(args.slice(1));
return;
}
// Special case: migrate command
if (firstArg === 'migrate' || firstArg === '--migrate') {
const { handleMigrateCommand, printMigrateHelp } = await import('./commands/migrate-command');
@@ -525,72 +475,80 @@ async function main(): Promise<void> {
return;
}
// Special case: auth command
if (firstArg === 'auth') {
const AuthCommandsModule = await import('./auth/auth-commands');
const AuthCommands = AuthCommandsModule.default;
const authCommands = new AuthCommands();
await authCommands.route(args.slice(1));
const commandAliases: Record<string, string> = {
'--version': 'version',
'-v': 'version',
'--help': 'help',
'-h': 'help',
'--doctor': 'doctor',
'--sync': 'sync',
'--cleanup': 'cleanup',
'--setup': 'setup',
};
const normalizedFirstArg = commandAliases[firstArg] || firstArg;
const earlyCommandHandlers: Record<string, () => Promise<void>> = {
version: async () => handleVersionCommand(),
help: async () => handleHelpCommand(),
'--install': async () => handleInstallCommand(),
'--uninstall': async () => handleUninstallCommand(),
'--shell-completion': async () => handleShellCompletionCommand(args.slice(1)),
'-sc': async () => handleShellCompletionCommand(args.slice(1)),
doctor: async () => handleDoctorCommand(args.slice(1)),
sync: async () => handleSyncCommand(),
cleanup: async () => {
const { handleCleanupCommand } = await import('./commands/cleanup-command');
await handleCleanupCommand(args.slice(1));
},
auth: async () => {
const AuthCommandsModule = await import('./auth/auth-commands');
const AuthCommands = AuthCommandsModule.default;
const authCommands = new AuthCommands();
await authCommands.route(args.slice(1));
},
api: async () => {
const { handleApiCommand } = await import('./commands/api-command');
await handleApiCommand(args.slice(1));
},
cliproxy: async () => {
const { handleCliproxyCommand } = await import('./commands/cliproxy-command');
await handleCliproxyCommand(args.slice(1));
},
config: async () => {
const { handleConfigCommand } = await import('./commands/config-command');
await handleConfigCommand(args.slice(1));
},
tokens: async () => {
const { handleTokensCommand } = await import('./commands/tokens-command');
const exitCode = await handleTokensCommand(args.slice(1));
process.exit(exitCode);
},
persist: async () => {
const { handlePersistCommand } = await import('./commands/persist-command');
await handlePersistCommand(args.slice(1));
},
env: async () => {
const { handleEnvCommand } = await import('./commands/env-command');
await handleEnvCommand(args.slice(1));
},
setup: async () => {
const { handleSetupCommand } = await import('./commands/setup-command');
await handleSetupCommand(args.slice(1));
},
cursor: async () => {
const { handleCursorCommand } = await import('./commands/cursor-command');
const exitCode = await handleCursorCommand(args.slice(1));
process.exit(exitCode);
},
};
const earlyCommandHandler = earlyCommandHandlers[normalizedFirstArg];
if (earlyCommandHandler) {
await earlyCommandHandler();
return;
}
// Special case: api command (manages API profiles)
if (firstArg === 'api') {
const { handleApiCommand } = await import('./commands/api-command');
await handleApiCommand(args.slice(1));
return;
}
// Special case: cliproxy command (manages CLIProxyAPI binary)
if (firstArg === 'cliproxy') {
const { handleCliproxyCommand } = await import('./commands/cliproxy-command');
await handleCliproxyCommand(args.slice(1));
return;
}
// Special case: config command (web dashboard)
if (firstArg === 'config') {
const { handleConfigCommand } = await import('./commands/config-command');
await handleConfigCommand(args.slice(1));
return;
}
// Special case: tokens command (auth token management)
if (firstArg === 'tokens') {
const { handleTokensCommand } = await import('./commands/tokens-command');
const exitCode = await handleTokensCommand(args.slice(1));
process.exit(exitCode);
}
// Special case: persist command (write profile env to ~/.claude/settings.json)
if (firstArg === 'persist') {
const { handlePersistCommand } = await import('./commands/persist-command');
await handlePersistCommand(args.slice(1));
return;
}
// Special case: env command (export env vars for third-party tools)
if (firstArg === 'env') {
const { handleEnvCommand } = await import('./commands/env-command');
await handleEnvCommand(args.slice(1));
return;
}
// Special case: setup command (first-time wizard)
if (firstArg === 'setup' || firstArg === '--setup') {
const { handleSetupCommand } = await import('./commands/setup-command');
await handleSetupCommand(args.slice(1));
return;
}
// Special case: cursor command (Cursor IDE integration)
// All `ccs cursor *` routes to cursor command handler — cursor has no profile-switching mode
if (firstArg === 'cursor') {
const { handleCursorCommand } = await import('./commands/cursor-command');
const exitCode = await handleCursorCommand(args.slice(1));
process.exit(exitCode);
}
// Special case: copilot command (GitHub Copilot integration)
// Only route to command handler for known subcommands, otherwise treat as profile
const COPILOT_SUBCOMMANDS = [
+12 -24
View File
@@ -10,26 +10,17 @@
*/
import { CLIProxyProvider } from '../types';
import { supportsExtendedContext, isNativeGeminiModel } from '../model-catalog';
import { supportsExtendedContext } from '../model-catalog';
import { warn } from '../../utils/ui';
import {
applyExtendedContextSuffix as applyExtendedContextSuffixShared,
isNativeGeminiModel,
stripExtendedContextSuffix,
} from '../../shared/extended-context-utils';
/** Extended context suffix recognized by Claude Code */
const EXTENDED_CONTEXT_SUFFIX = '[1m]';
/**
* Apply extended context suffix to model name.
* Appends [1m] suffix if not already present.
*
* @param model - Model name (may include thinking suffix like "model(high)")
* @returns Model name with [1m] suffix, e.g., "gemini-2.5-pro[1m]" or "gemini-2.5-pro(high)[1m]"
*/
export function applyExtendedContextSuffix(model: string): string {
if (!model) return model;
// Case-insensitive check to avoid double suffix (handles [1M], [1m], etc.)
if (model.toLowerCase().endsWith(EXTENDED_CONTEXT_SUFFIX.toLowerCase())) {
return model;
}
return `${model}${EXTENDED_CONTEXT_SUFFIX}`;
// Backward-compatible export retained for tests/importers that reference this module.
export function applyExtendedContextSuffix(modelId: string): string {
return applyExtendedContextSuffixShared(modelId);
}
/**
@@ -109,7 +100,7 @@ export function applyExtendedContextConfig(
// Apply suffix to main model
if (envVars.ANTHROPIC_MODEL) {
envVars.ANTHROPIC_MODEL = applyExtendedContextSuffix(envVars.ANTHROPIC_MODEL);
envVars.ANTHROPIC_MODEL = applyExtendedContextSuffixShared(envVars.ANTHROPIC_MODEL);
}
// Apply to tier models if they support extended context
@@ -119,7 +110,7 @@ export function applyExtendedContextConfig(
if (model) {
const tierCleanId = stripModelSuffixes(model);
if (shouldApplyExtendedContext(provider, tierCleanId, extendedContextOverride)) {
envVars[tierVar] = applyExtendedContextSuffix(model);
envVars[tierVar] = applyExtendedContextSuffixShared(model);
}
}
}
@@ -133,8 +124,5 @@ export function applyExtendedContextConfig(
* "gemini-2.5-pro" -> "gemini-2.5-pro"
*/
function stripModelSuffixes(modelId: string): string {
return modelId
.trim()
.replace(/\[1m\]$/i, '') // Remove [1m] suffix
.replace(/\([^)]+\)$/, ''); // Remove thinking suffix like (high) or (8192)
return stripExtendedContextSuffix(modelId.trim()).replace(/\([^)]+\)$/, '');
}
+29 -1
View File
@@ -140,6 +140,25 @@ export const CLIPROXY_PROVIDER_IDS = Object.freeze(
Object.keys(PROVIDER_CAPABILITIES) as CLIProxyProvider[]
);
/** Providers currently supported by quota status fetchers. */
export const QUOTA_SUPPORTED_PROVIDER_IDS = Object.freeze([
'agy',
'codex',
'gemini',
'ghcp',
] as const);
export type QuotaSupportedProvider = (typeof QUOTA_SUPPORTED_PROVIDER_IDS)[number];
const QUOTA_SUPPORTED_PROVIDER_SET = new Set<QuotaSupportedProvider>(QUOTA_SUPPORTED_PROVIDER_IDS);
export const QUOTA_PROVIDER_OPTION_VALUES = Object.freeze(
[
...QUOTA_SUPPORTED_PROVIDER_IDS,
...QUOTA_SUPPORTED_PROVIDER_IDS.flatMap((provider) => PROVIDER_CAPABILITIES[provider].aliases),
'all',
].filter((value, index, values) => values.indexOf(value) === index)
);
export const QUOTA_PROVIDER_HELP_TEXT = QUOTA_PROVIDER_OPTION_VALUES.join(', ');
export function buildProviderMap<T>(
valueFor: (provider: CLIProxyProvider) => T
): Record<CLIProxyProvider, T> {
@@ -243,6 +262,15 @@ export function getProviderTokenTypeValues(provider: CLIProxyProvider): readonly
}
export function mapExternalProviderName(providerName: string): CLIProxyProvider | null {
const normalized = providerName.toLowerCase();
const normalized = providerName.trim().toLowerCase();
if (!normalized) {
return null;
}
return PROVIDER_ALIAS_MAP.get(normalized) ?? null;
}
export function isQuotaSupportedProvider(
provider: CLIProxyProvider
): provider is QuotaSupportedProvider {
return QUOTA_SUPPORTED_PROVIDER_SET.has(provider as QuotaSupportedProvider);
}
+1 -1
View File
@@ -3,7 +3,7 @@
* Types for CLIProxyAPI binary management and execution
*/
import { CompositeTierConfig } from '../config/unified-config-types';
import type { CompositeTierConfig } from '../config/unified-config-types';
/**
* Supported operating systems
+127 -21
View File
@@ -43,6 +43,7 @@ import {
type ProviderPreset,
} from '../api/services';
import { syncToLocalConfig } from '../cliproxy/sync/local-config-sync';
import { extractOption, hasAnyFlag } from './arg-extractor';
interface ApiCommandArgs {
name?: string;
@@ -52,8 +53,14 @@ interface ApiCommandArgs {
preset?: string;
force?: boolean;
yes?: boolean;
errors: string[];
}
const API_BOOLEAN_FLAGS = ['--force', '--yes', '-y'] as const;
const API_VALUE_FLAGS = ['--base-url', '--api-key', '--model', '--preset'] as const;
const API_KNOWN_FLAGS: readonly string[] = [...API_BOOLEAN_FLAGS, ...API_VALUE_FLAGS];
const API_VALUE_FLAG_SET = new Set<string>(API_VALUE_FLAGS);
function sanitizeHelpText(value: string): string {
return value
.replace(/[\r\n\t]+/g, ' ')
@@ -70,37 +77,129 @@ function renderPresetHelpLine(preset: ProviderPreset, idWidth: number): string {
return ` ${color(paddedId, 'command')} ${presetName} - ${presetDescription}`;
}
/** Parse command line arguments for api commands */
function parseArgs(args: string[]): ApiCommandArgs {
const result: ApiCommandArgs = {};
function applyRepeatedOption(
args: string[],
flags: readonly string[],
onValue: (value: string) => void,
onMissing: () => void
): string[] {
let remaining = [...args];
while (true) {
const extracted = extractOption(remaining, flags, {
allowDashValue: true,
knownFlags: API_KNOWN_FLAGS,
});
if (!extracted.found) {
return remaining;
}
if (extracted.missingValue || !extracted.value) {
onMissing();
} else {
onValue(extracted.value);
}
remaining = extracted.remainingArgs;
}
}
function extractPositionalArgs(args: string[]): string[] {
const positionals: string[] = [];
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--base-url' && args[i + 1]) {
result.baseUrl = args[++i];
} else if (arg === '--api-key' && args[i + 1]) {
result.apiKey = args[++i];
} else if (arg === '--model' && args[i + 1]) {
result.model = args[++i];
} else if (arg === '--preset' && args[i + 1]) {
result.preset = args[++i];
} else if (arg === '--force') {
result.force = true;
} else if (arg === '--yes' || arg === '-y') {
result.yes = true;
} else if (!arg.startsWith('-') && !result.name) {
result.name = arg;
const token = args[i];
if (token === '--') {
positionals.push(...args.slice(i + 1));
break;
}
if (token.startsWith('-')) {
if (!token.includes('=') && API_VALUE_FLAG_SET.has(token)) {
const next = args[i + 1];
if (next && !next.startsWith('-')) {
i++;
}
}
continue;
}
positionals.push(token);
}
return positionals;
}
/** Parse command line arguments for api commands */
export function parseApiCommandArgs(args: string[]): ApiCommandArgs {
const result: ApiCommandArgs = {
force: hasAnyFlag(args, ['--force']),
yes: hasAnyFlag(args, ['--yes', '-y']),
errors: [],
};
let remaining = [...args];
remaining = applyRepeatedOption(
remaining,
['--base-url'],
(value) => {
result.baseUrl = value;
},
() => {
result.errors.push('Missing value for --base-url');
}
);
remaining = applyRepeatedOption(
remaining,
['--api-key'],
(value) => {
result.apiKey = value;
},
() => {
result.errors.push('Missing value for --api-key');
}
);
remaining = applyRepeatedOption(
remaining,
['--model'],
(value) => {
result.model = value;
},
() => {
result.errors.push('Missing value for --model');
}
);
remaining = applyRepeatedOption(
remaining,
['--preset'],
(value) => {
result.preset = value;
},
() => {
result.errors.push('Missing value for --preset');
}
);
const positionalArgs = extractPositionalArgs(remaining);
result.name = positionalArgs[0];
return result;
}
/** Handle 'ccs api create' command */
async function handleCreate(args: string[]): Promise<void> {
await initUI();
const parsedArgs = parseArgs(args);
const parsedArgs = parseApiCommandArgs(args);
if (parsedArgs.errors.length > 0) {
parsedArgs.errors.forEach((errorMessage) => {
console.log(fail(errorMessage));
});
process.exit(1);
}
console.log(header('Create API Profile'));
console.log('');
@@ -386,7 +485,14 @@ async function handleList(): Promise<void> {
/** Handle 'ccs api remove' command */
async function handleRemove(args: string[]): Promise<void> {
await initUI();
const parsedArgs = parseArgs(args);
const parsedArgs = parseApiCommandArgs(args);
if (parsedArgs.errors.length > 0) {
parsedArgs.errors.forEach((errorMessage) => {
console.log(fail(errorMessage));
});
process.exit(1);
}
const apis = getApiProfileNames();
+114
View File
@@ -0,0 +1,114 @@
/**
* Small helpers for consistent CLI option extraction.
*/
export interface ExtractedOption {
found: boolean;
value?: string;
missingValue: boolean;
remainingArgs: string[];
}
export interface ExtractOptionOptions {
/**
* Allow values that start with "-" when they are not recognized flags.
* Useful for model IDs or other arbitrary strings.
*/
allowDashValue?: boolean;
/**
* Known flags for the current command. Used with allowDashValue to avoid
* treating a real flag token as a value.
*/
knownFlags?: readonly string[];
}
function findInlineOption(arg: string, flag: string): string | undefined {
const prefix = `${flag}=`;
return arg.startsWith(prefix) ? arg.slice(prefix.length) : undefined;
}
function isKnownFlagToken(token: string, knownFlags: readonly string[] | undefined): boolean {
if (!knownFlags || knownFlags.length === 0) {
return false;
}
return knownFlags.some((flag) => token === flag || token.startsWith(`${flag}=`));
}
/**
* Extract a single-value option and remove it from args.
* Supports `--flag value` and `--flag=value` forms.
*/
export function extractOption(
args: string[],
flags: readonly string[],
options: ExtractOptionOptions = {}
): ExtractedOption {
const remaining = [...args];
const allowDashValue = options.allowDashValue ?? false;
for (let i = 0; i < remaining.length; i++) {
const token = remaining[i];
for (const flag of flags) {
if (token === flag) {
const next = remaining[i + 1];
if (!next) {
remaining.splice(i, 1);
return { found: true, missingValue: true, remainingArgs: remaining };
}
const nextLooksLikeFlag = next.startsWith('-');
const nextIsKnownFlag = isKnownFlagToken(next, options.knownFlags);
if (nextLooksLikeFlag && (!allowDashValue || nextIsKnownFlag)) {
remaining.splice(i, 1);
return { found: true, missingValue: true, remainingArgs: remaining };
}
remaining.splice(i, 2);
return {
found: true,
value: next,
missingValue: false,
remainingArgs: remaining,
};
}
const inlineValue = findInlineOption(token, flag);
if (inlineValue !== undefined) {
remaining.splice(i, 1);
if (!inlineValue.trim()) {
return { found: true, missingValue: true, remainingArgs: remaining };
}
return {
found: true,
value: inlineValue,
missingValue: false,
remainingArgs: remaining,
};
}
}
}
return { found: false, missingValue: false, remainingArgs: remaining };
}
/** Returns true if any of the provided boolean flags are present. */
export function hasAnyFlag(args: string[], flags: readonly string[]): boolean {
const truthyValues = new Set(['1', 'true', 'yes', 'on']);
return args.some((arg) =>
flags.some((flag) => {
if (arg === flag) {
return true;
}
const prefix = `${flag}=`;
if (!arg.startsWith(prefix)) {
return false;
}
const value = arg.slice(prefix.length).trim().toLowerCase();
return truthyValues.has(value);
})
);
}
+2 -1
View File
@@ -11,6 +11,7 @@ import {
getFallbackVersion,
BACKEND_CONFIG,
} from '../../cliproxy/platform-detector';
import { QUOTA_PROVIDER_HELP_TEXT } from '../../cliproxy/provider-capabilities';
export async function showHelp(): Promise<void> {
await initUI();
@@ -55,7 +56,7 @@ export async function showHelp(): Promise<void> {
['pause <account>', 'Pause account (skip in rotation)'],
['resume <account>', 'Resume paused account'],
['quota', 'Show quota status for all providers (Codex includes 5h + weekly reset)'],
['quota --provider <name>', 'Filter by provider (agy|codex|gemini|ghcp)'],
['quota --provider <name>', `Filter by provider (${QUOTA_PROVIDER_HELP_TEXT})`],
],
],
[
+77 -145
View File
@@ -7,8 +7,15 @@
import { CLIProxyBackend } from '../../cliproxy/types';
import { DEFAULT_BACKEND } from '../../cliproxy/platform-detector';
import {
type QuotaSupportedProvider,
QUOTA_PROVIDER_HELP_TEXT,
mapExternalProviderName,
isQuotaSupportedProvider,
} from '../../cliproxy/provider-capabilities';
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
import { handleSync } from '../cliproxy-sync-handler';
import { extractOption, hasAnyFlag } from '../arg-extractor';
// Import subcommand handlers
import { handleList } from './auth-subcommand';
@@ -42,30 +49,23 @@ function parseBackendArg(args: string[]): {
backend: CLIProxyBackend | undefined;
remainingArgs: string[];
} {
const backendIdx = args.indexOf('--backend');
if (backendIdx === -1) {
// Also check for --backend=value format
const backendEqualsIdx = args.findIndex((a) => a.startsWith('--backend='));
if (backendEqualsIdx !== -1) {
const value = args[backendEqualsIdx].split('=')[1] as CLIProxyBackend;
if (value !== 'original' && value !== 'plus') {
console.warn(`Invalid backend '${value}'. Valid options: original, plus`);
return { backend: undefined, remainingArgs: args };
}
const remainingArgs = [...args];
remainingArgs.splice(backendEqualsIdx, 1);
return { backend: value, remainingArgs };
}
const extracted = extractOption(args, ['--backend']);
if (!extracted.found) {
return { backend: undefined, remainingArgs: args };
}
const value = args[backendIdx + 1];
if (extracted.missingValue || !extracted.value) {
console.warn(`Invalid backend ''. Valid options: original, plus`);
return { backend: undefined, remainingArgs: extracted.remainingArgs };
}
const value = extracted.value as CLIProxyBackend;
if (value !== 'original' && value !== 'plus') {
console.warn(`Invalid backend '${value}'. Valid options: original, plus`);
return { backend: undefined, remainingArgs: args };
return { backend: undefined, remainingArgs: extracted.remainingArgs };
}
const remainingArgs = [...args];
remainingArgs.splice(backendIdx, 2);
return { backend: value, remainingArgs };
return { backend: value, remainingArgs: extracted.remainingArgs };
}
/**
@@ -80,77 +80,48 @@ function getEffectiveBackend(cliBackend?: CLIProxyBackend): CLIProxyBackend {
/**
* Parse --provider flag from args for quota command
* Returns the provider filter value and remaining args
* Accepts: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all
* Accepts canonical + aliases from quota-supported providers, and `all`
*/
type QuotaProviderFilter = QuotaSupportedProvider | 'all';
function normalizeQuotaProvider(value: string): QuotaProviderFilter | null {
if (value === 'all') {
return 'all';
}
const canonicalProvider = mapExternalProviderName(value);
if (!canonicalProvider || !isQuotaSupportedProvider(canonicalProvider)) {
return null;
}
return canonicalProvider;
}
function parseProviderArg(args: string[]): {
provider: 'agy' | 'codex' | 'gemini' | 'ghcp' | 'all';
provider: QuotaProviderFilter;
remainingArgs: string[];
} {
const providerIdx = args.indexOf('--provider');
if (providerIdx === -1) {
// Also check for --provider=value format
const providerEqualsIdx = args.findIndex((a) => a.startsWith('--provider='));
if (providerEqualsIdx !== -1) {
const value = args[providerEqualsIdx].split('=')[1]?.toLowerCase() || '';
const remainingArgs = [...args];
remainingArgs.splice(providerEqualsIdx, 1);
// Handle empty value
if (!value) {
console.error(
'Warning: --provider requires a value. Valid options: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all'
);
return { provider: 'all', remainingArgs };
}
// Normalize gemini-cli to gemini
const normalized =
value === 'gemini-cli' ? 'gemini' : value === 'github-copilot' ? 'ghcp' : value;
if (
normalized !== 'agy' &&
normalized !== 'codex' &&
normalized !== 'gemini' &&
normalized !== 'ghcp' &&
normalized !== 'all'
) {
console.error(
`Invalid provider '${value}'. Valid options: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all`
);
return { provider: 'all', remainingArgs };
}
return {
provider: normalized as 'agy' | 'codex' | 'gemini' | 'ghcp' | 'all',
remainingArgs,
};
}
const extracted = extractOption(args, ['--provider']);
if (!extracted.found) {
return { provider: 'all', remainingArgs: args };
}
const rawValue = args[providerIdx + 1];
// Warn if no value or value looks like another flag
if (!rawValue || rawValue.startsWith('-')) {
if (extracted.missingValue || !extracted.value) {
console.error(
'Warning: --provider requires a value. Valid options: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all'
`Warning: --provider requires a value. Valid options: ${QUOTA_PROVIDER_HELP_TEXT}`
);
return { provider: 'all', remainingArgs: extracted.remainingArgs };
}
const value = rawValue?.toLowerCase() || 'all';
const remainingArgs = [...args];
remainingArgs.splice(providerIdx, 2);
// Normalize gemini-cli to gemini
const normalized =
value === 'gemini-cli' ? 'gemini' : value === 'github-copilot' ? 'ghcp' : value;
if (
normalized !== 'agy' &&
normalized !== 'codex' &&
normalized !== 'gemini' &&
normalized !== 'ghcp' &&
normalized !== 'all'
) {
console.error(
`Invalid provider '${value}'. Valid options: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all`
);
return { provider: 'all', remainingArgs };
const value = extracted.value.toLowerCase();
const normalized = normalizeQuotaProvider(value);
if (!normalized) {
console.error(`Invalid provider '${value}'. Valid options: ${QUOTA_PROVIDER_HELP_TEXT}`);
return { provider: 'all', remainingArgs: extracted.remainingArgs };
}
return {
provider: normalized as 'agy' | 'codex' | 'gemini' | 'ghcp' | 'all',
remainingArgs,
provider: normalized,
remainingArgs: extracted.remainingArgs,
};
}
@@ -162,35 +133,14 @@ export async function handleCliproxyCommand(args: string[]): Promise<void> {
const { backend: cliBackend, remainingArgs } = parseBackendArg(args);
const effectiveBackend = getEffectiveBackend(cliBackend);
const verbose = remainingArgs.includes('--verbose') || remainingArgs.includes('-v');
const verbose = hasAnyFlag(remainingArgs, ['--verbose', '-v']);
const command = remainingArgs[0];
if (remainingArgs.includes('--help') || remainingArgs.includes('-h')) {
if (hasAnyFlag(remainingArgs, ['--help', '-h'])) {
await showHelp();
return;
}
// Profile commands
if (command === 'create') {
await handleCreate(remainingArgs.slice(1), effectiveBackend);
return;
}
if (command === 'edit') {
await handleEdit(remainingArgs.slice(1), effectiveBackend);
return;
}
if (command === 'list' || command === 'ls') {
await handleList();
return;
}
if (command === 'remove' || command === 'delete' || command === 'rm') {
await handleRemove(remainingArgs.slice(1));
return;
}
// Catalog commands
if (command === 'catalog') {
const subcommand = remainingArgs[1];
@@ -212,55 +162,37 @@ export async function handleCliproxyCommand(args: string[]): Promise<void> {
return;
}
// Proxy lifecycle commands
if (command === 'start') {
await handleStart(verbose);
return;
}
if (command === 'stop') {
await handleStop();
return;
}
if (command === 'restart') {
await handleRestart(verbose);
return;
}
if (command === 'status') {
await handleProxyStatus();
return;
}
// Diagnostics
if (command === 'doctor' || command === 'diag') {
await handleDoctor(verbose);
return;
}
// Quota management commands
if (command === 'default') {
await handleSetDefault(remainingArgs.slice(1));
return;
}
if (command === 'pause') {
await handlePauseAccount(remainingArgs.slice(1));
return;
}
if (command === 'resume') {
await handleResumeAccount(remainingArgs.slice(1));
return;
}
if (command === 'quota') {
const { provider: providerFilter } = parseProviderArg(remainingArgs.slice(1));
await handleQuotaStatus(verbose, providerFilter);
return;
}
const commandHandlers: Record<string, () => Promise<void>> = {
create: async () => handleCreate(remainingArgs.slice(1), effectiveBackend),
edit: async () => handleEdit(remainingArgs.slice(1), effectiveBackend),
list: async () => handleList(),
ls: async () => handleList(),
remove: async () => handleRemove(remainingArgs.slice(1)),
delete: async () => handleRemove(remainingArgs.slice(1)),
rm: async () => handleRemove(remainingArgs.slice(1)),
start: async () => handleStart(verbose),
stop: async () => handleStop(),
restart: async () => handleRestart(verbose),
status: async () => handleProxyStatus(),
doctor: async () => handleDoctor(verbose),
diag: async () => handleDoctor(verbose),
default: async () => handleSetDefault(remainingArgs.slice(1)),
pause: async () => handlePauseAccount(remainingArgs.slice(1)),
resume: async () => handleResumeAccount(remainingArgs.slice(1)),
};
const commandHandler = command ? commandHandlers[command] : undefined;
if (commandHandler) {
await commandHandler();
return;
}
// Binary installation commands
const installIdx = remainingArgs.indexOf('--install');
if (installIdx !== -1) {
+82 -44
View File
@@ -27,6 +27,10 @@ import type {
} from '../../cliproxy/quota-types';
import { isOnCooldown } from '../../cliproxy/quota-manager';
import { CLIProxyProvider } from '../../cliproxy/types';
import {
QUOTA_SUPPORTED_PROVIDER_IDS,
type QuotaSupportedProvider,
} from '../../cliproxy/provider-capabilities';
import { initUI, header, subheader, color, dim, ok, fail, warn, info, table } from '../../utils/ui';
interface CliproxyProfileArgs {
@@ -481,65 +485,99 @@ function displayGhcpQuotaSection(results: { account: string; quota: GhcpQuotaRes
}
}
interface QuotaProviderRuntime {
fetch: (verbose: boolean) => Promise<unknown>;
hasData: (result: unknown) => boolean;
render: (result: unknown) => void;
emptyTitle: string;
emptyMessage: string;
authCommand: string;
}
const QUOTA_PROVIDER_RUNTIME: Record<QuotaSupportedProvider, QuotaProviderRuntime> = {
agy: {
fetch: (verbose) => fetchAllProviderQuotas('agy', verbose),
hasData: (result) =>
(result as Awaited<ReturnType<typeof fetchAllProviderQuotas>>).accounts.length > 0,
render: (result) =>
displayAntigravityQuotaSection(result as Awaited<ReturnType<typeof fetchAllProviderQuotas>>),
emptyTitle: 'Antigravity (0 accounts)',
emptyMessage: 'No Antigravity accounts configured',
authCommand: 'ccs agy --auth',
},
codex: {
fetch: (verbose) => fetchAllCodexQuotas(verbose),
hasData: (result) => (result as { account: string; quota: CodexQuotaResult }[]).length > 0,
render: (result) =>
displayCodexQuotaSection(result as { account: string; quota: CodexQuotaResult }[]),
emptyTitle: 'Codex (0 accounts)',
emptyMessage: 'No Codex accounts configured',
authCommand: 'ccs codex --auth',
},
gemini: {
fetch: (verbose) => fetchAllGeminiCliQuotas(verbose),
hasData: (result) => (result as { account: string; quota: GeminiCliQuotaResult }[]).length > 0,
render: (result) =>
displayGeminiCliQuotaSection(result as { account: string; quota: GeminiCliQuotaResult }[]),
emptyTitle: 'Gemini CLI (0 accounts)',
emptyMessage: 'No Gemini CLI accounts configured',
authCommand: 'ccs gemini --auth',
},
ghcp: {
fetch: (verbose) => fetchAllGhcpQuotas(verbose),
hasData: (result) => (result as { account: string; quota: GhcpQuotaResult }[]).length > 0,
render: (result) =>
displayGhcpQuotaSection(result as { account: string; quota: GhcpQuotaResult }[]),
emptyTitle: 'GitHub Copilot (0 accounts)',
emptyMessage: 'No GitHub Copilot accounts configured',
authCommand: 'ccs ghcp --auth',
},
};
export async function handleQuotaStatus(
verbose = false,
providerFilter: 'agy' | 'codex' | 'gemini' | 'ghcp' | 'all' = 'all'
providerFilter: QuotaSupportedProvider | 'all' = 'all'
): Promise<void> {
await initUI();
console.log(header('Quota Status'));
console.log('');
const shouldFetch = {
agy: providerFilter === 'all' || providerFilter === 'agy',
codex: providerFilter === 'all' || providerFilter === 'codex',
gemini: providerFilter === 'all' || providerFilter === 'gemini',
ghcp: providerFilter === 'all' || providerFilter === 'ghcp',
};
const requestedProviders = new Set<QuotaSupportedProvider>(
providerFilter === 'all' ? QUOTA_SUPPORTED_PROVIDER_IDS : [providerFilter]
);
const shouldFetch = (provider: QuotaSupportedProvider): boolean =>
requestedProviders.has(provider);
console.log(dim('Fetching quotas...'));
const [agyResults, codexResults, geminiResults, ghcpResults] = await Promise.all([
shouldFetch.agy ? fetchAllProviderQuotas('agy', verbose) : null,
shouldFetch.codex ? fetchAllCodexQuotas(verbose) : null,
shouldFetch.gemini ? fetchAllGeminiCliQuotas(verbose) : null,
shouldFetch.ghcp ? fetchAllGhcpQuotas(verbose) : null,
]);
const providerResults = new Map<QuotaSupportedProvider, unknown | null>(
await Promise.all(
QUOTA_SUPPORTED_PROVIDER_IDS.map(async (provider) => {
if (!shouldFetch(provider)) {
return [provider, null] as const;
}
return [provider, await QUOTA_PROVIDER_RUNTIME[provider].fetch(verbose)] as const;
})
)
);
console.log('');
if (agyResults && agyResults.accounts.length > 0) {
displayAntigravityQuotaSection(agyResults);
} else if (shouldFetch.agy) {
console.log(subheader('Antigravity (0 accounts)'));
console.log(info('No Antigravity accounts configured'));
console.log(` Run: ${color('ccs agy --auth', 'command')} to authenticate`);
console.log('');
}
for (const provider of QUOTA_SUPPORTED_PROVIDER_IDS) {
if (!shouldFetch(provider)) {
continue;
}
if (codexResults && codexResults.length > 0) {
displayCodexQuotaSection(codexResults);
} else if (shouldFetch.codex) {
console.log(subheader('Codex (0 accounts)'));
console.log(info('No Codex accounts configured'));
console.log(` Run: ${color('ccs codex --auth', 'command')} to authenticate`);
console.log('');
}
const runtime = QUOTA_PROVIDER_RUNTIME[provider];
const result = providerResults.get(provider) ?? null;
if (result !== null && runtime.hasData(result)) {
runtime.render(result);
continue;
}
if (geminiResults && geminiResults.length > 0) {
displayGeminiCliQuotaSection(geminiResults);
} else if (shouldFetch.gemini) {
console.log(subheader('Gemini CLI (0 accounts)'));
console.log(info('No Gemini CLI accounts configured'));
console.log(` Run: ${color('ccs gemini --auth', 'command')} to authenticate`);
console.log('');
}
if (ghcpResults && ghcpResults.length > 0) {
displayGhcpQuotaSection(ghcpResults);
} else if (shouldFetch.ghcp) {
console.log(subheader('GitHub Copilot (0 accounts)'));
console.log(info('No GitHub Copilot accounts configured'));
console.log(` Run: ${color('ccs ghcp --auth', 'command')} to authenticate`);
console.log(subheader(runtime.emptyTitle));
console.log(info(runtime.emptyMessage));
console.log(` Run: ${color(runtime.authCommand, 'command')} to authenticate`);
console.log('');
}
}
+19 -15
View File
@@ -13,6 +13,7 @@ import { setupGracefulShutdown } from '../web-server/shutdown';
import { ensureCliproxyService } from '../cliproxy/service-manager';
import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config-generator';
import { initUI, header, ok, info, warn, fail } from '../utils/ui';
import { extractOption, hasAnyFlag } from './arg-extractor';
interface ConfigOptions {
port?: number;
@@ -25,25 +26,28 @@ interface ConfigOptions {
function parseArgs(args: string[]): ConfigOptions {
const result: ConfigOptions = {};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (hasAnyFlag(args, ['--help', '-h'])) {
showHelp();
process.exit(0);
}
if ((arg === '--port' || arg === '-p') && args[i + 1]) {
const port = parseInt(args[++i], 10);
if (!isNaN(port) && port > 0 && port < 65536) {
result.port = port;
} else {
console.error(fail('Invalid port number'));
process.exit(1);
}
} else if (arg === '--dev') {
result.dev = true;
} else if (arg === '--help' || arg === '-h') {
showHelp();
process.exit(0);
const portOption = extractOption(args, ['--port', '-p']);
if (portOption.found) {
if (portOption.missingValue || !portOption.value) {
console.error(fail('Invalid port number'));
process.exit(1);
}
const port = parseInt(portOption.value, 10);
if (!isNaN(port) && port > 0 && port < 65536) {
result.port = port;
} else {
console.error(fail('Invalid port number'));
process.exit(1);
}
}
result.dev = hasAnyFlag(args, ['--dev']);
return result;
}
+49 -25
View File
@@ -12,39 +12,53 @@ import {
loadOrCreateUnifiedConfig,
} from '../config/unified-config-loader';
import { DEFAULT_IMAGE_ANALYSIS_CONFIG } from '../config/unified-config-types';
import {
CLIPROXY_PROVIDER_IDS,
PROVIDER_CAPABILITIES,
mapExternalProviderName,
} from '../cliproxy/provider-capabilities';
import { extractOption, hasAnyFlag } from './arg-extractor';
interface ImageAnalysisCommandOptions {
enable?: boolean;
disable?: boolean;
timeout?: number;
setModel?: { provider: string; model: string };
setModelError?: string;
help?: boolean;
}
const IMAGE_ANALYSIS_PROVIDER_ALIASES = Object.freeze(
CLIPROXY_PROVIDER_IDS.flatMap((provider) => PROVIDER_CAPABILITIES[provider].aliases).filter(
(alias, index, aliases) => aliases.indexOf(alias) === index
)
);
function parseArgs(args: string[]): ImageAnalysisCommandOptions {
const options: ImageAnalysisCommandOptions = {};
const options: ImageAnalysisCommandOptions = {
enable: hasAnyFlag(args, ['--enable']),
disable: hasAnyFlag(args, ['--disable']),
help: hasAnyFlag(args, ['--help', '-h']),
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
const timeoutOption = extractOption(args, ['--timeout']);
if (timeoutOption.found) {
const timeout = parseInt(timeoutOption.value || '', 10);
if (isNaN(timeout) || timeout < 10 || timeout > 600) {
console.error(fail('Timeout must be between 10 and 600 seconds'));
process.exit(1);
}
options.timeout = timeout;
}
if (arg === '--enable') {
options.enable = true;
} else if (arg === '--disable') {
options.disable = true;
} else if (arg === '--timeout' && args[i + 1]) {
const timeout = parseInt(args[++i], 10);
if (isNaN(timeout) || timeout < 10 || timeout > 600) {
console.error(fail('Timeout must be between 10 and 600 seconds'));
process.exit(1);
}
options.timeout = timeout;
} else if (arg === '--set-model' && args[i + 1] && args[i + 2]) {
options.setModel = {
provider: args[++i],
model: args[++i],
};
} else if (arg === '--help' || arg === '-h') {
options.help = true;
const setModelIdx = args.indexOf('--set-model');
if (setModelIdx !== -1) {
const provider = args[setModelIdx + 1];
const model = args[setModelIdx + 2];
if (provider && model && !provider.startsWith('-') && !model.startsWith('-')) {
options.setModel = { provider, model };
} else {
options.setModelError = '--set-model requires <provider> <model>';
}
}
@@ -72,7 +86,10 @@ function showHelp(): void {
console.log('');
console.log(subheader('Provider Models:'));
console.log(` ${dim('Providers with vision support: agy, gemini, codex, kiro, ghcp, claude')}`);
console.log(` ${dim(`Valid providers: ${CLIPROXY_PROVIDER_IDS.join(', ')}`)}`);
if (IMAGE_ANALYSIS_PROVIDER_ALIASES.length > 0) {
console.log(` ${dim(`Aliases accepted: ${IMAGE_ANALYSIS_PROVIDER_ALIASES.join(', ')}`)}`);
}
console.log(` ${dim('Default model: gemini-2.5-flash (most providers)')}`);
console.log('');
@@ -159,6 +176,11 @@ export async function handleConfigImageAnalysisCommand(args: string[]): Promise<
return;
}
if (options.setModelError) {
console.error(fail(options.setModelError));
process.exit(1);
}
// Validate conflicting flags (Edge case #2: --enable + --disable conflict)
if (options.enable && options.disable) {
console.error(fail('Cannot use --enable and --disable together'));
@@ -186,8 +208,10 @@ export async function handleConfigImageAnalysisCommand(args: string[]): Promise<
}
if (options.setModel) {
const validProviders = ['agy', 'gemini', 'codex', 'kiro', 'ghcp', 'claude', 'qwen', 'iflow'];
if (!validProviders.includes(options.setModel.provider)) {
const validProviders = [...CLIPROXY_PROVIDER_IDS];
const normalizedProviderInput = options.setModel.provider.trim().toLowerCase();
const canonicalProvider = mapExternalProviderName(normalizedProviderInput);
if (!canonicalProvider) {
console.error(fail(`Invalid provider: ${options.setModel.provider}`));
console.error(info(`Valid providers: ${validProviders.join(', ')}`));
process.exit(1);
@@ -200,7 +224,7 @@ export async function handleConfigImageAnalysisCommand(args: string[]): Promise<
}
imageConfig.provider_models = {
...imageConfig.provider_models,
[options.setModel.provider]: model,
[canonicalProvider]: model,
};
hasChanges = true;
}
+75 -38
View File
@@ -21,6 +21,8 @@ import ProfileDetector, {
import { getEffectiveEnvVars, CLIPROXY_DEFAULT_PORT } from '../cliproxy/config-generator';
import { generateCopilotEnv } from '../copilot/copilot-executor';
import { expandPath } from '../utils/helpers';
import { getClaudeConfigDir, getClaudeSettingsPath } from '../utils/claude-config-path';
import { extractOption, hasAnyFlag } from './arg-extractor';
interface PersistCommandArgs {
profile?: string;
@@ -37,34 +39,48 @@ interface ResolvedEnv {
/** Parse command line arguments */
function parseArgs(args: string[]): PersistCommandArgs {
const result: PersistCommandArgs = {};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--yes' || arg === '-y') {
result.yes = true;
} else if (arg === '--help' || arg === '-h') {
// Will be handled in main function
} else if (arg === '--list-backups') {
result.listBackups = true;
} else if (arg === '--restore') {
// Check if next arg is a timestamp (not a flag)
const nextArg = args[i + 1];
if (nextArg && !nextArg.startsWith('-')) {
result.restore = nextArg;
i++; // Skip next arg
} else {
result.restore = true; // Use latest
}
} else if (!arg.startsWith('-') && !result.profile) {
const result: PersistCommandArgs = {
yes: hasAnyFlag(args, ['--yes', '-y']),
listBackups: hasAnyFlag(args, ['--list-backups']),
};
const restoreOption = extractOption(args, ['--restore']);
if (restoreOption.found) {
result.restore = restoreOption.missingValue ? true : restoreOption.value || true;
}
for (const arg of restoreOption.remainingArgs) {
if (!arg.startsWith('-')) {
result.profile = arg;
break;
}
}
return result;
}
/** Get Claude settings.json path */
function getClaudeSettingsPath(): string {
return path.join(os.homedir(), '.claude', 'settings.json');
function formatDisplayPath(filePath: string): string {
const defaultClaudeDir = path.join(os.homedir(), '.claude');
const claudeDir = getClaudeConfigDir();
// Keep real path when user overrides Claude directory.
if (path.resolve(claudeDir) !== path.resolve(defaultClaudeDir)) {
return filePath;
}
if (filePath === claudeDir) {
return '~/.claude';
}
const claudePrefix = `${claudeDir}${path.sep}`;
if (filePath.startsWith(claudePrefix)) {
return filePath.replace(claudePrefix, '~/.claude/');
}
return filePath;
}
function getClaudeSettingsDisplayPath(): string {
return formatDisplayPath(getClaudeSettingsPath());
}
/** Read existing Claude settings.json with validation */
@@ -171,6 +187,25 @@ interface BackupFile {
date: Date;
}
function parseBackupTimestamp(timestamp: string): Date | null {
const year = parseInt(timestamp.slice(0, 4), 10);
const month = parseInt(timestamp.slice(4, 6), 10);
const day = parseInt(timestamp.slice(6, 8), 10);
const hour = parseInt(timestamp.slice(9, 11), 10);
const minute = parseInt(timestamp.slice(11, 13), 10);
const second = parseInt(timestamp.slice(13, 15), 10);
const date = new Date(year, month - 1, day, hour, minute, second);
if (date.getFullYear() !== year) return null;
if (date.getMonth() !== month - 1) return null;
if (date.getDate() !== day) return null;
if (date.getHours() !== hour) return null;
if (date.getMinutes() !== minute) return null;
if (date.getSeconds() !== second) return null;
return date;
}
/** Get all backup files sorted by date (newest first) */
function getBackupFiles(): BackupFile[] {
const settingsPath = getClaudeSettingsPath();
@@ -186,17 +221,12 @@ function getBackupFiles(): BackupFile[] {
const match = f.match(backupPattern);
if (!match) return null;
const timestamp = match[1];
// Parse YYYYMMDD_HHMMSS
const year = parseInt(timestamp.slice(0, 4));
const month = parseInt(timestamp.slice(4, 6)) - 1;
const day = parseInt(timestamp.slice(6, 8));
const hour = parseInt(timestamp.slice(9, 11));
const min = parseInt(timestamp.slice(11, 13));
const sec = parseInt(timestamp.slice(13, 15));
const date = parseBackupTimestamp(timestamp);
if (!date) return null;
return {
path: path.join(dir, f),
timestamp,
date: new Date(year, month, day, hour, min, sec),
date,
};
})
.filter((f): f is BackupFile => f !== null)
@@ -324,7 +354,7 @@ async function handleRestore(timestamp: string | boolean, yes: boolean): Promise
console.log(`Backup: ${color(backup.timestamp, 'command')}`);
console.log(`Date: ${backup.date.toLocaleString()}`);
console.log('');
console.log(warn('This will replace ~/.claude/settings.json'));
console.log(warn(`This will replace ${getClaudeSettingsDisplayPath()}`));
console.log('');
if (!yes) {
const proceed = await InteractivePrompt.confirm('Proceed with restore?', { default: false });
@@ -342,6 +372,11 @@ async function handleRestore(timestamp: string | boolean, yes: boolean): Promise
process.exit(1);
}
} catch (error) {
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code === 'ENOENT') {
console.log(fail('Backup was deleted during restore'));
process.exit(1);
}
console.log(fail(`Backup file is corrupted: ${(error as Error).message}`));
process.exit(1);
}
@@ -373,7 +408,7 @@ async function showHelp(): Promise<void> {
console.log('');
console.log(subheader('Description'));
console.log(" Writes a profile's environment variables directly to");
console.log(' ~/.claude/settings.json for native Claude Code usage.');
console.log(` ${getClaudeSettingsDisplayPath()} for native Claude Code usage.`);
console.log('');
console.log(' This allows Claude Code to use the profile without CCS,');
console.log(' enabling compatibility with IDEs and extensions.');
@@ -414,7 +449,9 @@ async function showHelp(): Promise<void> {
console.log(subheader('Notes'));
console.log(' [i] CLIProxy profiles require the proxy to be running.');
console.log(' [i] Copilot profiles require copilot-api daemon.');
console.log(' [i] Backups are saved as ~/.claude/settings.json.backup.YYYYMMDD_HHMMSS');
console.log(
` [i] Backups are saved as ${getClaudeSettingsDisplayPath()}.backup.YYYYMMDD_HHMMSS`
);
console.log('');
}
@@ -474,7 +511,7 @@ export async function handlePersistCommand(args: string[]): Promise<void> {
console.log('');
console.log(`Profile type: ${color(resolved.profileType, 'command')}`);
console.log('');
console.log('The following env vars will be written to ~/.claude/settings.json:');
console.log(`The following env vars will be written to ${getClaudeSettingsDisplayPath()}:`);
console.log('');
// Display env vars (mask sensitive values)
const envKeys = Object.keys(resolved.env);
@@ -498,7 +535,7 @@ export async function handlePersistCommand(args: string[]): Promise<void> {
console.log('');
}
// Warning about modification
console.log(warn('This will modify ~/.claude/settings.json'));
console.log(warn(`This will modify ${getClaudeSettingsDisplayPath()}`));
console.log(dim(' Existing hooks and other settings will be preserved.'));
console.log('');
// Check if settings.json exists for backup
@@ -517,7 +554,7 @@ export async function handlePersistCommand(args: string[]): Promise<void> {
if (createBackupFlag) {
try {
createdBackupPath = createBackup();
console.log(ok(`Backup created: ${createdBackupPath.replace(os.homedir(), '~')}`));
console.log(ok(`Backup created: ${formatDisplayPath(createdBackupPath)}`));
console.log('');
} catch (error) {
console.log(fail(`Failed to create backup: ${(error as Error).message}`));
@@ -560,13 +597,13 @@ export async function handlePersistCommand(args: string[]): Promise<void> {
if (createdBackupPath) {
console.log('');
console.log(info(`A backup was created before this error:`));
console.log(` ${createdBackupPath.replace(os.homedir(), '~')}`);
console.log(` ${formatDisplayPath(createdBackupPath)}`);
console.log(dim(' To restore: ccs persist --restore'));
}
process.exit(1);
}
console.log('');
console.log(ok(`Profile '${parsedArgs.profile}' written to ~/.claude/settings.json`));
console.log(ok(`Profile '${parsedArgs.profile}' written to ${getClaudeSettingsDisplayPath()}`));
console.log('');
console.log(info('Claude Code will now use this profile by default.'));
console.log(dim(' To revert, restore the backup or edit settings.json manually.'));
+2 -2
View File
@@ -4,10 +4,10 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { ok, fail, warn, info } from '../../utils/ui';
import { HealthCheck, IHealthChecker, createSpinner } from './types';
import { getCcsDir } from '../../utils/config-manager';
import { getClaudeConfigDir } from '../../utils/claude-config-path';
const ora = createSpinner();
@@ -195,7 +195,7 @@ export class ClaudeSettingsChecker implements IHealthChecker {
private readonly claudeDir: string;
constructor() {
this.claudeDir = path.join(os.homedir(), '.claude');
this.claudeDir = getClaudeConfigDir();
}
run(results: HealthCheck): void {
+29
View File
@@ -0,0 +1,29 @@
/**
* Shared extended context helpers used by CLI + UI.
*/
/** Extended context suffix recognized by Claude Code. */
export const EXTENDED_CONTEXT_SUFFIX = '[1m]';
/** Check if model is a native Gemini model (auto-enabled behavior). */
export function isNativeGeminiModel(modelId: string): boolean {
return modelId.toLowerCase().startsWith('gemini-');
}
/** Check if model already has [1m] suffix. */
export function hasExtendedContextSuffix(model: string): boolean {
return model.toLowerCase().endsWith(EXTENDED_CONTEXT_SUFFIX.toLowerCase());
}
/** Apply [1m] suffix to model if not already present. */
export function applyExtendedContextSuffix(model: string): string {
if (!model) return model;
if (hasExtendedContextSuffix(model)) return model;
return `${model}${EXTENDED_CONTEXT_SUFFIX}`;
}
/** Strip [1m] suffix from model string. */
export function stripExtendedContextSuffix(model: string): string {
if (!model) return model;
return hasExtendedContextSuffix(model) ? model.slice(0, -EXTENDED_CONTEXT_SUFFIX.length) : model;
}
+26
View File
@@ -0,0 +1,26 @@
import * as os from 'os';
import * as path from 'path';
/**
* Resolve Claude config directory with test/dev overrides.
* Precedence:
* 1. CLAUDE_CONFIG_DIR (explicit override)
* 2. CCS_HOME compatibility path (<dirname(CCS_HOME)>/.claude)
* 3. ~/.claude (default)
*/
export function getClaudeConfigDir(): string {
if (process.env.CLAUDE_CONFIG_DIR) {
return path.resolve(process.env.CLAUDE_CONFIG_DIR);
}
if (process.env.CCS_HOME) {
return path.join(path.dirname(path.resolve(process.env.CCS_HOME)), '.claude');
}
return path.join(os.homedir(), '.claude');
}
/** Resolve Claude settings.json path. */
export function getClaudeSettingsPath(): string {
return path.join(getClaudeConfigDir(), 'settings.json');
}
+1 -16
View File
@@ -8,30 +8,15 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { info, warn } from '../ui';
import { getWebSearchConfig } from '../../config/unified-config-loader';
import { getCcsHooksDir } from '../config-manager';
import { getClaudeSettingsPath } from '../claude-config-path';
import { isCcsWebSearchHook, deduplicateCcsHooks } from './hook-utils';
// Hook file name
const WEBSEARCH_HOOK = 'websearch-transformer.cjs';
/**
* Get Claude settings path (respects CCS_HOME for test isolation)
* In tests, returns path under CCS_HOME; in production, uses real ~/.claude/
*/
function getClaudeSettingsPath(): string {
const ccsHome = process.env.CCS_HOME;
if (ccsHome) {
// Test mode: use CCS_HOME parent for .claude directory
// This prevents tests from modifying user's real settings
return path.join(path.dirname(ccsHome), '.claude', 'settings.json');
}
// Production: use real home directory
return path.join(os.homedir(), '.claude', 'settings.json');
}
// Buffer time added to max provider timeout for hook timeout (seconds)
const HOOK_TIMEOUT_BUFFER = 30;
+55 -32
View File
@@ -14,7 +14,7 @@
import * as fs from 'fs';
import * as path from 'path';
import * as readline from 'readline';
import * as os from 'os';
import { getClaudeConfigDir } from '../utils/claude-config-path';
// ============================================================================
// TYPE DEFINITIONS
@@ -63,6 +63,9 @@ export interface ParserOptions {
projectsDir?: string;
}
const DEFAULT_SCAN_CONCURRENCY = 10;
const MAX_SCAN_CONCURRENCY = 64;
// ============================================================================
// CORE PARSING FUNCTIONS
// ============================================================================
@@ -71,6 +74,14 @@ export interface ParserOptions {
* Parse a single JSONL line into RawUsageEntry if valid
* Returns null for non-assistant entries or entries without usage data
*/
function toNonNegativeNumber(value: unknown): number {
const numeric = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(numeric) || numeric < 0) {
return 0;
}
return numeric;
}
export function parseUsageEntry(line: string, projectPath: string): RawUsageEntry | null {
// Strip UTF-8 BOM if present (can occur on first line of some files)
const cleanLine = line.replace(/^\uFEFF/, '').trim();
@@ -88,10 +99,10 @@ export function parseUsageEntry(line: string, projectPath: string): RawUsageEntr
const assistant = entry as JsonlAssistantEntry;
return {
inputTokens: usage.input_tokens || 0,
outputTokens: usage.output_tokens || 0,
cacheCreationTokens: usage.cache_creation_input_tokens || 0,
cacheReadTokens: usage.cache_read_input_tokens || 0,
inputTokens: toNonNegativeNumber(usage.input_tokens),
outputTokens: toNonNegativeNumber(usage.output_tokens),
cacheCreationTokens: toNonNegativeNumber(usage.cache_creation_input_tokens),
cacheReadTokens: toNonNegativeNumber(usage.cache_read_input_tokens),
model: assistant.message.model,
sessionId: assistant.sessionId || '',
timestamp: assistant.timestamp || new Date().toISOString(),
@@ -118,38 +129,48 @@ export async function parseJsonlFile(
): Promise<RawUsageEntry[]> {
const entries: RawUsageEntry[] = [];
if (!fs.existsSync(filePath)) {
return entries;
}
let fileStream: fs.ReadStream | null = null;
let rl: readline.Interface | null = null;
try {
fileStream = fs.createReadStream(filePath, { encoding: 'utf8' });
rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity,
});
const fileStream = fs.createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity,
});
for await (const line of rl) {
const entry = parseUsageEntry(line, projectPath);
if (entry) {
entries.push(entry);
for await (const line of rl) {
const entry = parseUsageEntry(line, projectPath);
if (entry) {
entries.push(entry);
}
}
} catch {
// File read/stream error - return whatever was parsed so far
} finally {
rl?.close();
fileStream?.destroy();
}
return entries;
}
function decodeProjectPath(projectDir: string): string {
const raw = path.basename(projectDir).replace(/-/g, '/');
const safeSegments = raw
.split('/')
.filter((segment) => segment && segment !== '.' && segment !== '..');
return `/${safeSegments.join('/')}`;
}
/**
* Parse all JSONL files in a single project directory
*/
export async function parseProjectDirectory(projectDir: string): Promise<RawUsageEntry[]> {
const entries: RawUsageEntry[] = [];
if (!fs.existsSync(projectDir)) {
return entries;
}
// Get project path from directory name (e.g., "-home-kai-project" -> "/home/kai/project")
const projectPath = path.basename(projectDir).replace(/-/g, '/');
const projectPath = decodeProjectPath(projectDir);
try {
const files = await fs.promises.readdir(projectDir);
@@ -176,8 +197,7 @@ export async function parseProjectDirectory(projectDir: string): Promise<RawUsag
* Get default Claude projects directory
*/
export function getDefaultProjectsDir(): string {
const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
return path.join(configDir, 'projects');
return path.join(getClaudeConfigDir(), 'projects');
}
/**
@@ -186,10 +206,6 @@ export function getDefaultProjectsDir(): string {
export function findProjectDirectories(projectsDir?: string): string[] {
const dir = projectsDir || getDefaultProjectsDir();
if (!fs.existsSync(dir)) {
return [];
}
try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
return entries
@@ -208,7 +224,14 @@ export function findProjectDirectories(projectsDir?: string): string[] {
* @returns All parsed usage entries from all projects
*/
export async function scanProjectsDirectory(options: ParserOptions = {}): Promise<RawUsageEntry[]> {
const { concurrency = 10, projectsDir } = options;
const requestedConcurrency = options.concurrency;
const concurrency =
typeof requestedConcurrency === 'number' &&
Number.isInteger(requestedConcurrency) &&
requestedConcurrency > 0
? Math.min(requestedConcurrency, MAX_SCAN_CONCURRENCY)
: DEFAULT_SCAN_CONCURRENCY;
const { projectsDir } = options;
const allEntries: RawUsageEntry[] = [];
const projectDirs = findProjectDirectories(projectsDir);
@@ -231,8 +254,8 @@ export async function scanProjectsDirectory(options: ParserOptions = {}): Promis
if (options.minDate) {
const minTime = options.minDate.getTime();
return allEntries.filter((entry) => {
const entryTime = new Date(entry.timestamp).getTime();
return entryTime >= minTime;
const entryTime = Date.parse(entry.timestamp);
return Number.isFinite(entryTime) && entryTime >= minTime;
});
}
+48 -42
View File
@@ -6,7 +6,7 @@ import { Router, Request, Response } from 'express';
import rateLimit from 'express-rate-limit';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { getClaudeSettingsPath } from '../../utils/claude-config-path';
const router = Router();
@@ -28,49 +28,33 @@ interface BackupFile {
/**
* Async mutex for restore operations - prevents race conditions
*
* Design: Uses a Promise queue pattern for atomic lock acquisition.
* When the mutex is locked, subsequent callers are added to a queue
* and immediately receive `false` when released, signaling they should
* return a 409 Conflict rather than wait. This prevents request pileup
* while ensuring only one restore can execute at a time.
* Design: Fast-fail lock.
* If a restore is already running, callers immediately get `false`
* and the route returns HTTP 409. This avoids request pileup.
*/
class RestoreMutex {
private locked = false;
private queue: Array<() => void> = [];
/**
* Attempt to acquire the mutex
* @returns true if acquired, false if already locked (queued request)
* @returns true if acquired, false if already locked
*/
async acquire(): Promise<boolean> {
if (this.locked) {
// Already locked - add to queue and wait
return new Promise((resolve) => {
this.queue.push(() => resolve(false)); // Return false = was queued, reject
});
return false;
}
this.locked = true;
return true;
}
/** Release the mutex, signaling next queued request (if any) to fail */
/** Release the mutex */
release(): void {
const next = this.queue.shift();
if (next) {
next(); // Signal queued request to fail
} else {
this.locked = false;
}
this.locked = false;
}
}
const restoreMutex = new RestoreMutex();
/** Get Claude settings.json path */
function getClaudeSettingsPath(): string {
return path.join(os.homedir(), '.claude', 'settings.json');
}
/** Check if path is a symlink (security check) */
function isSymlink(filePath: string): boolean {
try {
@@ -81,6 +65,25 @@ function isSymlink(filePath: string): boolean {
}
}
function parseBackupTimestamp(timestamp: string): Date | null {
const year = parseInt(timestamp.slice(0, 4), 10);
const month = parseInt(timestamp.slice(4, 6), 10);
const day = parseInt(timestamp.slice(6, 8), 10);
const hour = parseInt(timestamp.slice(9, 11), 10);
const minute = parseInt(timestamp.slice(11, 13), 10);
const second = parseInt(timestamp.slice(13, 15), 10);
const date = new Date(year, month - 1, day, hour, minute, second);
if (date.getFullYear() !== year) return null;
if (date.getMonth() !== month - 1) return null;
if (date.getDate() !== day) return null;
if (date.getHours() !== hour) return null;
if (date.getMinutes() !== minute) return null;
if (date.getSeconds() !== second) return null;
return date;
}
/** Get all backup files sorted by date (newest first) */
function getBackupFiles(): BackupFile[] {
const settingsPath = getClaudeSettingsPath();
@@ -96,16 +99,12 @@ function getBackupFiles(): BackupFile[] {
const match = f.match(backupPattern);
if (!match) return null;
const timestamp = match[1];
const year = parseInt(timestamp.slice(0, 4));
const month = parseInt(timestamp.slice(4, 6)) - 1;
const day = parseInt(timestamp.slice(6, 8));
const hour = parseInt(timestamp.slice(9, 11));
const min = parseInt(timestamp.slice(11, 13));
const sec = parseInt(timestamp.slice(13, 15));
const date = parseBackupTimestamp(timestamp);
if (!date) return null;
return {
path: path.join(dir, f),
timestamp,
date: new Date(year, month, day, hour, min, sec),
date,
};
})
.filter((f): f is BackupFile => f !== null)
@@ -183,16 +182,18 @@ router.post('/restore', restoreRateLimiter, async (req: Request, res: Response):
let backupContent: string;
let fd: number | undefined;
try {
// Verify not symlink immediately before open
const stats = fs.lstatSync(backup.path);
if (stats.isSymbolicLink()) {
res
.status(400)
.json({ error: 'Backup became symlink during read - refusing for security' });
if (typeof fs.constants.O_NOFOLLOW !== 'number') {
res.status(500).json({ error: 'Secure restore unsupported on this platform' });
return;
}
// Open file descriptor for atomic read
fd = fs.openSync(backup.path, 'r');
const openFlags = fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW;
fd = fs.openSync(backup.path, openFlags);
const stats = fs.fstatSync(fd);
if (!stats.isFile()) {
res.status(400).json({ error: 'Backup path is not a regular file' });
return;
}
const buffer = Buffer.alloc(stats.size);
fs.readSync(fd, buffer, 0, stats.size, 0);
backupContent = buffer.toString('utf8');
@@ -204,6 +205,10 @@ router.post('/restore', restoreRateLimiter, async (req: Request, res: Response):
}
} catch (err) {
const error = err as NodeJS.ErrnoException;
if (error.code === 'ELOOP') {
res.status(400).json({ error: 'Backup file is a symlink - refusing for security' });
return;
}
if (error.code === 'ENOENT') {
res.status(404).json({ error: 'Backup was deleted during restore' });
return;
@@ -222,17 +227,18 @@ router.post('/restore', restoreRateLimiter, async (req: Request, res: Response):
// Atomic restore with rollback capability
const settingsDir = path.dirname(settingsPath);
const tempPath = path.join(settingsDir, 'settings.json.restore-tmp');
const rollbackPath = path.join(settingsDir, 'settings.json.rollback-tmp');
const restoreNonce = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
const tempPath = path.join(settingsDir, `settings.json.restore-${restoreNonce}.tmp`);
const rollbackPath = path.join(settingsDir, `settings.json.rollback-${restoreNonce}.tmp`);
try {
// Step 1: Backup current settings for rollback
if (fs.existsSync(settingsPath)) {
fs.copyFileSync(settingsPath, rollbackPath);
fs.copyFileSync(settingsPath, rollbackPath, fs.constants.COPYFILE_EXCL);
}
// Step 2: Write validated content to temp file
fs.writeFileSync(tempPath, backupContent, 'utf8');
fs.writeFileSync(tempPath, backupContent, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
// Step 3: Atomic rename (replaces existing file)
fs.renameSync(tempPath, settingsPath);
+58 -6
View File
@@ -6,6 +6,7 @@ import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir, getConfigPath, loadConfigSafe, loadSettings } from '../../utils/config-manager';
import { expandPath } from '../../utils/helpers';
import { getClaudeSettingsPath } from '../../utils/claude-config-path';
import type { Config, Settings } from '../../types/config';
/** Model mapping for API profiles */
@@ -160,21 +161,69 @@ export function updateSettingsFile(
* - ~/.ccs/ directory: read/write allowed
* - ~/.claude/settings.json: read-only
*/
function normalizePathForComparison(filePath: string): string {
const normalized = path.resolve(path.normalize(filePath));
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
}
function isPathWithin(basePath: string, targetPath: string): boolean {
const relative = path.relative(basePath, targetPath);
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
}
function isSymlinkPath(filePath: string): boolean {
try {
return fs.lstatSync(filePath).isSymbolicLink();
} catch (error) {
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code === 'ENOENT' || nodeError.code === 'ENOTDIR') {
return false;
}
return true;
}
}
function hasSymlinkSegment(basePath: string, targetPath: string): boolean {
const relative = path.relative(basePath, targetPath);
if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) {
return false;
}
let currentPath = basePath;
const segments = relative.split(path.sep).filter(Boolean);
for (const segment of segments) {
currentPath = path.join(currentPath, segment);
if (isSymlinkPath(currentPath)) {
return true;
}
}
return false;
}
export function validateFilePath(filePath: string): {
valid: boolean;
readonly: boolean;
error?: string;
} {
const expandedPath = expandPath(filePath);
const normalizedPath = path.normalize(expandedPath);
const ccsDir = getCcsDir();
const claudeSettingsPath = expandPath('~/.claude/settings.json');
const resolvedPath = path.resolve(path.normalize(expandedPath));
const resolvedCcsDir = path.resolve(path.normalize(getCcsDir()));
const resolvedClaudeSettingsPath = path.resolve(path.normalize(getClaudeSettingsPath()));
const normalizedPath = normalizePathForComparison(resolvedPath);
const ccsDir = normalizePathForComparison(resolvedCcsDir);
const claudeSettingsPath = normalizePathForComparison(resolvedClaudeSettingsPath);
// Check if path is within ~/.ccs/
if (normalizedPath.startsWith(ccsDir)) {
if (isPathWithin(ccsDir, normalizedPath)) {
if (hasSymlinkSegment(resolvedCcsDir, resolvedPath)) {
return { valid: false, readonly: false, error: 'Access to this path is not allowed' };
}
// Block access to sensitive subdirectories
const relativePath = normalizedPath.slice(ccsDir.length);
if (relativePath.includes('/.git/') || relativePath.includes('/node_modules/')) {
const relativePath = path.relative(ccsDir, normalizedPath);
const pathSegments = relativePath.split(path.sep).filter(Boolean);
if (pathSegments.includes('.git') || pathSegments.includes('node_modules')) {
return { valid: false, readonly: false, error: 'Access to this path is not allowed' };
}
return { valid: true, readonly: false };
@@ -182,6 +231,9 @@ export function validateFilePath(filePath: string): {
// Allow read-only access to ~/.claude/settings.json
if (normalizedPath === claudeSettingsPath) {
if (isSymlinkPath(resolvedClaudeSettingsPath)) {
return { valid: false, readonly: false, error: 'Access to this path is not allowed' };
}
return { valid: true, readonly: true };
}
+2 -2
View File
@@ -7,8 +7,8 @@
import { Router, Request, Response } from 'express';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { getCcsDir } from '../utils/config-manager';
import { getClaudeConfigDir } from '../utils/claude-config-path';
export const sharedRoutes = Router();
@@ -141,7 +141,7 @@ function checkSymlinkStatus(): { valid: boolean; message: string } {
if (stats.isSymbolicLink()) {
const target = fs.readlinkSync(linkPath);
// Check if it points to ~/.claude/{linkType}
const expectedTarget = path.join(os.homedir(), '.claude', linkType);
const expectedTarget = path.join(getClaudeConfigDir(), linkType);
if (path.resolve(path.dirname(linkPath), target) === path.resolve(expectedTarget)) {
validLinks++;
}