mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-03 05:19:21 +00:00
fix(core): resolve edge cases and hardcoded drift
This commit is contained in:
1 parent
6074fcb0b6
commit
343ec959fc
19 files changed
+894
-228
No files matched your search
+119
-16
@@ -53,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, ' ')
|
||||
@@ -71,39 +77,129 @@ function renderPresetHelpLine(preset: ProviderPreset, idWidth: number): string {
|
||||
return ` ${color(paddedId, 'command')} ${presetName} - ${presetDescription}`;
|
||||
}
|
||||
|
||||
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 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 */
|
||||
function parseArgs(args: string[]): ApiCommandArgs {
|
||||
export function parseApiCommandArgs(args: string[]): ApiCommandArgs {
|
||||
const result: ApiCommandArgs = {
|
||||
force: hasAnyFlag(args, ['--force']),
|
||||
yes: hasAnyFlag(args, ['--yes', '-y']),
|
||||
errors: [],
|
||||
};
|
||||
|
||||
let remaining = [...args];
|
||||
|
||||
const baseUrl = extractOption(remaining, ['--base-url']);
|
||||
if (baseUrl.value) result.baseUrl = baseUrl.value;
|
||||
remaining = baseUrl.remainingArgs;
|
||||
remaining = applyRepeatedOption(
|
||||
remaining,
|
||||
['--base-url'],
|
||||
(value) => {
|
||||
result.baseUrl = value;
|
||||
},
|
||||
() => {
|
||||
result.errors.push('Missing value for --base-url');
|
||||
}
|
||||
);
|
||||
|
||||
const apiKey = extractOption(remaining, ['--api-key']);
|
||||
if (apiKey.value) result.apiKey = apiKey.value;
|
||||
remaining = apiKey.remainingArgs;
|
||||
remaining = applyRepeatedOption(
|
||||
remaining,
|
||||
['--api-key'],
|
||||
(value) => {
|
||||
result.apiKey = value;
|
||||
},
|
||||
() => {
|
||||
result.errors.push('Missing value for --api-key');
|
||||
}
|
||||
);
|
||||
|
||||
const model = extractOption(remaining, ['--model']);
|
||||
if (model.value) result.model = model.value;
|
||||
remaining = model.remainingArgs;
|
||||
remaining = applyRepeatedOption(
|
||||
remaining,
|
||||
['--model'],
|
||||
(value) => {
|
||||
result.model = value;
|
||||
},
|
||||
() => {
|
||||
result.errors.push('Missing value for --model');
|
||||
}
|
||||
);
|
||||
|
||||
const preset = extractOption(remaining, ['--preset']);
|
||||
if (preset.value) result.preset = preset.value;
|
||||
remaining = preset.remainingArgs;
|
||||
remaining = applyRepeatedOption(
|
||||
remaining,
|
||||
['--preset'],
|
||||
(value) => {
|
||||
result.preset = value;
|
||||
},
|
||||
() => {
|
||||
result.errors.push('Missing value for --preset');
|
||||
}
|
||||
);
|
||||
|
||||
result.name = remaining.find((arg) => !arg.startsWith('-'));
|
||||
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('');
|
||||
@@ -389,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();
|
||||
|
||||
|
||||
@@ -9,17 +9,43 @@ export interface ExtractedOption {
|
||||
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[]): ExtractedOption {
|
||||
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];
|
||||
@@ -27,7 +53,14 @@ export function extractOption(args: string[], flags: readonly string[]): Extract
|
||||
for (const flag of flags) {
|
||||
if (token === flag) {
|
||||
const next = remaining[i + 1];
|
||||
if (!next || next.startsWith('-')) {
|
||||
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 };
|
||||
}
|
||||
@@ -62,5 +95,20 @@ export function extractOption(args: string[], flags: readonly string[]): Extract
|
||||
|
||||
/** Returns true if any of the provided boolean flags are present. */
|
||||
export function hasAnyFlag(args: string[], flags: readonly string[]): boolean {
|
||||
return args.some((arg) => flags.includes(arg));
|
||||
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);
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -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})`],
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
@@ -7,7 +7,12 @@
|
||||
|
||||
import { CLIProxyBackend } from '../../cliproxy/types';
|
||||
import { DEFAULT_BACKEND } from '../../cliproxy/platform-detector';
|
||||
import { CLIPROXY_PROVIDER_IDS } from '../../cliproxy/provider-capabilities';
|
||||
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';
|
||||
@@ -75,38 +80,21 @@ 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 QuotaProvider = 'agy' | 'codex' | 'gemini' | 'ghcp';
|
||||
type QuotaProviderFilter = QuotaProvider | 'all';
|
||||
|
||||
const PROVIDER_ARG_HELP_TEXT = 'agy, codex, gemini, gemini-cli, ghcp, github-copilot, all';
|
||||
|
||||
const QUOTA_PROVIDER_ALIAS_MAP: Readonly<Record<string, QuotaProvider>> = {
|
||||
'gemini-cli': 'gemini',
|
||||
'github-copilot': 'ghcp',
|
||||
};
|
||||
|
||||
const QUOTA_PROVIDER_IDS = Object.freeze(
|
||||
CLIPROXY_PROVIDER_IDS.filter(
|
||||
(provider): provider is QuotaProvider =>
|
||||
provider === 'agy' || provider === 'codex' || provider === 'gemini' || provider === 'ghcp'
|
||||
)
|
||||
);
|
||||
|
||||
const QUOTA_PROVIDER_SET = new Set<QuotaProvider>(QUOTA_PROVIDER_IDS);
|
||||
type QuotaProviderFilter = QuotaSupportedProvider | 'all';
|
||||
|
||||
function normalizeQuotaProvider(value: string): QuotaProviderFilter | null {
|
||||
if (value === 'all') {
|
||||
return 'all';
|
||||
}
|
||||
|
||||
const normalized = QUOTA_PROVIDER_ALIAS_MAP[value] ?? value;
|
||||
if (!QUOTA_PROVIDER_SET.has(normalized as QuotaProvider)) {
|
||||
const canonicalProvider = mapExternalProviderName(value);
|
||||
if (!canonicalProvider || !isQuotaSupportedProvider(canonicalProvider)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalized as QuotaProvider;
|
||||
return canonicalProvider;
|
||||
}
|
||||
|
||||
function parseProviderArg(args: string[]): {
|
||||
@@ -119,14 +107,16 @@ function parseProviderArg(args: string[]): {
|
||||
}
|
||||
|
||||
if (extracted.missingValue || !extracted.value) {
|
||||
console.error(`Warning: --provider requires a value. Valid options: ${PROVIDER_ARG_HELP_TEXT}`);
|
||||
console.error(
|
||||
`Warning: --provider requires a value. Valid options: ${QUOTA_PROVIDER_HELP_TEXT}`
|
||||
);
|
||||
return { provider: 'all', remainingArgs: extracted.remainingArgs };
|
||||
}
|
||||
|
||||
const value = extracted.value.toLowerCase();
|
||||
const normalized = normalizeQuotaProvider(value);
|
||||
if (!normalized) {
|
||||
console.error(`Invalid provider '${value}'. Valid options: ${PROVIDER_ARG_HELP_TEXT}`);
|
||||
console.error(`Invalid provider '${value}'. Valid options: ${QUOTA_PROVIDER_HELP_TEXT}`);
|
||||
return { provider: 'all', remainingArgs: extracted.remainingArgs };
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -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('');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,11 @@ import {
|
||||
loadOrCreateUnifiedConfig,
|
||||
} from '../config/unified-config-loader';
|
||||
import { DEFAULT_IMAGE_ANALYSIS_CONFIG } from '../config/unified-config-types';
|
||||
import { CLIPROXY_PROVIDER_IDS } from '../cliproxy/provider-capabilities';
|
||||
import {
|
||||
CLIPROXY_PROVIDER_IDS,
|
||||
PROVIDER_CAPABILITIES,
|
||||
mapExternalProviderName,
|
||||
} from '../cliproxy/provider-capabilities';
|
||||
import { extractOption, hasAnyFlag } from './arg-extractor';
|
||||
|
||||
interface ImageAnalysisCommandOptions {
|
||||
@@ -20,9 +24,16 @@ interface ImageAnalysisCommandOptions {
|
||||
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 = {
|
||||
enable: hasAnyFlag(args, ['--enable']),
|
||||
@@ -46,6 +57,8 @@ function parseArgs(args: string[]): ImageAnalysisCommandOptions {
|
||||
const model = args[setModelIdx + 2];
|
||||
if (provider && model && !provider.startsWith('-') && !model.startsWith('-')) {
|
||||
options.setModel = { provider, model };
|
||||
} else {
|
||||
options.setModelError = '--set-model requires <provider> <model>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,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('');
|
||||
|
||||
@@ -160,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'));
|
||||
@@ -188,9 +209,9 @@ export async function handleConfigImageAnalysisCommand(args: string[]): Promise<
|
||||
|
||||
if (options.setModel) {
|
||||
const validProviders = [...CLIPROXY_PROVIDER_IDS];
|
||||
if (
|
||||
!validProviders.includes(options.setModel.provider as (typeof CLIPROXY_PROVIDER_IDS)[number])
|
||||
) {
|
||||
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);
|
||||
@@ -203,7 +224,7 @@ export async function handleConfigImageAnalysisCommand(args: string[]): Promise<
|
||||
}
|
||||
imageConfig.provider_models = {
|
||||
...imageConfig.provider_models,
|
||||
[options.setModel.provider]: model,
|
||||
[canonicalProvider]: model,
|
||||
};
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { initUI, header, subheader, color, dim, ok, fail, warn, info } from '../utils/ui';
|
||||
import { InteractivePrompt } from '../utils/prompt';
|
||||
import ProfileDetector, {
|
||||
@@ -58,7 +59,14 @@ function parseArgs(args: string[]): PersistCommandArgs {
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
@@ -71,6 +79,10 @@ function formatDisplayPath(filePath: string): string {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function getClaudeSettingsDisplayPath(): string {
|
||||
return formatDisplayPath(getClaudeSettingsPath());
|
||||
}
|
||||
|
||||
/** Read existing Claude settings.json with validation */
|
||||
function readClaudeSettings(): Record<string, unknown> {
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
@@ -175,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();
|
||||
@@ -190,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)
|
||||
@@ -328,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 });
|
||||
@@ -346,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);
|
||||
}
|
||||
@@ -377,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.');
|
||||
@@ -418,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('');
|
||||
}
|
||||
|
||||
@@ -478,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);
|
||||
@@ -502,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
|
||||
@@ -570,7 +603,7 @@ export async function handlePersistCommand(args: string[]): Promise<void> {
|
||||
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.'));
|
||||
|
||||
Reference in new issue
Block a user