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:
kaitranntt
2025-12-19 11:48:33 -05:00
parent 5c28935d1e
commit ecb6bb448a
6 changed files with 564 additions and 482 deletions
+30
View File
@@ -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';
+122
View File
@@ -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();
}
+47
View File
@@ -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;
}
+189
View File
@@ -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 };
}
}
+76
View File
@@ -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
View File
@@ -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];