mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 10:15:40 +00:00
refactor(commands): split CLI command routing
This commit is contained in:
1 parent
eeb0378054
commit
204475627a
21 files changed
+1569
-1198
No files matched your search
@@ -34,7 +34,7 @@ The main CLI is organized into domain-specific modules with barrel exports.
|
|||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── ccs.ts # Main entry point & CLI router
|
├── ccs.ts # Main entry point & profile execution flow
|
||||||
├── types/ # TypeScript type definitions
|
├── types/ # TypeScript type definitions
|
||||||
│ ├── index.ts # Barrel export (aggregates all types)
|
│ ├── index.ts # Barrel export (aggregates all types)
|
||||||
│ ├── cli.ts # CLI types (ParsedArgs, ExitCode)
|
│ ├── cli.ts # CLI types (ParsedArgs, ExitCode)
|
||||||
@@ -44,13 +44,19 @@ src/
|
|||||||
│ └── utils.ts # Utility types (ErrorCode, LogLevel)
|
│ └── utils.ts # Utility types (ErrorCode, LogLevel)
|
||||||
│
|
│
|
||||||
├── commands/ # CLI command handlers
|
├── commands/ # CLI command handlers
|
||||||
|
│ ├── api-command/ # API profile subcommands (split facade + handlers)
|
||||||
|
│ │ ├── index.ts # API command facade/router
|
||||||
|
│ │ ├── shared.ts # Shared API arg parsing helpers
|
||||||
|
│ │ └── [subcommand files...]
|
||||||
│ ├── cliproxy-command.ts # CLIProxy subcommand handling
|
│ ├── cliproxy-command.ts # CLIProxy subcommand handling
|
||||||
│ ├── config-command.ts # Config management commands
|
│ ├── config-command.ts # Config management commands
|
||||||
│ ├── config-image-analysis-command.ts # Image analysis hook config (NEW v7.34)
|
│ ├── config-image-analysis-command.ts # Image analysis hook config (NEW v7.34)
|
||||||
|
│ ├── named-command-router.ts # Reusable named-command dispatcher
|
||||||
│ ├── doctor-command.ts # Health diagnostics
|
│ ├── doctor-command.ts # Health diagnostics
|
||||||
│ ├── env-command.ts # Export shell env vars for third-party tools (v7.39)
|
│ ├── env-command.ts # Export shell env vars for third-party tools (v7.39)
|
||||||
│ ├── help-command.ts # Help text generation
|
│ ├── help-command.ts # Help text generation
|
||||||
│ ├── install-command.ts # Install/uninstall logic
|
│ ├── install-command.ts # Install/uninstall logic
|
||||||
|
│ ├── root-command-router.ts # Extracted top-level command dispatch from ccs.ts
|
||||||
│ ├── shell-completion-command.ts
|
│ ├── shell-completion-command.ts
|
||||||
│ ├── sync-command.ts # Symlink synchronization
|
│ ├── sync-command.ts # Symlink synchronization
|
||||||
│ ├── update-command.ts # Self-update logic
|
│ ├── update-command.ts # Self-update logic
|
||||||
|
|||||||
+2
-122
@@ -40,15 +40,7 @@ import { isCopilotSubcommandToken } from './copilot/constants';
|
|||||||
|
|
||||||
// Import centralized error handling
|
// Import centralized error handling
|
||||||
import { handleError, runCleanup } from './errors';
|
import { handleError, runCleanup } from './errors';
|
||||||
|
import { tryHandleRootCommand } from './commands/root-command-router';
|
||||||
// Import extracted command handlers
|
|
||||||
import { handleVersionCommand } from './commands/version-command';
|
|
||||||
import { handleHelpCommand } from './commands/help-command';
|
|
||||||
import { handleInstallCommand, handleUninstallCommand } from './commands/install-command';
|
|
||||||
import { handleDoctorCommand } from './commands/doctor-command';
|
|
||||||
import { handleSyncCommand } from './commands/sync-command';
|
|
||||||
import { handleShellCompletionCommand } from './commands/shell-completion-command';
|
|
||||||
import { handleUpdateCommand } from './commands/update-command';
|
|
||||||
|
|
||||||
// Import extracted utility functions
|
// Import extracted utility functions
|
||||||
import {
|
import {
|
||||||
@@ -462,119 +454,7 @@ async function main(): Promise<void> {
|
|||||||
console.warn('[!] Recovery failed:', (err as Error).message);
|
console.warn('[!] Recovery failed:', (err as Error).message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Special case: migrate command
|
if (await tryHandleRootCommand(args)) {
|
||||||
if (firstArg === 'migrate' || firstArg === '--migrate') {
|
|
||||||
const { handleMigrateCommand, printMigrateHelp } = await import('./commands/migrate-command');
|
|
||||||
const migrateArgs = args.slice(1);
|
|
||||||
|
|
||||||
if (migrateArgs.includes('--help') || migrateArgs.includes('-h')) {
|
|
||||||
printMigrateHelp();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await handleMigrateCommand(migrateArgs);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Special case: update command
|
|
||||||
if (firstArg === 'update' || firstArg === '--update') {
|
|
||||||
const updateArgs = args.slice(1);
|
|
||||||
|
|
||||||
// Handle --help for update command
|
|
||||||
if (updateArgs.includes('--help') || updateArgs.includes('-h')) {
|
|
||||||
console.log('');
|
|
||||||
console.log('Usage: ccs update [options]');
|
|
||||||
console.log('');
|
|
||||||
console.log('Options:');
|
|
||||||
console.log(' --force Force reinstall current version');
|
|
||||||
console.log(' --beta, --dev Install from dev channel (unstable)');
|
|
||||||
console.log(' --help, -h Show this help message');
|
|
||||||
console.log('');
|
|
||||||
console.log('Examples:');
|
|
||||||
console.log(' ccs update Update to latest stable');
|
|
||||||
console.log(' ccs update --force Force reinstall');
|
|
||||||
console.log(' ccs update --beta Install dev channel');
|
|
||||||
console.log('');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const forceFlag = updateArgs.includes('--force');
|
|
||||||
const betaFlag = updateArgs.includes('--beta') || updateArgs.includes('--dev');
|
|
||||||
await handleUpdateCommand({ force: forceFlag, beta: betaFlag });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const commandAliases: Record<string, string> = {
|
|
||||||
'--version': 'version',
|
|
||||||
'-v': 'version',
|
|
||||||
'--help': 'help',
|
|
||||||
'-h': 'help',
|
|
||||||
'--doctor': 'doctor',
|
|
||||||
'--sync': 'sync',
|
|
||||||
'--cleanup': 'cleanup',
|
|
||||||
'--setup': 'setup',
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizedFirstArg = commandAliases[firstArg] || firstArg;
|
|
||||||
|
|
||||||
const earlyCommandHandlers: Record<string, () => Promise<void>> = {
|
|
||||||
version: async () => handleVersionCommand(),
|
|
||||||
help: async () => handleHelpCommand(),
|
|
||||||
'--install': async () => handleInstallCommand(),
|
|
||||||
'--uninstall': async () => handleUninstallCommand(),
|
|
||||||
'--shell-completion': async () => handleShellCompletionCommand(args.slice(1)),
|
|
||||||
'-sc': async () => handleShellCompletionCommand(args.slice(1)),
|
|
||||||
doctor: async () => handleDoctorCommand(args.slice(1)),
|
|
||||||
sync: async () => handleSyncCommand(),
|
|
||||||
cleanup: async () => {
|
|
||||||
const { handleCleanupCommand } = await import('./commands/cleanup-command');
|
|
||||||
await handleCleanupCommand(args.slice(1));
|
|
||||||
},
|
|
||||||
auth: async () => {
|
|
||||||
const AuthCommandsModule = await import('./auth/auth-commands');
|
|
||||||
const AuthCommands = AuthCommandsModule.default;
|
|
||||||
const authCommands = new AuthCommands();
|
|
||||||
await authCommands.route(args.slice(1));
|
|
||||||
},
|
|
||||||
api: async () => {
|
|
||||||
const { handleApiCommand } = await import('./commands/api-command');
|
|
||||||
await handleApiCommand(args.slice(1));
|
|
||||||
},
|
|
||||||
cliproxy: async () => {
|
|
||||||
const { handleCliproxyCommand } = await import('./commands/cliproxy-command');
|
|
||||||
await handleCliproxyCommand(args.slice(1));
|
|
||||||
},
|
|
||||||
config: async () => {
|
|
||||||
const { handleConfigCommand } = await import('./commands/config-command');
|
|
||||||
await handleConfigCommand(args.slice(1));
|
|
||||||
},
|
|
||||||
tokens: async () => {
|
|
||||||
const { handleTokensCommand } = await import('./commands/tokens-command');
|
|
||||||
const exitCode = await handleTokensCommand(args.slice(1));
|
|
||||||
process.exit(exitCode);
|
|
||||||
},
|
|
||||||
persist: async () => {
|
|
||||||
const { handlePersistCommand } = await import('./commands/persist-command');
|
|
||||||
await handlePersistCommand(args.slice(1));
|
|
||||||
},
|
|
||||||
env: async () => {
|
|
||||||
const { handleEnvCommand } = await import('./commands/env-command');
|
|
||||||
await handleEnvCommand(args.slice(1));
|
|
||||||
},
|
|
||||||
setup: async () => {
|
|
||||||
const { handleSetupCommand } = await import('./commands/setup-command');
|
|
||||||
await handleSetupCommand(args.slice(1));
|
|
||||||
},
|
|
||||||
cursor: async () => {
|
|
||||||
const { handleCursorCommand } = await import('./commands/cursor-command');
|
|
||||||
const exitCode = await handleCursorCommand(args.slice(1));
|
|
||||||
process.exit(exitCode);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const earlyCommandHandler = earlyCommandHandlers[normalizedFirstArg];
|
|
||||||
if (earlyCommandHandler) {
|
|
||||||
await earlyCommandHandler();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,47 @@
|
|||||||
|
import { copyApiProfile } from '../../api/services';
|
||||||
|
import { fail, info, initUI, ok, warn } from '../../utils/ui';
|
||||||
|
import { InteractivePrompt } from '../../utils/prompt';
|
||||||
|
import { exitOnApiCommandErrors, extractPositionalArgs, parseApiCommandArgs } from './shared';
|
||||||
|
|
||||||
|
export async function handleApiCopyCommand(args: string[]): Promise<void> {
|
||||||
|
await initUI();
|
||||||
|
const parsedArgs = parseApiCommandArgs(args);
|
||||||
|
exitOnApiCommandErrors(parsedArgs.errors);
|
||||||
|
|
||||||
|
const positionals = extractPositionalArgs(args);
|
||||||
|
const source = positionals[0];
|
||||||
|
let destination = positionals[1];
|
||||||
|
|
||||||
|
if (!source) {
|
||||||
|
console.log(fail('Source profile is required. Usage: ccs api copy <source> <destination>'));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!destination) {
|
||||||
|
destination = await InteractivePrompt.input('Destination profile name');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsedArgs.yes) {
|
||||||
|
const confirmed = await InteractivePrompt.confirm(
|
||||||
|
`Copy profile "${source}" to "${destination}"?`,
|
||||||
|
{ default: true }
|
||||||
|
);
|
||||||
|
if (!confirmed) {
|
||||||
|
console.log(info('Cancelled'));
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = copyApiProfile(source, destination, {
|
||||||
|
target: parsedArgs.target,
|
||||||
|
force: parsedArgs.force,
|
||||||
|
});
|
||||||
|
if (!result.success) {
|
||||||
|
console.log(fail(result.error || 'Failed to copy profile'));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(ok(`Profile copied: ${source} -> ${destination}`));
|
||||||
|
result.warnings?.forEach((warningMessage) => console.log(warn(warningMessage)));
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
import {
|
||||||
|
apiProfileExists,
|
||||||
|
createApiProfile,
|
||||||
|
getPresetById,
|
||||||
|
getPresetIds,
|
||||||
|
getUrlWarning,
|
||||||
|
isOpenRouterUrl,
|
||||||
|
isUsingUnifiedConfig,
|
||||||
|
pickOpenRouterModel,
|
||||||
|
sanitizeBaseUrl,
|
||||||
|
validateApiName,
|
||||||
|
validateUrl,
|
||||||
|
type ModelMapping,
|
||||||
|
type ProviderPreset,
|
||||||
|
} from '../../api/services';
|
||||||
|
import { syncToLocalConfig } from '../../cliproxy/sync/local-config-sync';
|
||||||
|
import type { TargetType } from '../../targets/target-adapter';
|
||||||
|
import { color, dim, fail, header, info, infoBox, initUI, warn } from '../../utils/ui';
|
||||||
|
import { InteractivePrompt } from '../../utils/prompt';
|
||||||
|
import { exitOnApiCommandErrors, parseApiCommandArgs } from './shared';
|
||||||
|
|
||||||
|
function resolvePresetOrExit(presetId?: string): ProviderPreset | null {
|
||||||
|
if (!presetId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preset = getPresetById(presetId);
|
||||||
|
if (preset) {
|
||||||
|
return preset;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(fail(`Unknown preset: ${presetId}`));
|
||||||
|
console.log('');
|
||||||
|
console.log('Available presets:');
|
||||||
|
getPresetIds().forEach((id) => console.log(` - ${id}`));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveProfileName(
|
||||||
|
providedName: string | undefined,
|
||||||
|
preset: ProviderPreset | null
|
||||||
|
): Promise<string> {
|
||||||
|
const name = providedName || preset?.defaultProfileName;
|
||||||
|
if (!name) {
|
||||||
|
return InteractivePrompt.input('API name', {
|
||||||
|
validate: validateApiName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = validateApiName(name);
|
||||||
|
if (error) {
|
||||||
|
console.log(fail(error));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveBaseUrl(
|
||||||
|
providedBaseUrl: string | undefined,
|
||||||
|
preset: ProviderPreset | null
|
||||||
|
): Promise<string> {
|
||||||
|
let baseUrl = providedBaseUrl ?? preset?.baseUrl ?? '';
|
||||||
|
|
||||||
|
if (!baseUrl && !preset) {
|
||||||
|
baseUrl = await InteractivePrompt.input(
|
||||||
|
'API Base URL (e.g., https://api.example.com/v1 - without /chat/completions)',
|
||||||
|
{ validate: validateUrl }
|
||||||
|
);
|
||||||
|
} else if (!preset) {
|
||||||
|
const error = validateUrl(baseUrl);
|
||||||
|
if (error) {
|
||||||
|
console.log(fail(error));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!preset) {
|
||||||
|
const urlWarning = getUrlWarning(baseUrl);
|
||||||
|
if (urlWarning) {
|
||||||
|
console.log('');
|
||||||
|
console.log(warn(urlWarning));
|
||||||
|
const continueAnyway = await InteractivePrompt.confirm('Continue with this URL anyway?', {
|
||||||
|
default: false,
|
||||||
|
});
|
||||||
|
if (!continueAnyway) {
|
||||||
|
baseUrl = await InteractivePrompt.input('API Base URL', {
|
||||||
|
validate: validateUrl,
|
||||||
|
default: sanitizeBaseUrl(baseUrl),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(info(`Using preset: ${preset.name}`));
|
||||||
|
console.log(dim(` ${preset.description}`));
|
||||||
|
console.log(
|
||||||
|
dim(
|
||||||
|
preset.baseUrl
|
||||||
|
? ` Base URL: ${preset.baseUrl}`
|
||||||
|
: ' Auth: Native Anthropic API (x-api-key header)'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
console.log('');
|
||||||
|
return baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveApiKey(
|
||||||
|
providedApiKey: string | undefined,
|
||||||
|
preset: ProviderPreset | null
|
||||||
|
): Promise<string> {
|
||||||
|
if (preset?.requiresApiKey === false) {
|
||||||
|
if (providedApiKey) {
|
||||||
|
console.log(dim(`Note: Using provided API key for ${preset.name} (optional)`));
|
||||||
|
return providedApiKey;
|
||||||
|
}
|
||||||
|
console.log(info(`No API key required for ${preset.name}`));
|
||||||
|
return preset.apiKeyPlaceholder || preset.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (providedApiKey) {
|
||||||
|
return providedApiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyPrompt = preset?.apiKeyHint ? `API Key (${preset.apiKeyHint})` : 'API Key';
|
||||||
|
const apiKey = await InteractivePrompt.password(keyPrompt);
|
||||||
|
if (!apiKey) {
|
||||||
|
console.log(fail('API key is required'));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
return apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveModelConfiguration(
|
||||||
|
baseUrl: string,
|
||||||
|
preset: ProviderPreset | null,
|
||||||
|
providedModel: string | undefined,
|
||||||
|
yes: boolean | undefined
|
||||||
|
): Promise<{ model: string; models: ModelMapping }> {
|
||||||
|
let openRouterModel: string | undefined;
|
||||||
|
let openRouterTierMapping: { opus?: string; sonnet?: string; haiku?: string } | undefined;
|
||||||
|
|
||||||
|
if (isOpenRouterUrl(baseUrl) && !providedModel) {
|
||||||
|
console.log('');
|
||||||
|
console.log(info('OpenRouter detected!'));
|
||||||
|
const useInteractive = await InteractivePrompt.confirm('Browse models interactively?', {
|
||||||
|
default: true,
|
||||||
|
});
|
||||||
|
if (useInteractive) {
|
||||||
|
const selection = await pickOpenRouterModel();
|
||||||
|
if (selection) {
|
||||||
|
openRouterModel = selection.model;
|
||||||
|
openRouterTierMapping = selection.tierMapping;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
console.log(dim('Note: For OpenRouter, ANTHROPIC_API_KEY should be empty.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultModel = preset?.defaultModel || 'claude-sonnet-4-6';
|
||||||
|
let model = providedModel || openRouterModel || preset?.defaultModel;
|
||||||
|
if (!model && !yes && !preset) {
|
||||||
|
model = await InteractivePrompt.input('Default model (ANTHROPIC_MODEL)', {
|
||||||
|
default: defaultModel,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
model = model || defaultModel;
|
||||||
|
|
||||||
|
let opusModel = openRouterTierMapping?.opus || model;
|
||||||
|
let sonnetModel = openRouterTierMapping?.sonnet || model;
|
||||||
|
let haikuModel = openRouterTierMapping?.haiku || model;
|
||||||
|
const shouldPromptForMapping = !yes && !openRouterTierMapping && !preset;
|
||||||
|
|
||||||
|
if (shouldPromptForMapping) {
|
||||||
|
let wantCustomMapping = model !== defaultModel;
|
||||||
|
if (!wantCustomMapping) {
|
||||||
|
console.log('');
|
||||||
|
console.log(dim('Some API proxies route different model types to different backends.'));
|
||||||
|
wantCustomMapping = await InteractivePrompt.confirm(
|
||||||
|
'Configure different models for Opus/Sonnet/Haiku?',
|
||||||
|
{ default: false }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wantCustomMapping) {
|
||||||
|
console.log('');
|
||||||
|
console.log(dim('Leave blank to use the default model for each tier.'));
|
||||||
|
opusModel =
|
||||||
|
(await InteractivePrompt.input('Opus model (ANTHROPIC_DEFAULT_OPUS_MODEL)', {
|
||||||
|
default: model,
|
||||||
|
})) || model;
|
||||||
|
sonnetModel =
|
||||||
|
(await InteractivePrompt.input('Sonnet model (ANTHROPIC_DEFAULT_SONNET_MODEL)', {
|
||||||
|
default: model,
|
||||||
|
})) || model;
|
||||||
|
haikuModel =
|
||||||
|
(await InteractivePrompt.input('Haiku model (ANTHROPIC_DEFAULT_HAIKU_MODEL)', {
|
||||||
|
default: model,
|
||||||
|
})) || model;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
model,
|
||||||
|
models: {
|
||||||
|
default: model,
|
||||||
|
opus: opusModel,
|
||||||
|
sonnet: sonnetModel,
|
||||||
|
haiku: haikuModel,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveDefaultTarget(
|
||||||
|
providedTarget: TargetType | undefined,
|
||||||
|
yes: boolean | undefined
|
||||||
|
): Promise<TargetType> {
|
||||||
|
if (providedTarget) {
|
||||||
|
return providedTarget;
|
||||||
|
}
|
||||||
|
if (yes) {
|
||||||
|
return 'claude';
|
||||||
|
}
|
||||||
|
|
||||||
|
const useDroidByDefault = await InteractivePrompt.confirm(
|
||||||
|
'Set default target to Factory Droid for this profile?',
|
||||||
|
{ default: false }
|
||||||
|
);
|
||||||
|
return useDroidByDefault ? 'droid' : 'claude';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleApiCreateCommand(args: string[]): Promise<void> {
|
||||||
|
await initUI();
|
||||||
|
const parsedArgs = parseApiCommandArgs(args);
|
||||||
|
exitOnApiCommandErrors(parsedArgs.errors);
|
||||||
|
|
||||||
|
console.log(header('Create API Profile'));
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
const preset = resolvePresetOrExit(parsedArgs.preset);
|
||||||
|
const name = await resolveProfileName(parsedArgs.name, preset);
|
||||||
|
|
||||||
|
if (apiProfileExists(name) && !parsedArgs.force) {
|
||||||
|
console.log(fail(`API '${name}' already exists`));
|
||||||
|
console.log(` Use ${color('--force', 'command')} to overwrite`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let baseUrl = await resolveBaseUrl(parsedArgs.baseUrl, preset);
|
||||||
|
if (baseUrl && baseUrl.includes('api.anthropic.com') && !preset) {
|
||||||
|
console.log('');
|
||||||
|
console.log(info('Anthropic Direct API detected. Base URL will be omitted for native auth.'));
|
||||||
|
baseUrl = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = await resolveApiKey(parsedArgs.apiKey, preset);
|
||||||
|
const { model, models } = await resolveModelConfiguration(
|
||||||
|
baseUrl,
|
||||||
|
preset,
|
||||||
|
parsedArgs.model,
|
||||||
|
parsedArgs.yes
|
||||||
|
);
|
||||||
|
const target = await resolveDefaultTarget(parsedArgs.target, parsedArgs.yes);
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log(info('Creating API profile...'));
|
||||||
|
const result = createApiProfile(name, baseUrl || '', apiKey, models, target);
|
||||||
|
if (!result.success) {
|
||||||
|
console.log(fail(`Failed to create API profile: ${result.error}`));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
syncToLocalConfig();
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
console.log(`[i] Auto-sync to CLIProxy config skipped: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasCustomMapping =
|
||||||
|
models.opus !== model || models.sonnet !== model || models.haiku !== model;
|
||||||
|
let details =
|
||||||
|
`API: ${name}\n` +
|
||||||
|
`Config: ${isUsingUnifiedConfig() ? '~/.ccs/config.yaml' : '~/.ccs/config.json'}\n` +
|
||||||
|
`Settings: ${result.settingsFile}\n` +
|
||||||
|
`Base URL: ${baseUrl}\n` +
|
||||||
|
`Model: ${model}\n` +
|
||||||
|
`Target: ${target}`;
|
||||||
|
|
||||||
|
if (hasCustomMapping) {
|
||||||
|
details +=
|
||||||
|
`\n\nModel Mapping:\n` +
|
||||||
|
` Opus: ${models.opus}\n` +
|
||||||
|
` Sonnet: ${models.sonnet}\n` +
|
||||||
|
` Haiku: ${models.haiku}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log(infoBox(details, 'API Profile Created'));
|
||||||
|
console.log('');
|
||||||
|
console.log(header('Usage'));
|
||||||
|
if (target === 'droid') {
|
||||||
|
console.log(
|
||||||
|
` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses droid by default')}`
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
` ${color(`ccsd ${name} "your prompt"`, 'command')} ${dim('# explicit droid alias')}`
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
` ${color(`ccs ${name} --target claude "your prompt"`, 'command')} ${dim('# override to Claude')}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses claude by default')}`
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
` ${color(`ccs ${name} --target droid "your prompt"`, 'command')} ${dim('# run on droid for this call')}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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('');
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { discoverApiProfileOrphans, registerApiProfileOrphans } from '../../api/services';
|
||||||
|
import { color, fail, header, info, initUI, ok, table, warn } from '../../utils/ui';
|
||||||
|
import { hasAnyFlag } from '../arg-extractor';
|
||||||
|
import { API_KNOWN_FLAGS, parseOptionalTargetFlag } from './shared';
|
||||||
|
|
||||||
|
export async function handleApiDiscoverCommand(args: string[]): Promise<void> {
|
||||||
|
await initUI();
|
||||||
|
const register = hasAnyFlag(args, ['--register']);
|
||||||
|
const jsonOutput = hasAnyFlag(args, ['--json']);
|
||||||
|
const force = hasAnyFlag(args, ['--force']);
|
||||||
|
|
||||||
|
const targetParsed = parseOptionalTargetFlag(args, [...API_KNOWN_FLAGS, '--register', '--json']);
|
||||||
|
if (targetParsed.errors.length > 0) {
|
||||||
|
targetParsed.errors.forEach((errorMessage) => console.log(fail(errorMessage)));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = discoverApiProfileOrphans();
|
||||||
|
if (jsonOutput) {
|
||||||
|
console.log(JSON.stringify(result, null, 2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(header('Discover Orphan API Profiles'));
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
if (result.orphans.length === 0) {
|
||||||
|
console.log(ok('No orphan settings files found.'));
|
||||||
|
console.log('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
table(
|
||||||
|
result.orphans.map((orphan) => {
|
||||||
|
const status = orphan.validation.valid ? color('[OK]', 'success') : color('[X]', 'error');
|
||||||
|
const issueSummary =
|
||||||
|
orphan.validation.issues.length > 0
|
||||||
|
? orphan.validation.issues[0].message
|
||||||
|
: 'Ready to register';
|
||||||
|
return [orphan.name, status, issueSummary];
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
head: ['Profile', 'Status', 'Validation'],
|
||||||
|
colWidths: [20, 10, 64],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
if (!register) {
|
||||||
|
console.log(info('To register discovered profiles:'));
|
||||||
|
console.log(` ${color('ccs api discover --register', 'command')}`);
|
||||||
|
console.log('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const registration = registerApiProfileOrphans({
|
||||||
|
target: targetParsed.target || 'claude',
|
||||||
|
force,
|
||||||
|
});
|
||||||
|
console.log(ok(`Registered: ${registration.registered.length}`));
|
||||||
|
if (registration.skipped.length > 0) {
|
||||||
|
console.log(warn(`Skipped: ${registration.skipped.length}`));
|
||||||
|
registration.skipped.forEach((item) => {
|
||||||
|
console.log(` - ${item.name}: ${item.reason}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { exportApiProfile } from '../../api/services';
|
||||||
|
import { fail, initUI, ok, warn } from '../../utils/ui';
|
||||||
|
import { extractOption, hasAnyFlag } from '../arg-extractor';
|
||||||
|
import { API_KNOWN_FLAGS, extractPositionalArgs } from './shared';
|
||||||
|
|
||||||
|
export async function handleApiExportCommand(args: string[]): Promise<void> {
|
||||||
|
await initUI();
|
||||||
|
const includeSecrets = hasAnyFlag(args, ['--include-secrets']);
|
||||||
|
|
||||||
|
const outExtracted = extractOption(args, ['--out'], {
|
||||||
|
allowDashValue: true,
|
||||||
|
knownFlags: [...API_KNOWN_FLAGS, '--out', '--include-secrets'],
|
||||||
|
});
|
||||||
|
if (outExtracted.found && (outExtracted.missingValue || !outExtracted.value)) {
|
||||||
|
console.log(fail('Missing value for --out'));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = extractPositionalArgs(outExtracted.remainingArgs)[0];
|
||||||
|
if (!name) {
|
||||||
|
console.log(fail('Profile name is required. Usage: ccs api export <name> [--out <file>]'));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = exportApiProfile(name, includeSecrets);
|
||||||
|
if (!result.success || !result.bundle) {
|
||||||
|
console.log(fail(result.error || 'Failed to export profile'));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputPath = path.resolve(outExtracted.value || `${name}.ccs-profile.json`);
|
||||||
|
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||||
|
fs.writeFileSync(outputPath, JSON.stringify(result.bundle, null, 2) + '\n', 'utf8');
|
||||||
|
|
||||||
|
console.log(ok(`Profile exported to: ${outputPath}`));
|
||||||
|
if (result.redacted) {
|
||||||
|
console.log(warn('Token was redacted in export. Use --include-secrets to include it.'));
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import {
|
||||||
|
PROVIDER_PRESETS,
|
||||||
|
getPresetAliases,
|
||||||
|
getPresetIds,
|
||||||
|
type ProviderPreset,
|
||||||
|
} from '../../api/services';
|
||||||
|
import { color, dim, fail, header, initUI, subheader } from '../../utils/ui';
|
||||||
|
import { sanitizeHelpText } from './shared';
|
||||||
|
|
||||||
|
function renderPresetHelpLine(preset: ProviderPreset, idWidth: number): string {
|
||||||
|
const presetId = sanitizeHelpText(preset.id) || 'unknown';
|
||||||
|
const paddedId = presetId.padEnd(idWidth);
|
||||||
|
const presetName = sanitizeHelpText(preset.name) || 'Unknown preset';
|
||||||
|
const presetDescription = sanitizeHelpText(preset.description) || 'No description';
|
||||||
|
return ` ${color(paddedId, 'command')} ${presetName} - ${presetDescription}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function showApiCommandHelp(): Promise<void> {
|
||||||
|
await initUI();
|
||||||
|
const presetIds = getPresetIds()
|
||||||
|
.map((id) => sanitizeHelpText(id))
|
||||||
|
.filter(Boolean);
|
||||||
|
const presetAliases = getPresetAliases();
|
||||||
|
const presetIdWidth = Math.max(0, ...presetIds.map((id) => id.length)) + 2;
|
||||||
|
|
||||||
|
console.log(header('CCS API Management'));
|
||||||
|
console.log('');
|
||||||
|
console.log(subheader('Usage'));
|
||||||
|
console.log(` ${color('ccs api', 'command')} <command> [options]`);
|
||||||
|
console.log('');
|
||||||
|
console.log(subheader('Commands'));
|
||||||
|
console.log(` ${color('create [name]', 'command')} Create new API profile (interactive)`);
|
||||||
|
console.log(` ${color('list', 'command')} List all API profiles`);
|
||||||
|
console.log(
|
||||||
|
` ${color('discover', 'command')} Discover orphan *.settings.json and register`
|
||||||
|
);
|
||||||
|
console.log(` ${color('copy <src> <dest>', 'command')} Duplicate API profile settings + config`);
|
||||||
|
console.log(
|
||||||
|
` ${color('export <name>', 'command')} Export profile bundle for cross-device transfer`
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
` ${color('import <file>', 'command')} Import profile bundle and register profile`
|
||||||
|
);
|
||||||
|
console.log(` ${color('remove <name>', 'command')} Remove an API profile`);
|
||||||
|
console.log('');
|
||||||
|
console.log(subheader('Options'));
|
||||||
|
console.log(
|
||||||
|
` ${color('--preset <id>', 'command')} Use provider preset (${presetIds.join(', ')})`
|
||||||
|
);
|
||||||
|
console.log(` ${color('--base-url <url>', 'command')} API base URL (create)`);
|
||||||
|
console.log(` ${color('--api-key <key>', 'command')} API key (create)`);
|
||||||
|
console.log(` ${color('--model <model>', 'command')} Default model (create)`);
|
||||||
|
console.log(
|
||||||
|
` ${color('--target <cli>', 'command')} Default target: claude or droid (create)`
|
||||||
|
);
|
||||||
|
console.log(` ${color('--register', 'command')} Register discovered orphan settings`);
|
||||||
|
console.log(` ${color('--json', 'command')} JSON output for discover command`);
|
||||||
|
console.log(` ${color('--out <file>', 'command')} Export bundle output path`);
|
||||||
|
console.log(` ${color('--include-secrets', 'command')} Include token in export bundle`);
|
||||||
|
console.log(` ${color('--name <name>', 'command')} Override profile name during import`);
|
||||||
|
console.log(
|
||||||
|
` ${color('--force', 'command')} Overwrite existing or bypass validation (create/discover/copy/import)`
|
||||||
|
);
|
||||||
|
console.log(` ${color('--yes, -y', 'command')} Skip confirmation prompts`);
|
||||||
|
console.log('');
|
||||||
|
console.log(subheader('Provider Presets'));
|
||||||
|
PROVIDER_PRESETS.forEach((preset) => console.log(renderPresetHelpLine(preset, presetIdWidth)));
|
||||||
|
Object.entries(presetAliases).forEach(([alias, canonical]) => {
|
||||||
|
const safeAlias = sanitizeHelpText(alias);
|
||||||
|
const safeCanonical = sanitizeHelpText(canonical);
|
||||||
|
console.log(
|
||||||
|
` ${dim(`Legacy alias: --preset ${safeAlias} (auto-mapped to ${safeCanonical})`)}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
console.log('');
|
||||||
|
console.log(subheader('Examples'));
|
||||||
|
console.log(` ${dim('# Interactive wizard')}`);
|
||||||
|
console.log(` ${color('ccs api create', 'command')}`);
|
||||||
|
console.log('');
|
||||||
|
console.log(` ${dim('# Quick setup with preset')}`);
|
||||||
|
console.log(` ${color('ccs api create --preset anthropic', 'command')}`);
|
||||||
|
console.log(` ${color('ccs api create --preset openrouter', 'command')}`);
|
||||||
|
console.log(` ${color('ccs api create --preset alibaba-coding-plan', 'command')}`);
|
||||||
|
console.log(` ${color('ccs api create --preset alibaba', 'command')} ${dim('# alias')}`);
|
||||||
|
console.log(` ${color('ccs api create --preset glm', 'command')}`);
|
||||||
|
console.log('');
|
||||||
|
console.log(` ${dim('# Create with name')}`);
|
||||||
|
console.log(` ${color('ccs api create myapi', 'command')}`);
|
||||||
|
console.log(` ${color('ccs api create mydroid --preset glm --target droid', 'command')}`);
|
||||||
|
console.log('');
|
||||||
|
console.log(` ${dim('# Remove API profile')}`);
|
||||||
|
console.log(` ${color('ccs api remove myapi', 'command')}`);
|
||||||
|
console.log('');
|
||||||
|
console.log(` ${dim('# Discover and register orphan settings files')}`);
|
||||||
|
console.log(` ${color('ccs api discover', 'command')}`);
|
||||||
|
console.log(` ${color('ccs api discover --register', 'command')}`);
|
||||||
|
console.log('');
|
||||||
|
console.log(` ${dim('# Duplicate an existing API profile')}`);
|
||||||
|
console.log(` ${color('ccs api copy glm glm-backup', 'command')}`);
|
||||||
|
console.log('');
|
||||||
|
console.log(` ${dim('# Export and import across devices')}`);
|
||||||
|
console.log(` ${color('ccs api export glm --out ./glm.ccs-profile.json', 'command')}`);
|
||||||
|
console.log(` ${color('ccs api import ./glm.ccs-profile.json', 'command')}`);
|
||||||
|
console.log('');
|
||||||
|
console.log(` ${dim('# Show all API profiles')}`);
|
||||||
|
console.log(` ${color('ccs api list', 'command')}`);
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function showUnknownApiCommand(command: string): Promise<void> {
|
||||||
|
await initUI();
|
||||||
|
console.log(fail(`Unknown command: ${command}`));
|
||||||
|
console.log('');
|
||||||
|
console.log('Run for help:');
|
||||||
|
console.log(` ${color('ccs api --help', 'command')}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import { importApiProfileBundle, type ProfileValidationIssue } from '../../api/services';
|
||||||
|
import { color, fail, info, initUI, ok, warn } from '../../utils/ui';
|
||||||
|
import { InteractivePrompt } from '../../utils/prompt';
|
||||||
|
import { extractOption, hasAnyFlag } from '../arg-extractor';
|
||||||
|
import { API_KNOWN_FLAGS, extractPositionalArgs, parseOptionalTargetFlag } from './shared';
|
||||||
|
|
||||||
|
function renderValidationIssue(issue: ProfileValidationIssue): void {
|
||||||
|
const indicator = issue.level === 'error' ? color('[X]', 'error') : color('[!]', 'warning');
|
||||||
|
console.log(`${indicator} ${issue.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleApiImportCommand(args: string[]): Promise<void> {
|
||||||
|
await initUI();
|
||||||
|
const force = hasAnyFlag(args, ['--force']);
|
||||||
|
const yes = hasAnyFlag(args, ['--yes', '-y']);
|
||||||
|
|
||||||
|
const nameExtracted = extractOption(args, ['--name'], {
|
||||||
|
allowDashValue: true,
|
||||||
|
knownFlags: [...API_KNOWN_FLAGS, '--name'],
|
||||||
|
});
|
||||||
|
if (nameExtracted.found && (nameExtracted.missingValue || !nameExtracted.value)) {
|
||||||
|
console.log(fail('Missing value for --name'));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetParsed = parseOptionalTargetFlag(nameExtracted.remainingArgs, [
|
||||||
|
...API_KNOWN_FLAGS,
|
||||||
|
'--name',
|
||||||
|
]);
|
||||||
|
if (targetParsed.errors.length > 0) {
|
||||||
|
targetParsed.errors.forEach((errorMessage) => console.log(fail(errorMessage)));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const importPath = extractPositionalArgs(targetParsed.remainingArgs)[0];
|
||||||
|
if (!importPath) {
|
||||||
|
console.log(
|
||||||
|
fail('Import file path is required. Usage: ccs api import <file> [--name <new-name>]')
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(importPath)) {
|
||||||
|
console.log(fail(`File not found: ${importPath}`));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let bundle: unknown;
|
||||||
|
try {
|
||||||
|
bundle = JSON.parse(fs.readFileSync(importPath, 'utf8'));
|
||||||
|
} catch (error) {
|
||||||
|
console.log(fail(`Invalid JSON file: ${(error as Error).message}`));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!yes) {
|
||||||
|
const confirmed = await InteractivePrompt.confirm(
|
||||||
|
`Import profile bundle from "${importPath}"?`,
|
||||||
|
{
|
||||||
|
default: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!confirmed) {
|
||||||
|
console.log(info('Cancelled'));
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = importApiProfileBundle(bundle, {
|
||||||
|
name: nameExtracted.value,
|
||||||
|
target: targetParsed.target,
|
||||||
|
force,
|
||||||
|
});
|
||||||
|
if (!result.success) {
|
||||||
|
console.log(fail(result.error || 'Failed to import profile'));
|
||||||
|
if (result.validation?.issues?.length) {
|
||||||
|
console.log('');
|
||||||
|
result.validation.issues.forEach(renderValidationIssue);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(ok(`Profile imported: ${result.name}`));
|
||||||
|
result.warnings?.forEach((warningMessage) => console.log(warn(warningMessage)));
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { dispatchNamedCommand, type NamedCommandRoute } from '../named-command-router';
|
||||||
|
import { handleApiCopyCommand } from './copy-command';
|
||||||
|
import { handleApiCreateCommand } from './create-command';
|
||||||
|
import { handleApiDiscoverCommand } from './discover-command';
|
||||||
|
import { handleApiExportCommand } from './export-command';
|
||||||
|
import { showApiCommandHelp, showUnknownApiCommand } from './help';
|
||||||
|
import { handleApiImportCommand } from './import-command';
|
||||||
|
import { handleApiListCommand } from './list-command';
|
||||||
|
import { handleApiRemoveCommand } from './remove-command';
|
||||||
|
|
||||||
|
export { parseApiCommandArgs } from './shared';
|
||||||
|
|
||||||
|
const API_COMMAND_ROUTES: readonly NamedCommandRoute[] = [
|
||||||
|
{ name: 'create', handle: handleApiCreateCommand },
|
||||||
|
{ name: 'list', handle: async () => handleApiListCommand() },
|
||||||
|
{ name: 'discover', handle: handleApiDiscoverCommand },
|
||||||
|
{ name: 'copy', handle: handleApiCopyCommand },
|
||||||
|
{ name: 'export', handle: handleApiExportCommand },
|
||||||
|
{ name: 'import', handle: handleApiImportCommand },
|
||||||
|
{ name: 'remove', aliases: ['delete', 'rm'], handle: handleApiRemoveCommand },
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function handleApiCommand(args: string[]): Promise<void> {
|
||||||
|
await dispatchNamedCommand({
|
||||||
|
args,
|
||||||
|
routes: API_COMMAND_ROUTES,
|
||||||
|
onHelp: showApiCommandHelp,
|
||||||
|
onUnknown: showUnknownApiCommand,
|
||||||
|
allowEmptyHelp: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { listApiProfiles, isUsingUnifiedConfig } from '../../api/services';
|
||||||
|
import { dim, header, initUI, subheader, table, warn, color } from '../../utils/ui';
|
||||||
|
|
||||||
|
export async function handleApiListCommand(): Promise<void> {
|
||||||
|
await initUI();
|
||||||
|
console.log(header('CCS API Profiles'));
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
const { profiles, variants } = listApiProfiles();
|
||||||
|
if (profiles.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = profiles.map((profile) => {
|
||||||
|
const status = profile.isConfigured ? color('[OK]', 'success') : color('[!]', 'warning');
|
||||||
|
return [profile.name, profile.target, profile.settingsPath, status];
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
table(rows, {
|
||||||
|
head: ['API', 'Target', isUsingUnifiedConfig() ? 'Config' : 'Settings File', 'Status'],
|
||||||
|
colWidths: isUsingUnifiedConfig() ? [15, 10, 20, 10] : [15, 10, 35, 10],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
if (variants.length > 0) {
|
||||||
|
console.log(subheader('CLIProxy Variants'));
|
||||||
|
console.log(
|
||||||
|
table(
|
||||||
|
variants.map((variant) => [
|
||||||
|
variant.name,
|
||||||
|
variant.provider,
|
||||||
|
variant.target,
|
||||||
|
variant.settings,
|
||||||
|
]),
|
||||||
|
{
|
||||||
|
head: ['Variant', 'Provider', 'Target', 'Settings'],
|
||||||
|
colWidths: [15, 12, 10, 28],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(dim(`Total: ${profiles.length} API profile(s)`));
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { getApiProfileNames, isUsingUnifiedConfig, removeApiProfile } from '../../api/services';
|
||||||
|
import { color, fail, header, info, initUI, ok, warn } from '../../utils/ui';
|
||||||
|
import { InteractivePrompt } from '../../utils/prompt';
|
||||||
|
import { exitOnApiCommandErrors, parseApiCommandArgs } from './shared';
|
||||||
|
|
||||||
|
export async function handleApiRemoveCommand(args: string[]): Promise<void> {
|
||||||
|
await initUI();
|
||||||
|
const parsedArgs = parseApiCommandArgs(args);
|
||||||
|
exitOnApiCommandErrors(parsedArgs.errors);
|
||||||
|
|
||||||
|
const apis = getApiProfileNames();
|
||||||
|
if (apis.length === 0) {
|
||||||
|
console.log(warn('No API profiles to remove'));
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = parsedArgs.name;
|
||||||
|
if (!name) {
|
||||||
|
console.log(header('Remove API Profile'));
|
||||||
|
console.log('');
|
||||||
|
console.log('Available APIs:');
|
||||||
|
apis.forEach((api, index) => console.log(` ${index + 1}. ${api}`));
|
||||||
|
console.log('');
|
||||||
|
name = await InteractivePrompt.input('API name to remove', {
|
||||||
|
validate: (value) => {
|
||||||
|
if (!value) return 'API name is required';
|
||||||
|
if (!apis.includes(value)) return `API '${value}' not found`;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!apis.includes(name)) {
|
||||||
|
console.log(fail(`API '${name}' not found`));
|
||||||
|
console.log('');
|
||||||
|
console.log('Available APIs:');
|
||||||
|
apis.forEach((api) => console.log(` - ${api}`));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log(`API '${color(name, 'command')}' will be removed.`);
|
||||||
|
console.log(` Settings: ~/.ccs/${name}.settings.json`);
|
||||||
|
if (isUsingUnifiedConfig()) {
|
||||||
|
console.log(' Config: ~/.ccs/config.yaml');
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
const confirmed =
|
||||||
|
parsedArgs.yes ||
|
||||||
|
(await InteractivePrompt.confirm('Delete this API profile?', { default: false }));
|
||||||
|
if (!confirmed) {
|
||||||
|
console.log(info('Cancelled'));
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = removeApiProfile(name);
|
||||||
|
if (!result.success) {
|
||||||
|
console.log(fail(`Failed to remove API profile: ${result.error}`));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(ok(`API profile removed: ${name}`));
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import type { TargetType } from '../../targets/target-adapter';
|
||||||
|
import { fail } from '../../utils/ui';
|
||||||
|
import { extractOption, hasAnyFlag } from '../arg-extractor';
|
||||||
|
|
||||||
|
export interface ApiCommandArgs {
|
||||||
|
name?: string;
|
||||||
|
baseUrl?: string;
|
||||||
|
apiKey?: string;
|
||||||
|
model?: string;
|
||||||
|
preset?: string;
|
||||||
|
target?: TargetType;
|
||||||
|
force?: boolean;
|
||||||
|
yes?: boolean;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const API_BOOLEAN_FLAGS = ['--force', '--yes', '-y'] as const;
|
||||||
|
export const API_VALUE_FLAGS = [
|
||||||
|
'--base-url',
|
||||||
|
'--api-key',
|
||||||
|
'--model',
|
||||||
|
'--preset',
|
||||||
|
'--target',
|
||||||
|
] as const;
|
||||||
|
export const API_KNOWN_FLAGS: readonly string[] = [...API_BOOLEAN_FLAGS, ...API_VALUE_FLAGS];
|
||||||
|
|
||||||
|
const API_VALUE_FLAG_SET = new Set<string>(API_VALUE_FLAGS);
|
||||||
|
|
||||||
|
export function sanitizeHelpText(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/[\r\n\t]+/g, ' ')
|
||||||
|
.replace(/[\x00-\x1f\x7f]/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTargetValue(value: string): TargetType | null {
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (normalized === 'claude' || normalized === 'droid') {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseOptionalTargetFlag(
|
||||||
|
args: string[],
|
||||||
|
knownFlags: readonly string[]
|
||||||
|
): { target?: TargetType; remainingArgs: string[]; errors: string[] } {
|
||||||
|
const extracted = extractOption(args, ['--target'], {
|
||||||
|
allowDashValue: true,
|
||||||
|
knownFlags,
|
||||||
|
});
|
||||||
|
if (!extracted.found) {
|
||||||
|
return { remainingArgs: args, errors: [] };
|
||||||
|
}
|
||||||
|
if (extracted.missingValue || !extracted.value) {
|
||||||
|
return { remainingArgs: extracted.remainingArgs, errors: ['Missing value for --target'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = parseTargetValue(extracted.value);
|
||||||
|
if (!target) {
|
||||||
|
return {
|
||||||
|
remainingArgs: extracted.remainingArgs,
|
||||||
|
errors: [`Invalid --target value "${extracted.value}". Use: claude or droid`],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { target, remainingArgs: extracted.remainingArgs, errors: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseApiCommandArgs(args: string[]): ApiCommandArgs {
|
||||||
|
const result: ApiCommandArgs = {
|
||||||
|
force: hasAnyFlag(args, ['--force']),
|
||||||
|
yes: hasAnyFlag(args, ['--yes', '-y']),
|
||||||
|
errors: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
let remaining = [...args];
|
||||||
|
|
||||||
|
remaining = applyRepeatedOption(
|
||||||
|
remaining,
|
||||||
|
['--base-url'],
|
||||||
|
(value) => {
|
||||||
|
result.baseUrl = value;
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
result.errors.push('Missing value for --base-url');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
remaining = applyRepeatedOption(
|
||||||
|
remaining,
|
||||||
|
['--api-key'],
|
||||||
|
(value) => {
|
||||||
|
result.apiKey = value;
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
result.errors.push('Missing value for --api-key');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
remaining = applyRepeatedOption(
|
||||||
|
remaining,
|
||||||
|
['--model'],
|
||||||
|
(value) => {
|
||||||
|
result.model = value;
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
result.errors.push('Missing value for --model');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
remaining = applyRepeatedOption(
|
||||||
|
remaining,
|
||||||
|
['--preset'],
|
||||||
|
(value) => {
|
||||||
|
result.preset = value;
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
result.errors.push('Missing value for --preset');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
remaining = applyRepeatedOption(
|
||||||
|
remaining,
|
||||||
|
['--target'],
|
||||||
|
(value) => {
|
||||||
|
const target = parseTargetValue(value);
|
||||||
|
if (!target) {
|
||||||
|
result.errors.push(`Invalid --target value "${value}". Use: claude or droid`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
result.target = target;
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
result.errors.push('Missing value for --target');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
result.name = extractPositionalArgs(remaining)[0];
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exitOnApiCommandErrors(errors: string[]): void {
|
||||||
|
if (errors.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
errors.forEach((errorMessage) => {
|
||||||
|
console.log(fail(errorMessage));
|
||||||
|
});
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -8,12 +8,35 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { initUI, header, subheader, color, dim, fail } from '../../utils/ui';
|
import { initUI, header, subheader, color, dim, fail } from '../../utils/ui';
|
||||||
|
import { dispatchNamedCommand, type NamedCommandRoute } from '../named-command-router';
|
||||||
|
|
||||||
// Import command handlers
|
// Import command handlers
|
||||||
import { handleSetup } from './setup-command';
|
import { handleSetup } from './setup-command';
|
||||||
import { handleShow } from './show-command';
|
import { handleShow } from './show-command';
|
||||||
import { handleDisable } from './disable-command';
|
import { handleDisable } from './disable-command';
|
||||||
|
|
||||||
|
const CONFIG_AUTH_ROUTES: readonly NamedCommandRoute[] = [
|
||||||
|
{
|
||||||
|
name: 'setup',
|
||||||
|
handle: async () => {
|
||||||
|
await handleSetup();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'show',
|
||||||
|
aliases: ['status'],
|
||||||
|
handle: async () => {
|
||||||
|
await handleShow();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'disable',
|
||||||
|
handle: async () => {
|
||||||
|
await handleDisable();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show help for config auth commands
|
* Show help for config auth commands
|
||||||
*/
|
*/
|
||||||
@@ -58,36 +81,20 @@ async function showHelp(): Promise<void> {
|
|||||||
* Route config auth command to appropriate handler
|
* Route config auth command to appropriate handler
|
||||||
*/
|
*/
|
||||||
export async function handleConfigAuthCommand(args: string[]): Promise<void> {
|
export async function handleConfigAuthCommand(args: string[]): Promise<void> {
|
||||||
// Default to help if no subcommand
|
await dispatchNamedCommand({
|
||||||
if (args.length === 0 || args[0] === '--help' || args[0] === '-h' || args[0] === 'help') {
|
args,
|
||||||
await showHelp();
|
routes: CONFIG_AUTH_ROUTES,
|
||||||
return;
|
onHelp: showHelp,
|
||||||
}
|
allowEmptyHelp: true,
|
||||||
|
onUnknown: async (command) => {
|
||||||
const command = args[0];
|
|
||||||
|
|
||||||
switch (command) {
|
|
||||||
case 'setup':
|
|
||||||
await handleSetup();
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'show':
|
|
||||||
case 'status':
|
|
||||||
await handleShow();
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'disable':
|
|
||||||
await handleDisable();
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
await initUI();
|
await initUI();
|
||||||
console.log(fail(`Unknown command: ${command}`));
|
console.log(fail(`Unknown command: ${command}`));
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('Run for help:');
|
console.log('Run for help:');
|
||||||
console.log(` ${color('ccs config auth --help', 'command')}`);
|
console.log(` ${color('ccs config auth --help', 'command')}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-export types
|
// Re-export types
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { ensureCliproxyService } from '../cliproxy/service-manager';
|
|||||||
import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config-generator';
|
import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config-generator';
|
||||||
import { getDashboardAuthConfig } from '../config/unified-config-loader';
|
import { getDashboardAuthConfig } from '../config/unified-config-loader';
|
||||||
import { initUI, header, ok, info, warn, fail } from '../utils/ui';
|
import { initUI, header, ok, info, warn, fail } from '../utils/ui';
|
||||||
|
import { resolveNamedCommand, type NamedCommandRoute } from './named-command-router';
|
||||||
import {
|
import {
|
||||||
isLoopbackHost,
|
isLoopbackHost,
|
||||||
isWildcardHost,
|
isWildcardHost,
|
||||||
@@ -22,28 +23,39 @@ import {
|
|||||||
} from './config-dashboard-host';
|
} from './config-dashboard-host';
|
||||||
import { parseConfigCommandArgs, showConfigCommandHelp } from './config-command-options';
|
import { parseConfigCommandArgs, showConfigCommandHelp } from './config-command-options';
|
||||||
|
|
||||||
|
const CONFIG_SUBCOMMAND_ROUTES: readonly NamedCommandRoute[] = [
|
||||||
|
{
|
||||||
|
name: 'auth',
|
||||||
|
handle: async (args) => {
|
||||||
|
const { handleConfigAuthCommand } = await import('./config-auth');
|
||||||
|
await handleConfigAuthCommand(args);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'image-analysis',
|
||||||
|
handle: async (args) => {
|
||||||
|
const { handleConfigImageAnalysisCommand } = await import('./config-image-analysis-command');
|
||||||
|
await handleConfigImageAnalysisCommand(args);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'thinking',
|
||||||
|
handle: async (args) => {
|
||||||
|
const { handleConfigThinkingCommand } = await import('./config-thinking-command');
|
||||||
|
await handleConfigThinkingCommand(args);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle config command
|
* Handle config command
|
||||||
*/
|
*/
|
||||||
export async function handleConfigCommand(args: string[]): Promise<void> {
|
export async function handleConfigCommand(args: string[]): Promise<void> {
|
||||||
// Route subcommands before dashboard launch
|
const subcommand = args[0]?.startsWith('-')
|
||||||
if (args[0] === 'auth') {
|
? undefined
|
||||||
const { handleConfigAuthCommand } = await import('./config-auth');
|
: resolveNamedCommand(args[0], CONFIG_SUBCOMMAND_ROUTES);
|
||||||
await handleConfigAuthCommand(args.slice(1));
|
if (subcommand) {
|
||||||
return;
|
await subcommand.handle(args.slice(1));
|
||||||
}
|
|
||||||
|
|
||||||
// Route image-analysis subcommand
|
|
||||||
if (args[0] === 'image-analysis') {
|
|
||||||
const { handleConfigImageAnalysisCommand } = await import('./config-image-analysis-command');
|
|
||||||
await handleConfigImageAnalysisCommand(args.slice(1));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Route thinking subcommand
|
|
||||||
if (args[0] === 'thinking') {
|
|
||||||
const { handleConfigThinkingCommand } = await import('./config-thinking-command');
|
|
||||||
await handleConfigThinkingCommand(args.slice(1));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
export interface NamedCommandRoute {
|
||||||
|
name: string;
|
||||||
|
aliases?: readonly string[];
|
||||||
|
handle(args: string[]): Promise<void> | void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DispatchNamedCommandOptions {
|
||||||
|
args: string[];
|
||||||
|
routes: readonly NamedCommandRoute[];
|
||||||
|
onUnknown(command: string): Promise<void> | void;
|
||||||
|
onHelp?: () => Promise<void> | void;
|
||||||
|
helpTokens?: readonly string[];
|
||||||
|
allowEmptyHelp?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_HELP_TOKENS = ['help', '--help', '-h'] as const;
|
||||||
|
|
||||||
|
export function resolveNamedCommand(
|
||||||
|
token: string | undefined,
|
||||||
|
routes: readonly NamedCommandRoute[]
|
||||||
|
): NamedCommandRoute | undefined {
|
||||||
|
if (!token) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return routes.find((route) => route.name === token || route.aliases?.includes(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dispatchNamedCommand(options: DispatchNamedCommandOptions): Promise<boolean> {
|
||||||
|
const { args, routes, onUnknown, onHelp, allowEmptyHelp = false } = options;
|
||||||
|
const helpTokens = options.helpTokens || DEFAULT_HELP_TOKENS;
|
||||||
|
const command = args[0];
|
||||||
|
|
||||||
|
if (!command) {
|
||||||
|
if (!allowEmptyHelp || !onHelp) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await onHelp();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (helpTokens.includes(command)) {
|
||||||
|
if (!onHelp) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await onHelp();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = resolveNamedCommand(command, routes);
|
||||||
|
if (!route) {
|
||||||
|
await onUnknown(command);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
await route.handle(args.slice(1));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { resolveNamedCommand, type NamedCommandRoute } from './named-command-router';
|
||||||
|
|
||||||
|
async function printUpdateCommandHelp(): Promise<void> {
|
||||||
|
console.log('');
|
||||||
|
console.log('Usage: ccs update [options]');
|
||||||
|
console.log('');
|
||||||
|
console.log('Options:');
|
||||||
|
console.log(' --force Force reinstall current version');
|
||||||
|
console.log(' --beta, --dev Install from dev channel (unstable)');
|
||||||
|
console.log(' --help, -h Show this help message');
|
||||||
|
console.log('');
|
||||||
|
console.log('Examples:');
|
||||||
|
console.log(' ccs update Update to latest stable');
|
||||||
|
console.log(' ccs update --force Force reinstall');
|
||||||
|
console.log(' ccs update --beta Install dev channel');
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROOT_COMMAND_ROUTES: readonly NamedCommandRoute[] = [
|
||||||
|
{
|
||||||
|
name: 'migrate',
|
||||||
|
aliases: ['--migrate'],
|
||||||
|
handle: async (args) => {
|
||||||
|
const { handleMigrateCommand, printMigrateHelp } = await import('./migrate-command');
|
||||||
|
if (args.includes('--help') || args.includes('-h')) {
|
||||||
|
printMigrateHelp();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await handleMigrateCommand(args);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'update',
|
||||||
|
aliases: ['--update'],
|
||||||
|
handle: async (args) => {
|
||||||
|
if (args.includes('--help') || args.includes('-h')) {
|
||||||
|
await printUpdateCommandHelp();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { handleUpdateCommand } = await import('./update-command');
|
||||||
|
await handleUpdateCommand({
|
||||||
|
force: args.includes('--force'),
|
||||||
|
beta: args.includes('--beta') || args.includes('--dev'),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'version',
|
||||||
|
aliases: ['--version', '-v'],
|
||||||
|
handle: async () => {
|
||||||
|
const { handleVersionCommand } = await import('./version-command');
|
||||||
|
await handleVersionCommand();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'help',
|
||||||
|
aliases: ['--help', '-h'],
|
||||||
|
handle: async () => {
|
||||||
|
const { handleHelpCommand } = await import('./help-command');
|
||||||
|
await handleHelpCommand();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '--install',
|
||||||
|
handle: async () => {
|
||||||
|
const { handleInstallCommand } = await import('./install-command');
|
||||||
|
await handleInstallCommand();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '--uninstall',
|
||||||
|
handle: async () => {
|
||||||
|
const { handleUninstallCommand } = await import('./install-command');
|
||||||
|
await handleUninstallCommand();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '--shell-completion',
|
||||||
|
aliases: ['-sc'],
|
||||||
|
handle: async (args) => {
|
||||||
|
const { handleShellCompletionCommand } = await import('./shell-completion-command');
|
||||||
|
await handleShellCompletionCommand(args);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'doctor',
|
||||||
|
aliases: ['--doctor'],
|
||||||
|
handle: async (args) => {
|
||||||
|
const { handleDoctorCommand } = await import('./doctor-command');
|
||||||
|
await handleDoctorCommand(args);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'sync',
|
||||||
|
aliases: ['--sync'],
|
||||||
|
handle: async () => {
|
||||||
|
const { handleSyncCommand } = await import('./sync-command');
|
||||||
|
await handleSyncCommand();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'cleanup',
|
||||||
|
aliases: ['--cleanup'],
|
||||||
|
handle: async (args) => {
|
||||||
|
const { handleCleanupCommand } = await import('./cleanup-command');
|
||||||
|
await handleCleanupCommand(args);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'auth',
|
||||||
|
handle: async (args) => {
|
||||||
|
const AuthCommandsModule = await import('../auth/auth-commands');
|
||||||
|
const AuthCommands = AuthCommandsModule.default;
|
||||||
|
const authCommands = new AuthCommands();
|
||||||
|
await authCommands.route(args);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'api',
|
||||||
|
handle: async (args) => {
|
||||||
|
const { handleApiCommand } = await import('./api-command');
|
||||||
|
await handleApiCommand(args);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'cliproxy',
|
||||||
|
handle: async (args) => {
|
||||||
|
const { handleCliproxyCommand } = await import('./cliproxy-command');
|
||||||
|
await handleCliproxyCommand(args);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'config',
|
||||||
|
handle: async (args) => {
|
||||||
|
const { handleConfigCommand } = await import('./config-command');
|
||||||
|
await handleConfigCommand(args);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'tokens',
|
||||||
|
handle: async (args) => {
|
||||||
|
const { handleTokensCommand } = await import('./tokens-command');
|
||||||
|
process.exit(await handleTokensCommand(args));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'persist',
|
||||||
|
handle: async (args) => {
|
||||||
|
const { handlePersistCommand } = await import('./persist-command');
|
||||||
|
await handlePersistCommand(args);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'env',
|
||||||
|
handle: async (args) => {
|
||||||
|
const { handleEnvCommand } = await import('./env-command');
|
||||||
|
await handleEnvCommand(args);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'setup',
|
||||||
|
aliases: ['--setup'],
|
||||||
|
handle: async (args) => {
|
||||||
|
const { handleSetupCommand } = await import('./setup-command');
|
||||||
|
await handleSetupCommand(args);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'cursor',
|
||||||
|
handle: async (args) => {
|
||||||
|
const { handleCursorCommand } = await import('./cursor-command');
|
||||||
|
process.exit(await handleCursorCommand(args));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function tryHandleRootCommand(args: string[]): Promise<boolean> {
|
||||||
|
const route = resolveNamedCommand(args[0], ROOT_COMMAND_ROUTES);
|
||||||
|
if (!route) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await route.handle(args.slice(1));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ class RoutingProxyDispatcher extends Dispatcher {
|
|||||||
this.httpsProxyDispatcher = httpsProxyUrl ? new ProxyAgent(httpsProxyUrl) : null;
|
this.httpsProxyDispatcher = httpsProxyUrl ? new ProxyAgent(httpsProxyUrl) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatch(options: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandlers): boolean {
|
dispatch(options: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandler): boolean {
|
||||||
return this.resolveDispatcher(options.origin).dispatch(options, handler);
|
return this.resolveDispatcher(options.origin).dispatch(options, handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
|
||||||
|
|
||||||
|
let calls: string[] = [];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
calls = [];
|
||||||
|
|
||||||
|
mock.module('../../../src/commands/api-command/help', () => ({
|
||||||
|
showApiCommandHelp: async () => {
|
||||||
|
calls.push('help');
|
||||||
|
},
|
||||||
|
showUnknownApiCommand: async (command: string) => {
|
||||||
|
calls.push(`unknown:${command}`);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('../../../src/commands/api-command/create-command', () => ({
|
||||||
|
handleApiCreateCommand: async (args: string[]) => {
|
||||||
|
calls.push(`create:${args.join(' ')}`);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('../../../src/commands/api-command/list-command', () => ({
|
||||||
|
handleApiListCommand: async () => {
|
||||||
|
calls.push('list');
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('../../../src/commands/api-command/remove-command', () => ({
|
||||||
|
handleApiRemoveCommand: async (args: string[]) => {
|
||||||
|
calls.push(`remove:${args.join(' ')}`);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('../../../src/commands/api-command/discover-command', () => ({
|
||||||
|
handleApiDiscoverCommand: async (args: string[]) => {
|
||||||
|
calls.push(`discover:${args.join(' ')}`);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('../../../src/commands/api-command/copy-command', () => ({
|
||||||
|
handleApiCopyCommand: async (args: string[]) => {
|
||||||
|
calls.push(`copy:${args.join(' ')}`);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('../../../src/commands/api-command/export-command', () => ({
|
||||||
|
handleApiExportCommand: async (args: string[]) => {
|
||||||
|
calls.push(`export:${args.join(' ')}`);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('../../../src/commands/api-command/import-command', () => ({
|
||||||
|
handleApiImportCommand: async (args: string[]) => {
|
||||||
|
calls.push(`import:${args.join(' ')}`);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mock.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadHandleApiCommand() {
|
||||||
|
const mod = await import(`../../../src/commands/api-command?test=${Date.now()}-${Math.random()}`);
|
||||||
|
return mod.handleApiCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('api-command router', () => {
|
||||||
|
it('defaults to help when no subcommand is provided', async () => {
|
||||||
|
const handleApiCommand = await loadHandleApiCommand();
|
||||||
|
|
||||||
|
await handleApiCommand([]);
|
||||||
|
|
||||||
|
expect(calls).toEqual(['help']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes remove aliases through the named command dispatcher', async () => {
|
||||||
|
const handleApiCommand = await loadHandleApiCommand();
|
||||||
|
|
||||||
|
await handleApiCommand(['rm', 'profile-a']);
|
||||||
|
|
||||||
|
expect(calls).toEqual(['remove:profile-a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delegates unknown commands to the shared unknown handler', async () => {
|
||||||
|
const handleApiCommand = await loadHandleApiCommand();
|
||||||
|
|
||||||
|
await handleApiCommand(['bogus']);
|
||||||
|
|
||||||
|
expect(calls).toEqual(['unknown:bogus']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
|
|||||||
|
|
||||||
const startServerCalls: Array<Record<string, unknown>> = [];
|
const startServerCalls: Array<Record<string, unknown>> = [];
|
||||||
const resolveDashboardUrlsCalls: Array<[string | undefined, number]> = [];
|
const resolveDashboardUrlsCalls: Array<[string | undefined, number]> = [];
|
||||||
|
const configAuthCalls: string[][] = [];
|
||||||
let logLines: string[] = [];
|
let logLines: string[] = [];
|
||||||
let errorLines: string[] = [];
|
let errorLines: string[] = [];
|
||||||
let dashboardAuthEnabled = false;
|
let dashboardAuthEnabled = false;
|
||||||
@@ -14,6 +15,7 @@ let originalProcessExit: typeof process.exit;
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
startServerCalls.length = 0;
|
startServerCalls.length = 0;
|
||||||
resolveDashboardUrlsCalls.length = 0;
|
resolveDashboardUrlsCalls.length = 0;
|
||||||
|
configAuthCalls.length = 0;
|
||||||
logLines = [];
|
logLines = [];
|
||||||
errorLines = [];
|
errorLines = [];
|
||||||
dashboardAuthEnabled = false;
|
dashboardAuthEnabled = false;
|
||||||
@@ -122,6 +124,12 @@ beforeEach(() => {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
mock.module('../../../src/commands/config-auth', () => ({
|
||||||
|
handleConfigAuthCommand: async (args: string[]) => {
|
||||||
|
configAuthCalls.push([...args]);
|
||||||
|
},
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -132,11 +140,23 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function loadHandleConfigCommand() {
|
async function loadHandleConfigCommand() {
|
||||||
const mod = await import(`../../../src/commands/config-command?test=${Date.now()}-${Math.random()}`);
|
const mod = await import(
|
||||||
|
`../../../src/commands/config-command?test=${Date.now()}-${Math.random()}`
|
||||||
|
);
|
||||||
return mod.handleConfigCommand;
|
return mod.handleConfigCommand;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('config command dashboard startup', () => {
|
describe('config command dashboard startup', () => {
|
||||||
|
it('routes auth subcommands before dashboard startup', async () => {
|
||||||
|
const handleConfigCommand = await loadHandleConfigCommand();
|
||||||
|
|
||||||
|
await handleConfigCommand(['auth', 'setup']);
|
||||||
|
|
||||||
|
expect(configAuthCalls).toEqual([['setup']]);
|
||||||
|
expect(startServerCalls).toHaveLength(0);
|
||||||
|
expect(resolveDashboardUrlsCalls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps the default startup path free of an explicit host override', async () => {
|
it('keeps the default startup path free of an explicit host override', async () => {
|
||||||
const handleConfigCommand = await loadHandleConfigCommand();
|
const handleConfigCommand = await loadHandleConfigCommand();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
|
||||||
|
|
||||||
|
let calls: string[] = [];
|
||||||
|
let logLines: string[] = [];
|
||||||
|
let originalConsoleLog: typeof console.log;
|
||||||
|
let originalProcessExit: typeof process.exit;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
calls = [];
|
||||||
|
logLines = [];
|
||||||
|
originalConsoleLog = console.log;
|
||||||
|
originalProcessExit = process.exit;
|
||||||
|
|
||||||
|
console.log = (...args: unknown[]) => {
|
||||||
|
logLines.push(args.map(String).join(' '));
|
||||||
|
};
|
||||||
|
|
||||||
|
mock.module('../../../src/commands/version-command', () => ({
|
||||||
|
handleVersionCommand: async () => {
|
||||||
|
calls.push('version');
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('../../../src/commands/update-command', () => ({
|
||||||
|
handleUpdateCommand: async (options: Record<string, unknown>) => {
|
||||||
|
calls.push(`update:${JSON.stringify(options)}`);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('../../../src/commands/api-command', () => ({
|
||||||
|
handleApiCommand: async (args: string[]) => {
|
||||||
|
calls.push(`api:${args.join(' ')}`);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('../../../src/commands/tokens-command', () => ({
|
||||||
|
handleTokensCommand: async () => 37,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
console.log = originalConsoleLog;
|
||||||
|
process.exit = originalProcessExit;
|
||||||
|
mock.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadTryHandleRootCommand() {
|
||||||
|
const mod = await import(
|
||||||
|
`../../../src/commands/root-command-router?test=${Date.now()}-${Math.random()}`
|
||||||
|
);
|
||||||
|
return mod.tryHandleRootCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('root-command-router', () => {
|
||||||
|
it('routes command aliases to their handlers', async () => {
|
||||||
|
const tryHandleRootCommand = await loadTryHandleRootCommand();
|
||||||
|
|
||||||
|
await expect(tryHandleRootCommand(['--version'])).resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(calls).toEqual(['version']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for profile-like tokens that are not root commands', async () => {
|
||||||
|
const tryHandleRootCommand = await loadTryHandleRootCommand();
|
||||||
|
|
||||||
|
await expect(tryHandleRootCommand(['glm'])).resolves.toBe(false);
|
||||||
|
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prints update help without invoking the updater', async () => {
|
||||||
|
const tryHandleRootCommand = await loadTryHandleRootCommand();
|
||||||
|
|
||||||
|
await expect(tryHandleRootCommand(['update', '--help'])).resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
expect(logLines.join('\n')).toContain('Usage: ccs update [options]');
|
||||||
|
expect(logLines.join('\n')).toContain('ccs update --beta');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes remaining args through to nested command handlers', async () => {
|
||||||
|
const tryHandleRootCommand = await loadTryHandleRootCommand();
|
||||||
|
|
||||||
|
await expect(tryHandleRootCommand(['api', 'discover', '--register'])).resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(calls).toEqual(['api:discover --register']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exits with the nested command exit code when required', async () => {
|
||||||
|
process.exit = ((code?: number) => {
|
||||||
|
throw new Error(`process.exit(${code ?? 0})`);
|
||||||
|
}) as typeof process.exit;
|
||||||
|
|
||||||
|
const tryHandleRootCommand = await loadTryHandleRootCommand();
|
||||||
|
|
||||||
|
await expect(tryHandleRootCommand(['tokens', 'list'])).rejects.toThrow('process.exit(37)');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user