mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 08:17:22 +00:00
refactor(api): extract service layer from api-command
- create api/services/ with 5 modules (800 → 426 lines) - validation-service, profile-types, profile-reader, profile-writer - separate CLI parsing from business logic Phase 5: Command Refactor (api-command.ts)
This commit is contained in:
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* API Services
|
||||||
|
*
|
||||||
|
* Barrel export for API-related business logic services.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Validation services
|
||||||
|
export { validateApiName, validateUrl, getUrlWarning, sanitizeBaseUrl } from './validation-service';
|
||||||
|
|
||||||
|
// Profile types
|
||||||
|
export {
|
||||||
|
type ModelMapping,
|
||||||
|
type ApiProfileInfo,
|
||||||
|
type CliproxyVariantInfo,
|
||||||
|
type ApiListResult,
|
||||||
|
type CreateApiProfileResult,
|
||||||
|
type RemoveApiProfileResult,
|
||||||
|
} from './profile-types';
|
||||||
|
|
||||||
|
// Profile read operations
|
||||||
|
export {
|
||||||
|
apiProfileExists,
|
||||||
|
isApiProfileConfigured,
|
||||||
|
listApiProfiles,
|
||||||
|
getApiProfileNames,
|
||||||
|
isUsingUnifiedConfig,
|
||||||
|
} from './profile-reader';
|
||||||
|
|
||||||
|
// Profile write operations
|
||||||
|
export { createApiProfile, removeApiProfile } from './profile-writer';
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* API Profile Reader Service
|
||||||
|
*
|
||||||
|
* Read operations for API profiles.
|
||||||
|
* Supports both unified YAML config and legacy JSON config.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { getCcsDir, loadConfig } from '../../utils/config-manager';
|
||||||
|
import { loadOrCreateUnifiedConfig, isUnifiedMode } from '../../config/unified-config-loader';
|
||||||
|
import { getProfileSecrets } from '../../config/secrets-manager';
|
||||||
|
import type { ApiProfileInfo, CliproxyVariantInfo, ApiListResult } from './profile-types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if API profile exists in config
|
||||||
|
*/
|
||||||
|
export function apiProfileExists(name: string): boolean {
|
||||||
|
try {
|
||||||
|
if (isUnifiedMode()) {
|
||||||
|
const config = loadOrCreateUnifiedConfig();
|
||||||
|
return name in config.profiles;
|
||||||
|
}
|
||||||
|
const config = loadConfig();
|
||||||
|
return name in config.profiles;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if API profile has real API key (not placeholder)
|
||||||
|
*/
|
||||||
|
export function isApiProfileConfigured(apiName: string): boolean {
|
||||||
|
try {
|
||||||
|
if (isUnifiedMode()) {
|
||||||
|
const secrets = getProfileSecrets(apiName);
|
||||||
|
const token = secrets?.ANTHROPIC_AUTH_TOKEN || '';
|
||||||
|
return token.length > 0 && !token.includes('YOUR_') && !token.includes('your-');
|
||||||
|
}
|
||||||
|
// Legacy: check settings.json file
|
||||||
|
const ccsDir = getCcsDir();
|
||||||
|
const settingsPath = path.join(ccsDir, `${apiName}.settings.json`);
|
||||||
|
if (!fs.existsSync(settingsPath)) return false;
|
||||||
|
|
||||||
|
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||||
|
const token = settings?.env?.ANTHROPIC_AUTH_TOKEN || '';
|
||||||
|
return token.length > 0 && !token.includes('YOUR_') && !token.includes('your-');
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all API profiles
|
||||||
|
*/
|
||||||
|
export function listApiProfiles(): ApiListResult {
|
||||||
|
const profiles: ApiProfileInfo[] = [];
|
||||||
|
const variants: CliproxyVariantInfo[] = [];
|
||||||
|
|
||||||
|
if (isUnifiedMode()) {
|
||||||
|
const unifiedConfig = loadOrCreateUnifiedConfig();
|
||||||
|
for (const name of Object.keys(unifiedConfig.profiles)) {
|
||||||
|
profiles.push({
|
||||||
|
name,
|
||||||
|
settingsPath: 'config.yaml',
|
||||||
|
isConfigured: isApiProfileConfigured(name),
|
||||||
|
configSource: 'unified',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// CLIProxy variants
|
||||||
|
for (const [name, variant] of Object.entries(unifiedConfig.cliproxy?.variants || {})) {
|
||||||
|
variants.push({
|
||||||
|
name,
|
||||||
|
provider: variant?.provider || 'unknown',
|
||||||
|
settings: variant?.settings || '-',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const config = loadConfig();
|
||||||
|
for (const [name, settingsPath] of Object.entries(config.profiles)) {
|
||||||
|
profiles.push({
|
||||||
|
name,
|
||||||
|
settingsPath: settingsPath as string,
|
||||||
|
isConfigured: isApiProfileConfigured(name),
|
||||||
|
configSource: 'legacy',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// CLIProxy variants
|
||||||
|
if (config.cliproxy) {
|
||||||
|
for (const [name, v] of Object.entries(config.cliproxy)) {
|
||||||
|
const variant = v as { provider: string; settings: string };
|
||||||
|
variants.push({
|
||||||
|
name,
|
||||||
|
provider: variant.provider,
|
||||||
|
settings: variant.settings,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { profiles, variants };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of available API profile names
|
||||||
|
*/
|
||||||
|
export function getApiProfileNames(): string[] {
|
||||||
|
if (isUnifiedMode()) {
|
||||||
|
const config = loadOrCreateUnifiedConfig();
|
||||||
|
return Object.keys(config.profiles);
|
||||||
|
}
|
||||||
|
const config = loadConfig();
|
||||||
|
return Object.keys(config.profiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if using unified config mode
|
||||||
|
*/
|
||||||
|
export function isUsingUnifiedConfig(): boolean {
|
||||||
|
return isUnifiedMode();
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* API Profile Types
|
||||||
|
*
|
||||||
|
* Shared type definitions for API profile services.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Model mapping for API profiles */
|
||||||
|
export interface ModelMapping {
|
||||||
|
default: string;
|
||||||
|
opus: string;
|
||||||
|
sonnet: string;
|
||||||
|
haiku: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** API profile info for listing */
|
||||||
|
export interface ApiProfileInfo {
|
||||||
|
name: string;
|
||||||
|
settingsPath: string;
|
||||||
|
isConfigured: boolean;
|
||||||
|
configSource: 'unified' | 'legacy';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CLIProxy variant info */
|
||||||
|
export interface CliproxyVariantInfo {
|
||||||
|
name: string;
|
||||||
|
provider: string;
|
||||||
|
settings: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result from list operation */
|
||||||
|
export interface ApiListResult {
|
||||||
|
profiles: ApiProfileInfo[];
|
||||||
|
variants: CliproxyVariantInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result from create operation */
|
||||||
|
export interface CreateApiProfileResult {
|
||||||
|
success: boolean;
|
||||||
|
settingsFile: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result from remove operation */
|
||||||
|
export interface RemoveApiProfileResult {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
/**
|
||||||
|
* API Profile Writer Service - Create/remove operations for API profiles.
|
||||||
|
* Supports both unified YAML config and legacy JSON config.
|
||||||
|
*/
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { getCcsDir, getConfigPath, loadConfig } from '../../utils/config-manager';
|
||||||
|
import {
|
||||||
|
loadOrCreateUnifiedConfig,
|
||||||
|
saveUnifiedConfig,
|
||||||
|
isUnifiedMode,
|
||||||
|
} from '../../config/unified-config-loader';
|
||||||
|
import { deleteAllProfileSecrets } from '../../config/secrets-manager';
|
||||||
|
import type { ModelMapping, CreateApiProfileResult, RemoveApiProfileResult } from './profile-types';
|
||||||
|
|
||||||
|
/** Create settings.json file for API profile (legacy format) */
|
||||||
|
function createSettingsFile(
|
||||||
|
name: string,
|
||||||
|
baseUrl: string,
|
||||||
|
apiKey: string,
|
||||||
|
models: ModelMapping
|
||||||
|
): string {
|
||||||
|
const ccsDir = getCcsDir();
|
||||||
|
const settingsPath = path.join(ccsDir, `${name}.settings.json`);
|
||||||
|
|
||||||
|
const settings = {
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_BASE_URL: baseUrl,
|
||||||
|
ANTHROPIC_AUTH_TOKEN: apiKey,
|
||||||
|
ANTHROPIC_MODEL: models.default,
|
||||||
|
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus,
|
||||||
|
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
|
||||||
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
|
||||||
|
return settingsPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update config.json with new API profile (legacy format) */
|
||||||
|
function updateLegacyConfig(name: string): void {
|
||||||
|
const configPath = getConfigPath();
|
||||||
|
const ccsDir = getCcsDir();
|
||||||
|
|
||||||
|
let config: { profiles: Record<string, string>; cliproxy?: Record<string, unknown> };
|
||||||
|
try {
|
||||||
|
config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||||
|
} catch {
|
||||||
|
config = { profiles: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
const relativePath = `~/.ccs/${name}.settings.json`;
|
||||||
|
config.profiles[name] = relativePath;
|
||||||
|
|
||||||
|
if (!fs.existsSync(ccsDir)) {
|
||||||
|
fs.mkdirSync(ccsDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write config atomically
|
||||||
|
const tempPath = configPath + '.tmp';
|
||||||
|
fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
||||||
|
fs.renameSync(tempPath, configPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create API profile in unified config */
|
||||||
|
function createApiProfileUnified(
|
||||||
|
name: string,
|
||||||
|
baseUrl: string,
|
||||||
|
apiKey: string,
|
||||||
|
models: ModelMapping
|
||||||
|
): void {
|
||||||
|
const ccsDir = path.join(os.homedir(), '.ccs');
|
||||||
|
const settingsFile = `${name}.settings.json`;
|
||||||
|
const settingsPath = path.join(ccsDir, settingsFile);
|
||||||
|
|
||||||
|
const settings = {
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_BASE_URL: baseUrl,
|
||||||
|
ANTHROPIC_AUTH_TOKEN: apiKey,
|
||||||
|
ANTHROPIC_MODEL: models.default,
|
||||||
|
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus,
|
||||||
|
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
|
||||||
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!fs.existsSync(ccsDir)) {
|
||||||
|
fs.mkdirSync(ccsDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
|
||||||
|
|
||||||
|
const config = loadOrCreateUnifiedConfig();
|
||||||
|
config.profiles[name] = {
|
||||||
|
type: 'api',
|
||||||
|
settings: `~/.ccs/${settingsFile}`,
|
||||||
|
};
|
||||||
|
saveUnifiedConfig(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a new API profile */
|
||||||
|
export function createApiProfile(
|
||||||
|
name: string,
|
||||||
|
baseUrl: string,
|
||||||
|
apiKey: string,
|
||||||
|
models: ModelMapping
|
||||||
|
): CreateApiProfileResult {
|
||||||
|
try {
|
||||||
|
const settingsFile = `~/.ccs/${name}.settings.json`;
|
||||||
|
|
||||||
|
if (isUnifiedMode()) {
|
||||||
|
createApiProfileUnified(name, baseUrl, apiKey, models);
|
||||||
|
} else {
|
||||||
|
createSettingsFile(name, baseUrl, apiKey, models);
|
||||||
|
updateLegacyConfig(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, settingsFile };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
settingsFile: '',
|
||||||
|
error: (error as Error).message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove API profile from unified config */
|
||||||
|
function removeApiProfileUnified(name: string): void {
|
||||||
|
const config = loadOrCreateUnifiedConfig();
|
||||||
|
const profile = config.profiles[name];
|
||||||
|
|
||||||
|
if (!profile) {
|
||||||
|
throw new Error(`API profile not found: ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the settings file if it exists
|
||||||
|
if (profile.settings) {
|
||||||
|
const settingsPath = profile.settings.replace(/^~/, os.homedir());
|
||||||
|
if (fs.existsSync(settingsPath)) {
|
||||||
|
fs.unlinkSync(settingsPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delete config.profiles[name];
|
||||||
|
|
||||||
|
// Clear default if it was the deleted profile
|
||||||
|
if (config.default === name) {
|
||||||
|
config.default = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveUnifiedConfig(config);
|
||||||
|
|
||||||
|
// Remove any legacy secrets
|
||||||
|
deleteAllProfileSecrets(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove API profile from legacy config */
|
||||||
|
function removeApiProfileLegacy(name: string): void {
|
||||||
|
const config = loadConfig();
|
||||||
|
delete config.profiles[name];
|
||||||
|
|
||||||
|
const configPath = getConfigPath();
|
||||||
|
const tempPath = configPath + '.tmp';
|
||||||
|
fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
||||||
|
fs.renameSync(tempPath, configPath);
|
||||||
|
|
||||||
|
// Remove settings file if it exists
|
||||||
|
const expandedPath = path.join(getCcsDir(), `${name}.settings.json`);
|
||||||
|
if (fs.existsSync(expandedPath)) {
|
||||||
|
fs.unlinkSync(expandedPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove an API profile */
|
||||||
|
export function removeApiProfile(name: string): RemoveApiProfileResult {
|
||||||
|
try {
|
||||||
|
if (isUnifiedMode()) {
|
||||||
|
removeApiProfileUnified(name);
|
||||||
|
} else {
|
||||||
|
removeApiProfileLegacy(name);
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* API Validation Service
|
||||||
|
*
|
||||||
|
* Provides validation functions for API profile names and URLs.
|
||||||
|
* Extracted from api-command.ts for reuse and testability.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { isReservedName } from '../../config/reserved-names';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate API profile name
|
||||||
|
* @returns Error message if invalid, null if valid
|
||||||
|
*/
|
||||||
|
export function validateApiName(name: string): string | null {
|
||||||
|
if (!name) {
|
||||||
|
return 'API name is required';
|
||||||
|
}
|
||||||
|
if (!/^[a-zA-Z][a-zA-Z0-9._-]*$/.test(name)) {
|
||||||
|
return 'API name must start with letter, contain only letters, numbers, dot, dash, underscore';
|
||||||
|
}
|
||||||
|
if (name.length > 32) {
|
||||||
|
return 'API name must be 32 characters or less';
|
||||||
|
}
|
||||||
|
if (isReservedName(name)) {
|
||||||
|
return `'${name}' is a reserved name`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate URL format
|
||||||
|
* @returns Error message if invalid, null if valid
|
||||||
|
*/
|
||||||
|
export function validateUrl(url: string): string | null {
|
||||||
|
if (!url) {
|
||||||
|
return 'Base URL is required';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
new URL(url);
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return 'Invalid URL format (must include protocol, e.g., https://)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if URL looks like it includes endpoint path (common mistake)
|
||||||
|
* @returns Warning message if problematic, null if OK
|
||||||
|
*/
|
||||||
|
export function getUrlWarning(url: string): string | null {
|
||||||
|
const problematicPaths = ['/chat/completions', '/v1/messages', '/messages', '/completions'];
|
||||||
|
const lowerUrl = url.toLowerCase();
|
||||||
|
|
||||||
|
for (const pathSuffix of problematicPaths) {
|
||||||
|
if (lowerUrl.endsWith(pathSuffix)) {
|
||||||
|
return (
|
||||||
|
`URL ends with "${pathSuffix}" - Claude appends this automatically.\n` +
|
||||||
|
` You likely want: ${url.replace(new RegExp(pathSuffix + '$', 'i'), '')}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize URL by removing common endpoint suffixes
|
||||||
|
*/
|
||||||
|
export function sanitizeBaseUrl(url: string): string {
|
||||||
|
const suffixes = ['/chat/completions', '/v1/messages', '/messages', '/completions'];
|
||||||
|
let sanitized = url;
|
||||||
|
for (const suffix of suffixes) {
|
||||||
|
const regex = new RegExp(suffix + '$', 'i');
|
||||||
|
sanitized = sanitized.replace(regex, '');
|
||||||
|
}
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
+100
-482
@@ -4,14 +4,10 @@
|
|||||||
* Manages CCS API profiles for custom API providers.
|
* Manages CCS API profiles for custom API providers.
|
||||||
* Commands: create, list, remove
|
* Commands: create, list, remove
|
||||||
*
|
*
|
||||||
* Supports dual-mode configuration:
|
* CLI parsing and output formatting only.
|
||||||
* - Unified YAML format (config.yaml) when CCS_UNIFIED_CONFIG=1 or config.yaml exists
|
* Business logic delegated to src/api/services/.
|
||||||
* - Legacy JSON format (config.json, *.settings.json) as fallback
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as os from 'os';
|
|
||||||
import * as path from 'path';
|
|
||||||
import {
|
import {
|
||||||
initUI,
|
initUI,
|
||||||
header,
|
header,
|
||||||
@@ -26,15 +22,19 @@ import {
|
|||||||
infoBox,
|
infoBox,
|
||||||
} from '../utils/ui';
|
} from '../utils/ui';
|
||||||
import { InteractivePrompt } from '../utils/prompt';
|
import { InteractivePrompt } from '../utils/prompt';
|
||||||
import { getCcsDir, getConfigPath, loadConfig } from '../utils/config-manager';
|
|
||||||
import { isReservedName } from '../config/reserved-names';
|
|
||||||
import {
|
import {
|
||||||
hasUnifiedConfig,
|
validateApiName,
|
||||||
loadOrCreateUnifiedConfig,
|
validateUrl,
|
||||||
saveUnifiedConfig,
|
getUrlWarning,
|
||||||
} from '../config/unified-config-loader';
|
sanitizeBaseUrl,
|
||||||
import { deleteAllProfileSecrets } from '../config/secrets-manager';
|
apiProfileExists,
|
||||||
import { isUnifiedConfigEnabled } from '../config/feature-flags';
|
listApiProfiles,
|
||||||
|
createApiProfile,
|
||||||
|
removeApiProfile,
|
||||||
|
getApiProfileNames,
|
||||||
|
isUsingUnifiedConfig,
|
||||||
|
type ModelMapping,
|
||||||
|
} from '../api/services';
|
||||||
|
|
||||||
interface ApiCommandArgs {
|
interface ApiCommandArgs {
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -45,9 +45,7 @@ interface ApiCommandArgs {
|
|||||||
yes?: boolean;
|
yes?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Parse command line arguments for api commands */
|
||||||
* Parse command line arguments for api commands
|
|
||||||
*/
|
|
||||||
function parseArgs(args: string[]): ApiCommandArgs {
|
function parseArgs(args: string[]): ApiCommandArgs {
|
||||||
const result: ApiCommandArgs = {};
|
const result: ApiCommandArgs = {};
|
||||||
|
|
||||||
@@ -72,228 +70,7 @@ function parseArgs(args: string[]): ApiCommandArgs {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Handle 'ccs api create' command */
|
||||||
* Validate API profile name
|
|
||||||
*/
|
|
||||||
function validateApiName(name: string): string | null {
|
|
||||||
if (!name) {
|
|
||||||
return 'API name is required';
|
|
||||||
}
|
|
||||||
if (!/^[a-zA-Z][a-zA-Z0-9._-]*$/.test(name)) {
|
|
||||||
return 'API name must start with letter, contain only letters, numbers, dot, dash, underscore';
|
|
||||||
}
|
|
||||||
if (name.length > 32) {
|
|
||||||
return 'API name must be 32 characters or less';
|
|
||||||
}
|
|
||||||
// Use centralized reserved names list
|
|
||||||
if (isReservedName(name)) {
|
|
||||||
return `'${name}' is a reserved name`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate URL format and warn about common mistakes
|
|
||||||
*/
|
|
||||||
function validateUrl(url: string): string | null {
|
|
||||||
if (!url) {
|
|
||||||
return 'Base URL is required';
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
new URL(url);
|
|
||||||
return null;
|
|
||||||
} catch {
|
|
||||||
return 'Invalid URL format (must include protocol, e.g., https://)';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if URL looks like it includes endpoint path (common mistake)
|
|
||||||
* Returns warning message if problematic, null if OK
|
|
||||||
*/
|
|
||||||
function getUrlWarning(url: string): string | null {
|
|
||||||
const problematicPaths = ['/chat/completions', '/v1/messages', '/messages', '/completions'];
|
|
||||||
const lowerUrl = url.toLowerCase();
|
|
||||||
|
|
||||||
for (const path of problematicPaths) {
|
|
||||||
if (lowerUrl.endsWith(path)) {
|
|
||||||
return (
|
|
||||||
`URL ends with "${path}" - Claude appends this automatically.\n` +
|
|
||||||
` You likely want: ${url.replace(new RegExp(path + '$', 'i'), '')}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if unified config mode is active
|
|
||||||
*/
|
|
||||||
function isUnifiedMode(): boolean {
|
|
||||||
return hasUnifiedConfig() || isUnifiedConfigEnabled();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if API profile already exists in config
|
|
||||||
*/
|
|
||||||
function apiExists(name: string): boolean {
|
|
||||||
try {
|
|
||||||
if (isUnifiedMode()) {
|
|
||||||
const config = loadOrCreateUnifiedConfig();
|
|
||||||
return name in config.profiles;
|
|
||||||
}
|
|
||||||
const config = loadConfig();
|
|
||||||
return name in config.profiles;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Model mapping for API profiles */
|
|
||||||
interface ModelMapping {
|
|
||||||
default: string;
|
|
||||||
opus: string;
|
|
||||||
sonnet: string;
|
|
||||||
haiku: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create settings.json file for API profile
|
|
||||||
* Includes all 4 model fields for proper Claude CLI integration
|
|
||||||
*/
|
|
||||||
function createSettingsFile(
|
|
||||||
name: string,
|
|
||||||
baseUrl: string,
|
|
||||||
apiKey: string,
|
|
||||||
models: ModelMapping
|
|
||||||
): string {
|
|
||||||
const ccsDir = getCcsDir();
|
|
||||||
const settingsPath = path.join(ccsDir, `${name}.settings.json`);
|
|
||||||
|
|
||||||
const settings = {
|
|
||||||
env: {
|
|
||||||
ANTHROPIC_BASE_URL: baseUrl,
|
|
||||||
ANTHROPIC_AUTH_TOKEN: apiKey,
|
|
||||||
ANTHROPIC_MODEL: models.default,
|
|
||||||
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus,
|
|
||||||
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
|
|
||||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
|
|
||||||
return settingsPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update config.json with new API profile
|
|
||||||
*/
|
|
||||||
function updateConfig(name: string, _settingsPath: string): void {
|
|
||||||
const configPath = getConfigPath();
|
|
||||||
const ccsDir = getCcsDir();
|
|
||||||
|
|
||||||
// Read existing config or create new
|
|
||||||
let config: { profiles: Record<string, string>; cliproxy?: Record<string, unknown> };
|
|
||||||
try {
|
|
||||||
config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
||||||
} catch {
|
|
||||||
config = { profiles: {} };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use relative path with ~ for portability
|
|
||||||
const relativePath = `~/.ccs/${name}.settings.json`;
|
|
||||||
config.profiles[name] = relativePath;
|
|
||||||
|
|
||||||
// Ensure directory exists
|
|
||||||
if (!fs.existsSync(ccsDir)) {
|
|
||||||
fs.mkdirSync(ccsDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write config atomically (write to temp, then rename)
|
|
||||||
const tempPath = configPath + '.tmp';
|
|
||||||
fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
|
||||||
fs.renameSync(tempPath, configPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create API profile in unified config
|
|
||||||
* Creates *.settings.json file and stores reference in config.yaml
|
|
||||||
* (matching Claude's ~/.claude/settings.json pattern)
|
|
||||||
*/
|
|
||||||
function createApiProfileUnified(
|
|
||||||
name: string,
|
|
||||||
baseUrl: string,
|
|
||||||
apiKey: string,
|
|
||||||
models: ModelMapping
|
|
||||||
): void {
|
|
||||||
const ccsDir = path.join(os.homedir(), '.ccs');
|
|
||||||
const settingsFile = `${name}.settings.json`;
|
|
||||||
const settingsPath = path.join(ccsDir, settingsFile);
|
|
||||||
|
|
||||||
// Create settings file with all env vars (matching Claude's pattern)
|
|
||||||
const settings = {
|
|
||||||
env: {
|
|
||||||
ANTHROPIC_BASE_URL: baseUrl,
|
|
||||||
ANTHROPIC_AUTH_TOKEN: apiKey,
|
|
||||||
ANTHROPIC_MODEL: models.default,
|
|
||||||
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus,
|
|
||||||
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
|
|
||||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Ensure directory exists
|
|
||||||
if (!fs.existsSync(ccsDir)) {
|
|
||||||
fs.mkdirSync(ccsDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write settings file
|
|
||||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
|
|
||||||
|
|
||||||
// Store reference in config.yaml
|
|
||||||
const config = loadOrCreateUnifiedConfig();
|
|
||||||
config.profiles[name] = {
|
|
||||||
type: 'api',
|
|
||||||
settings: `~/.ccs/${settingsFile}`,
|
|
||||||
};
|
|
||||||
saveUnifiedConfig(config);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove API profile from unified config
|
|
||||||
*/
|
|
||||||
function removeApiProfileUnified(name: string): void {
|
|
||||||
const config = loadOrCreateUnifiedConfig();
|
|
||||||
const profile = config.profiles[name];
|
|
||||||
|
|
||||||
if (!profile) {
|
|
||||||
throw new Error(`API profile not found: ${name}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete the settings file if it exists
|
|
||||||
if (profile.settings) {
|
|
||||||
const settingsPath = profile.settings.replace(/^~/, os.homedir());
|
|
||||||
if (fs.existsSync(settingsPath)) {
|
|
||||||
fs.unlinkSync(settingsPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
delete config.profiles[name];
|
|
||||||
|
|
||||||
// Clear default if it was the deleted profile
|
|
||||||
if (config.default === name) {
|
|
||||||
config.default = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
saveUnifiedConfig(config);
|
|
||||||
|
|
||||||
// Remove any legacy secrets (backward compat)
|
|
||||||
deleteAllProfileSecrets(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle 'ccs api create' command
|
|
||||||
*/
|
|
||||||
async function handleCreate(args: string[]): Promise<void> {
|
async function handleCreate(args: string[]): Promise<void> {
|
||||||
await initUI();
|
await initUI();
|
||||||
const parsedArgs = parseArgs(args);
|
const parsedArgs = parseArgs(args);
|
||||||
@@ -316,7 +93,7 @@ async function handleCreate(args: string[]): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if exists
|
// Check if exists
|
||||||
if (apiExists(name) && !parsedArgs.force) {
|
if (apiProfileExists(name) && !parsedArgs.force) {
|
||||||
console.log(fail(`API '${name}' already exists`));
|
console.log(fail(`API '${name}' already exists`));
|
||||||
console.log(` Use ${color('--force', 'command')} to overwrite`);
|
console.log(` Use ${color('--force', 'command')} to overwrite`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -327,9 +104,7 @@ async function handleCreate(args: string[]): Promise<void> {
|
|||||||
if (!baseUrl) {
|
if (!baseUrl) {
|
||||||
baseUrl = await InteractivePrompt.input(
|
baseUrl = await InteractivePrompt.input(
|
||||||
'API Base URL (e.g., https://api.example.com/v1 - without /chat/completions)',
|
'API Base URL (e.g., https://api.example.com/v1 - without /chat/completions)',
|
||||||
{
|
{ validate: validateUrl }
|
||||||
validate: validateUrl,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const error = validateUrl(baseUrl);
|
const error = validateUrl(baseUrl);
|
||||||
@@ -348,10 +123,9 @@ async function handleCreate(args: string[]): Promise<void> {
|
|||||||
default: false,
|
default: false,
|
||||||
});
|
});
|
||||||
if (!continueAnyway) {
|
if (!continueAnyway) {
|
||||||
// Let user re-enter URL
|
|
||||||
baseUrl = await InteractivePrompt.input('API Base URL', {
|
baseUrl = await InteractivePrompt.input('API Base URL', {
|
||||||
validate: validateUrl,
|
validate: validateUrl,
|
||||||
default: baseUrl.replace(/\/(chat\/completions|v1\/messages|messages|completions)$/i, ''),
|
default: sanitizeBaseUrl(baseUrl),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -366,7 +140,7 @@ async function handleCreate(args: string[]): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Model (optional)
|
// Step 4: Model configuration
|
||||||
const defaultModel = 'claude-sonnet-4-5-20250929';
|
const defaultModel = 'claude-sonnet-4-5-20250929';
|
||||||
let model = parsedArgs.model;
|
let model = parsedArgs.model;
|
||||||
if (!model && !parsedArgs.yes) {
|
if (!model && !parsedArgs.yes) {
|
||||||
@@ -377,16 +151,12 @@ async function handleCreate(args: string[]): Promise<void> {
|
|||||||
model = model || defaultModel;
|
model = model || defaultModel;
|
||||||
|
|
||||||
// Step 5: Model mapping for Opus/Sonnet/Haiku
|
// Step 5: Model mapping for Opus/Sonnet/Haiku
|
||||||
// Auto-show if user entered a custom model, otherwise ask
|
|
||||||
let opusModel = model;
|
let opusModel = model;
|
||||||
let sonnetModel = model;
|
let sonnetModel = model;
|
||||||
let haikuModel = model;
|
let haikuModel = model;
|
||||||
|
|
||||||
const isCustomModel = model !== defaultModel;
|
const isCustomModel = model !== defaultModel;
|
||||||
|
|
||||||
if (!parsedArgs.yes) {
|
if (!parsedArgs.yes) {
|
||||||
// If user entered custom model, auto-prompt for model mapping
|
|
||||||
// Otherwise, ask if they want to configure it
|
|
||||||
let wantCustomMapping = isCustomModel;
|
let wantCustomMapping = isCustomModel;
|
||||||
|
|
||||||
if (!isCustomModel) {
|
if (!isCustomModel) {
|
||||||
@@ -400,11 +170,13 @@ async function handleCreate(args: string[]): Promise<void> {
|
|||||||
|
|
||||||
if (wantCustomMapping) {
|
if (wantCustomMapping) {
|
||||||
console.log('');
|
console.log('');
|
||||||
if (isCustomModel) {
|
console.log(
|
||||||
console.log(dim('Configure model IDs for each tier (defaults to your model):'));
|
dim(
|
||||||
} else {
|
isCustomModel
|
||||||
console.log(dim('Leave blank to use the default model for each.'));
|
? 'Configure model IDs for each tier (defaults to your model):'
|
||||||
}
|
: 'Leave blank to use the default model for each.'
|
||||||
|
)
|
||||||
|
);
|
||||||
opusModel =
|
opusModel =
|
||||||
(await InteractivePrompt.input('Opus model (ANTHROPIC_DEFAULT_OPUS_MODEL)', {
|
(await InteractivePrompt.input('Opus model (ANTHROPIC_DEFAULT_OPUS_MODEL)', {
|
||||||
default: model,
|
default: model,
|
||||||
@@ -420,7 +192,6 @@ async function handleCreate(args: string[]): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build model mapping
|
|
||||||
const models: ModelMapping = {
|
const models: ModelMapping = {
|
||||||
default: model,
|
default: model,
|
||||||
opus: opusModel,
|
opus: opusModel,
|
||||||
@@ -428,234 +199,102 @@ async function handleCreate(args: string[]): Promise<void> {
|
|||||||
haiku: haikuModel,
|
haiku: haikuModel,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if custom model mapping is configured
|
// Create profile
|
||||||
const hasCustomMapping = opusModel !== model || sonnetModel !== model || haikuModel !== model;
|
|
||||||
|
|
||||||
// Create files
|
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(info('Creating API profile...'));
|
console.log(info('Creating API profile...'));
|
||||||
|
|
||||||
try {
|
const result = createApiProfile(name, baseUrl, apiKey, models);
|
||||||
const settingsFile = `~/.ccs/${name}.settings.json`;
|
|
||||||
|
|
||||||
if (isUnifiedMode()) {
|
if (!result.success) {
|
||||||
// Use unified config format
|
console.log(fail(`Failed to create API profile: ${result.error}`));
|
||||||
createApiProfileUnified(name, baseUrl, apiKey, models);
|
|
||||||
console.log('');
|
|
||||||
|
|
||||||
// Build info message
|
|
||||||
let infoMsg =
|
|
||||||
`API: ${name}\n` +
|
|
||||||
`Config: ~/.ccs/config.yaml\n` +
|
|
||||||
`Settings: ${settingsFile}\n` +
|
|
||||||
`Base URL: ${baseUrl}\n` +
|
|
||||||
`Model: ${model}`;
|
|
||||||
|
|
||||||
if (hasCustomMapping) {
|
|
||||||
infoMsg +=
|
|
||||||
`\n\nModel Mapping:\n` +
|
|
||||||
` Opus: ${opusModel}\n` +
|
|
||||||
` Sonnet: ${sonnetModel}\n` +
|
|
||||||
` Haiku: ${haikuModel}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(infoBox(infoMsg, 'API Profile Created'));
|
|
||||||
} else {
|
|
||||||
// Use legacy JSON format
|
|
||||||
const settingsPath = createSettingsFile(name, baseUrl, apiKey, models);
|
|
||||||
updateConfig(name, settingsPath);
|
|
||||||
console.log('');
|
|
||||||
|
|
||||||
let infoMsg =
|
|
||||||
`API: ${name}\n` +
|
|
||||||
`Settings: ${settingsFile}\n` +
|
|
||||||
`Base URL: ${baseUrl}\n` +
|
|
||||||
`Model: ${model}`;
|
|
||||||
|
|
||||||
if (hasCustomMapping) {
|
|
||||||
infoMsg +=
|
|
||||||
`\n\nModel Mapping:\n` +
|
|
||||||
` Opus: ${opusModel}\n` +
|
|
||||||
` Sonnet: ${sonnetModel}\n` +
|
|
||||||
` Haiku: ${haikuModel}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(infoBox(infoMsg, 'API Profile Created'));
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('');
|
|
||||||
console.log(header('Usage'));
|
|
||||||
console.log(` ${color(`ccs ${name} "your prompt"`, 'command')}`);
|
|
||||||
console.log('');
|
|
||||||
console.log(header('Edit Settings'));
|
|
||||||
console.log(` ${dim('To modify env vars later:')}`);
|
|
||||||
console.log(` ${color(`nano ${settingsFile.replace('~', '$HOME')}`, 'command')}`);
|
|
||||||
console.log('');
|
|
||||||
} catch (error) {
|
|
||||||
console.log(fail(`Failed to create API profile: ${(error as Error).message}`));
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
// Display success
|
||||||
* Check if API profile has real API key (not placeholder)
|
console.log('');
|
||||||
*/
|
const hasCustomMapping = opusModel !== model || sonnetModel !== model || haikuModel !== model;
|
||||||
function isApiConfigured(apiName: string): boolean {
|
let infoMsg =
|
||||||
try {
|
`API: ${name}\n` +
|
||||||
if (isUnifiedMode()) {
|
`Config: ${isUsingUnifiedConfig() ? '~/.ccs/config.yaml' : '~/.ccs/config.json'}\n` +
|
||||||
// Check secrets.yaml for unified config
|
`Settings: ${result.settingsFile}\n` +
|
||||||
const { getProfileSecrets } = require('../config/secrets-manager');
|
`Base URL: ${baseUrl}\n` +
|
||||||
const secrets = getProfileSecrets(apiName);
|
`Model: ${model}`;
|
||||||
const token = secrets?.ANTHROPIC_AUTH_TOKEN || '';
|
|
||||||
return token.length > 0 && !token.includes('YOUR_') && !token.includes('your-');
|
|
||||||
}
|
|
||||||
// Legacy: check settings.json file
|
|
||||||
const ccsDir = getCcsDir();
|
|
||||||
const settingsPath = path.join(ccsDir, `${apiName}.settings.json`);
|
|
||||||
if (!fs.existsSync(settingsPath)) return false;
|
|
||||||
|
|
||||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
if (hasCustomMapping) {
|
||||||
const token = settings?.env?.ANTHROPIC_AUTH_TOKEN || '';
|
infoMsg +=
|
||||||
// Check if it's a placeholder or empty
|
`\n\nModel Mapping:\n` +
|
||||||
return token.length > 0 && !token.includes('YOUR_') && !token.includes('your-');
|
` Opus: ${opusModel}\n` +
|
||||||
} catch {
|
` Sonnet: ${sonnetModel}\n` +
|
||||||
return false;
|
` Haiku: ${haikuModel}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(infoBox(infoMsg, 'API Profile Created'));
|
||||||
|
console.log('');
|
||||||
|
console.log(header('Usage'));
|
||||||
|
console.log(` ${color(`ccs ${name} "your prompt"`, 'command')}`);
|
||||||
|
console.log('');
|
||||||
|
console.log(header('Edit Settings'));
|
||||||
|
console.log(` ${dim('To modify env vars later:')}`);
|
||||||
|
console.log(` ${color(`nano ${result.settingsFile.replace('~', '$HOME')}`, 'command')}`);
|
||||||
|
console.log('');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Handle 'ccs api list' command */
|
||||||
* Handle 'ccs api list' command
|
|
||||||
*/
|
|
||||||
async function handleList(): Promise<void> {
|
async function handleList(): Promise<void> {
|
||||||
await initUI();
|
await initUI();
|
||||||
|
|
||||||
console.log(header('CCS API Profiles'));
|
console.log(header('CCS API Profiles'));
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
try {
|
const { profiles, variants } = listApiProfiles();
|
||||||
if (isUnifiedMode()) {
|
|
||||||
// List from unified config
|
|
||||||
const unifiedConfig = loadOrCreateUnifiedConfig();
|
|
||||||
const apis = Object.keys(unifiedConfig.profiles);
|
|
||||||
|
|
||||||
if (apis.length === 0) {
|
if (profiles.length === 0) {
|
||||||
console.log(warn('No API profiles configured'));
|
console.log(warn('No API profiles configured'));
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('To create an API profile:');
|
console.log('To create an API profile:');
|
||||||
console.log(` ${color('ccs api create', 'command')}`);
|
console.log(` ${color('ccs api create', 'command')}`);
|
||||||
console.log('');
|
console.log('');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build table data with status indicators
|
// Build table data
|
||||||
const rows: string[][] = apis.map((name) => {
|
const rows: string[][] = profiles.map((p) => {
|
||||||
const status = isApiConfigured(name) ? color('[OK]', 'success') : color('[!]', 'warning');
|
const status = p.isConfigured ? color('[OK]', 'success') : color('[!]', 'warning');
|
||||||
return [name, 'config.yaml', status];
|
return [p.name, p.settingsPath, status];
|
||||||
});
|
});
|
||||||
|
|
||||||
// Print table
|
const colWidths = isUsingUnifiedConfig() ? [15, 20, 10] : [15, 35, 10];
|
||||||
console.log(
|
console.log(
|
||||||
table(rows, {
|
table(rows, {
|
||||||
head: ['API', 'Config', 'Status'],
|
head: ['API', isUsingUnifiedConfig() ? 'Config' : 'Settings File', 'Status'],
|
||||||
colWidths: [15, 20, 10],
|
colWidths,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
// Show CLIProxy variants if any
|
// Show CLIProxy variants if any
|
||||||
const variants = Object.keys(unifiedConfig.cliproxy?.variants || {});
|
if (variants.length > 0) {
|
||||||
if (variants.length > 0) {
|
console.log(subheader('CLIProxy Variants'));
|
||||||
console.log(subheader('CLIProxy Variants'));
|
const cliproxyRows = variants.map((v) => [v.name, v.provider, v.settings]);
|
||||||
const cliproxyRows = variants.map((name) => {
|
|
||||||
const variant = unifiedConfig.cliproxy?.variants[name];
|
|
||||||
return [name, variant?.provider || 'unknown', variant?.settings || '-'];
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
table(cliproxyRows, {
|
|
||||||
head: ['Variant', 'Provider', 'Settings'],
|
|
||||||
colWidths: [15, 15, 30],
|
|
||||||
})
|
|
||||||
);
|
|
||||||
console.log('');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(dim(`Total: ${apis.length} API profile(s)`));
|
|
||||||
console.log('');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Legacy: list from config.json
|
|
||||||
const config = loadConfig();
|
|
||||||
const apis = Object.keys(config.profiles);
|
|
||||||
|
|
||||||
if (apis.length === 0) {
|
|
||||||
console.log(warn('No API profiles configured'));
|
|
||||||
console.log('');
|
|
||||||
console.log('To create an API profile:');
|
|
||||||
console.log(` ${color('ccs api create', 'command')}`);
|
|
||||||
console.log('');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build table data with status indicators
|
|
||||||
const rows: string[][] = apis.map((name) => {
|
|
||||||
const settingsPath = config.profiles[name];
|
|
||||||
const status = isApiConfigured(name) ? color('[OK]', 'success') : color('[!]', 'warning');
|
|
||||||
|
|
||||||
return [name, settingsPath, status];
|
|
||||||
});
|
|
||||||
|
|
||||||
// Print table
|
|
||||||
console.log(
|
console.log(
|
||||||
table(rows, {
|
table(cliproxyRows, {
|
||||||
head: ['API', 'Settings File', 'Status'],
|
head: ['Variant', 'Provider', 'Settings'],
|
||||||
colWidths: [15, 35, 10],
|
colWidths: [15, 15, 30],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
// Show CLIProxy variants if any
|
|
||||||
if (config.cliproxy && Object.keys(config.cliproxy).length > 0) {
|
|
||||||
console.log(subheader('CLIProxy Variants'));
|
|
||||||
const cliproxyRows = Object.entries(config.cliproxy).map(([name, v]) => {
|
|
||||||
const variant = v as { provider: string; settings: string };
|
|
||||||
return [name, variant.provider, variant.settings];
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
table(cliproxyRows, {
|
|
||||||
head: ['Variant', 'Provider', 'Settings'],
|
|
||||||
colWidths: [15, 15, 30],
|
|
||||||
})
|
|
||||||
);
|
|
||||||
console.log('');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(dim(`Total: ${apis.length} API profile(s)`));
|
|
||||||
console.log('');
|
|
||||||
} catch (error) {
|
|
||||||
console.log(fail(`Failed to list API profiles: ${(error as Error).message}`));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(dim(`Total: ${profiles.length} API profile(s)`));
|
||||||
|
console.log('');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Handle 'ccs api remove' command */
|
||||||
* Handle 'ccs api remove' command
|
|
||||||
*/
|
|
||||||
async function handleRemove(args: string[]): Promise<void> {
|
async function handleRemove(args: string[]): Promise<void> {
|
||||||
await initUI();
|
await initUI();
|
||||||
const parsedArgs = parseArgs(args);
|
const parsedArgs = parseArgs(args);
|
||||||
|
|
||||||
// Get available APIs based on config mode
|
const apis = getApiProfileNames();
|
||||||
let apis: string[];
|
|
||||||
if (isUnifiedMode()) {
|
|
||||||
const unifiedConfig = loadOrCreateUnifiedConfig();
|
|
||||||
apis = Object.keys(unifiedConfig.profiles);
|
|
||||||
} else {
|
|
||||||
const config = loadConfig();
|
|
||||||
apis = Object.keys(config.profiles);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (apis.length === 0) {
|
if (apis.length === 0) {
|
||||||
console.log(warn('No API profiles to remove'));
|
console.log(warn('No API profiles to remove'));
|
||||||
@@ -691,7 +330,7 @@ async function handleRemove(args: string[]): Promise<void> {
|
|||||||
// Confirm deletion
|
// Confirm deletion
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(`API '${color(name, 'command')}' will be removed.`);
|
console.log(`API '${color(name, 'command')}' will be removed.`);
|
||||||
if (isUnifiedMode()) {
|
if (isUsingUnifiedConfig()) {
|
||||||
console.log(' Config: ~/.ccs/config.yaml');
|
console.log(' Config: ~/.ccs/config.yaml');
|
||||||
console.log(' Secrets: ~/.ccs/secrets.yaml');
|
console.log(' Secrets: ~/.ccs/secrets.yaml');
|
||||||
} else {
|
} else {
|
||||||
@@ -708,37 +347,18 @@ async function handleRemove(args: string[]): Promise<void> {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const result = removeApiProfile(name);
|
||||||
if (isUnifiedMode()) {
|
|
||||||
// Remove from unified config
|
|
||||||
removeApiProfileUnified(name);
|
|
||||||
} else {
|
|
||||||
// Remove from legacy config.json
|
|
||||||
const config = loadConfig();
|
|
||||||
delete config.profiles[name];
|
|
||||||
const configPath = getConfigPath();
|
|
||||||
const tempPath = configPath + '.tmp';
|
|
||||||
fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
|
||||||
fs.renameSync(tempPath, configPath);
|
|
||||||
|
|
||||||
// Remove settings file if it exists
|
if (!result.success) {
|
||||||
const expandedPath = path.join(getCcsDir(), `${name}.settings.json`);
|
console.log(fail(`Failed to remove API profile: ${result.error}`));
|
||||||
if (fs.existsSync(expandedPath)) {
|
|
||||||
fs.unlinkSync(expandedPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(ok(`API profile removed: ${name}`));
|
|
||||||
console.log('');
|
|
||||||
} catch (error) {
|
|
||||||
console.log(fail(`Failed to remove API profile: ${(error as Error).message}`));
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(ok(`API profile removed: ${name}`));
|
||||||
|
console.log('');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Show help for api commands */
|
||||||
* Show help for api commands
|
|
||||||
*/
|
|
||||||
async function showHelp(): Promise<void> {
|
async function showHelp(): Promise<void> {
|
||||||
await initUI();
|
await initUI();
|
||||||
|
|
||||||
@@ -774,9 +394,7 @@ async function showHelp(): Promise<void> {
|
|||||||
console.log('');
|
console.log('');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Main api command router */
|
||||||
* Main api command router
|
|
||||||
*/
|
|
||||||
export async function handleApiCommand(args: string[]): Promise<void> {
|
export async function handleApiCommand(args: string[]): Promise<void> {
|
||||||
const command = args[0];
|
const command = args[0];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user