diff --git a/README.md b/README.md index 815aa9fb..dd0d0dd4 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,8 @@ The dashboard provides visual management for all account types: - **Claude Accounts**: Isolation-first by default (work, personal, client), with explicit shared context opt-in - **OAuth Providers**: One-click auth for Gemini, Codex, Antigravity, Kiro, Copilot -- **API Profiles**: Configure GLM, Kimi with your keys +- **AI Providers**: Configure Gemini, Codex, Claude, Vertex, and OpenAI-compatible API keys under `CLIProxy -> AI Providers` +- **API Profiles**: Configure GLM, Kimi, OpenRouter, and other Anthropic-compatible APIs as CCS-native profiles - **Factory Droid**: Track Droid install location and BYOK settings health - **Updates Center**: Track support rollouts (Droid target, CLIProxy provider changes, WebSearch integrations) - **Health Monitor**: Real-time status across all profiles @@ -148,6 +149,8 @@ The dashboard provides visual management for all account types: > **OAuth providers** authenticate via browser on first run. Tokens are cached in `~/.ccs/cliproxy/auth/`. +> **AI Providers dashboard:** Configure CLIProxy-managed API key families at `ccs config` -> `CLIProxy` -> `AI Providers`. Use `API Profiles` only for CCS-native Anthropic-compatible profiles. + **Powered by:** - [CLIProxyAPIPlus](https://github.com/router-for-me/CLIProxyAPIPlus) - Extended OAuth proxy with Kiro ([@fuko2935](https://github.com/fuko2935), [@Ravens2121](https://github.com/Ravens2121)) and Copilot ([@em4go](https://github.com/em4go)) support - [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) - Core OAuth proxy for Gemini, Codex, Antigravity diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index df29f733..17ae9fca 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -1,6 +1,6 @@ # CCS Codebase Summary -Last Updated: 2026-03-17 +Last Updated: 2026-03-18 Comprehensive overview of the modularized CCS codebase structure following the Phase 9 modularization effort (Settings, Analytics, Auth Monitor splits + Test Infrastructure), v7.1 Remote CLIProxy feature, v7.2 Kiro + GitHub Copilot (ghcp) OAuth providers, v7.14 Hybrid Quota Management, v7.34 Image Analysis Hook, and account-context validation hardening. @@ -222,6 +222,13 @@ src/ - API route rejects `context_group`/`continuity_mode` when mode is not `shared` - registry normalization drops malformed persisted `context_group` values +### Shared Plugin Layout + +- Shared payload owner: `src/management/shared-manager.ts`. +- Profile entry point: `src/management/instance-manager.ts`. +- `plugins/marketplaces/`, `plugins/cache/`, and `installed_plugins.json` stay shared through the `~/.ccs/shared/` topology. +- `known_marketplaces.json` is now instance-local under `~/.ccs/instances//plugins/` so Claude Code validates `installLocation` against the active `CLAUDE_CONFIG_DIR` instead of a last-writer-wins shared file. + ### Target Adapter Module The targets module provides an extensible interface for dispatching profiles to different CLI implementations. diff --git a/docs/project-overview-pdr.md b/docs/project-overview-pdr.md index f6384588..2849246f 100644 --- a/docs/project-overview-pdr.md +++ b/docs/project-overview-pdr.md @@ -1,6 +1,6 @@ # CCS Product Development Requirements (PDR) -Last Updated: 2026-02-04 +Last Updated: 2026-03-19 ## Product Overview @@ -32,10 +32,11 @@ CCS provides: 1. **Multi-Account Claude**: Isolated instances via `CLAUDE_CONFIG_DIR` 2. **OAuth Providers**: Zero-config Gemini, Codex, Antigravity, Copilot, Kiro (ghcp) integration -3. **API Profiles**: GLM, Kimi, OpenRouter, any Anthropic-compatible API -4. **Visual Dashboard**: React SPA for configuration management -5. **Automatic WebSearch**: MCP fallback for third-party providers -6. **Usage Analytics**: Token tracking, cost analysis, model breakdown +3. **AI Providers**: Dedicated CLIProxy dashboard for Gemini, Codex, Claude, Vertex, and OpenAI-compatible API-key families +4. **API Profiles**: GLM, Kimi, OpenRouter, any Anthropic-compatible API +5. **Visual Dashboard**: React SPA for configuration management +6. **Automatic WebSearch**: MCP fallback for third-party providers +7. **Usage Analytics**: Token tracking, cost analysis, model breakdown --- @@ -74,6 +75,11 @@ CCS provides: - Model mapping and configuration - OpenRouter integration with 300+ models +### FR-004A: CLIProxy AI Provider Management +- Configure CLIProxy-managed Gemini, Codex, Claude, Vertex, and OpenAI-compatible API-key entries +- Keep provider authoring separate from CCS API Profile creation +- Support local config editing and remote CLIProxy management parity where available + ### FR-005: Dashboard UI - Visual profile management - Real-time health monitoring @@ -332,5 +338,5 @@ CCS provides: - [Codebase Summary](./codebase-summary.md) - Technical structure - [Code Standards](./code-standards.md) - Development conventions -- [System Architecture](./system-architecture.md) - Architecture diagrams +- [System Architecture](./system-architecture/index.md) - Architecture diagrams - [Project Roadmap](./project-roadmap.md) - Development phases and GitHub issues diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 347851b2..4ca846c8 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -1,6 +1,6 @@ # CCS Project Roadmap -Last Updated: 2026-03-17 +Last Updated: 2026-03-19 Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans. @@ -41,6 +41,8 @@ All major modularization work is complete. The codebase evolved from monolithic ### Recent Fixes +- **2026-03-19**: **#649** CCS splits CLIProxy provider-key authoring into a dedicated `CLIProxy -> AI Providers` dashboard route. `/cliproxy` now stays focused on OAuth accounts and variants, `/cliproxy/ai-providers` owns Gemini/Codex/Claude/Vertex/OpenAI-compatible key management, and `/providers` stays reserved for CCS-native API Profiles. +- **2026-03-18**: **#755** Marketplace refresh no longer reuses one shared `known_marketplaces.json` across isolated instances. CCS now keeps marketplace payload directories shared while reconciling per-instance marketplace metadata so Claude Code validation succeeds for alternating or concurrent profiles, including Windows copy fallback. - **2026-03-17**: Deprecated user-facing GLMT discovery across CLI help, completions, presets, and docs. Existing `glmt` profiles now run through a compatibility path that normalizes legacy proxy settings to the direct GLM endpoint. - **#748**: API profile creation now keeps provider selection compact by collapsing advanced presets behind an explicit toggle, shrinking chooser cards so the form fields stay visually primary, and giving `llama.cpp` a dedicated provider logo. - **#744**: API profile creation now keeps featured providers in a horizontal rail with scroll fallback, moves Anthropic Direct API to the end, reuses the shared Claude logo, and separates the custom-endpoint entry point from advanced template discovery. @@ -239,6 +241,6 @@ The check mode supports a maintainability regression gate that blocks increases - [Codebase Summary](./codebase-summary.md) - Current structure - [Code Standards](./code-standards.md) - Patterns and conventions -- [System Architecture](./system-architecture.md) - Architecture diagrams +- [System Architecture](./system-architecture/index.md) - Architecture diagrams - [Hardening Debt Burndown Tracker](./hardening-debt-burndown.md) - Legacy shim + sync-fs debt tracking - [CLAUDE.md](../CLAUDE.md) - AI development guidance diff --git a/docs/system-architecture/index.md b/docs/system-architecture/index.md index a13cd68e..7de060eb 100644 --- a/docs/system-architecture/index.md +++ b/docs/system-architecture/index.md @@ -1,6 +1,6 @@ # CCS System Architecture -Last Updated: 2026-03-02 +Last Updated: 2026-03-18 High-level architecture overview for the CCS (Claude Code Switch) system. @@ -233,12 +233,27 @@ For detailed provider flows (CLIProxyAPI, legacy GLMT compatibility, quota manag +---> commands/ # Claude Code commands +---> skills/ # Custom skills +---> agents/ # Agent configurations + +---> plugins/ + | + +---> cache/ # Shared plugin payload/cache data + +---> marketplaces/ # Shared marketplace payload directories + +---> installed_plugins.json + + ~/.ccs/instances// + | + +---> plugins/ + | + +---> known_marketplaces.json # Instance-local registry for active CLAUDE_CONFIG_DIR validation ~/.factory/ (Droid CLI) | +---> settings.json # Droid config (custom models) ``` +Plugin ownership note: +- `commands/`, `skills/`, `agents/`, and `settings.json` remain shared through the existing symlink/copy flow. +- Marketplace payload directories stay shared, but `known_marketplaces.json` is reconciled per instance so Claude Code can validate `installLocation` against that instance's `CLAUDE_CONFIG_DIR/plugins/marketplaces`. + ### Config Loading Order ``` diff --git a/package.json b/package.json index 6ac7b844..aa7cea22 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.55.0", + "version": "7.55.0-dev.3", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", @@ -72,7 +72,7 @@ "test": "bun run build && bun run test:all", "test:ci": "bun run test:all", "test:all": "bun test tests/unit tests/integration tests/npm", - "test:unit": "bun test tests/unit/", + "test:unit": "bun test tests/unit", "test:npm": "bun test tests/npm/", "test:native": "bash tests/native/unix/edge-cases.sh", "test:e2e": "bun test tests/e2e/ --bail --timeout 60000", diff --git a/src/api/services/cliproxy-profile-bridge.ts b/src/api/services/cliproxy-profile-bridge.ts new file mode 100644 index 00000000..e903d222 --- /dev/null +++ b/src/api/services/cliproxy-profile-bridge.ts @@ -0,0 +1,153 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { getCcsDir, loadConfigSafe } from '../../utils/config-manager'; +import { buildProxyUrl, getProxyTarget } from '../../cliproxy/proxy-target-resolver'; +import { getEffectiveApiKey } from '../../cliproxy/auth-token-manager'; +import { getModelMappingFromConfig } from '../../cliproxy/base-config-loader'; +import { + CLIPROXY_PROVIDER_IDS, + getProviderDescription, + getProviderDisplayName, + mapExternalProviderName, +} from '../../cliproxy/provider-capabilities'; +import { extractProviderFromPathname } from '../../cliproxy/model-id-normalizer'; +import { isUnifiedMode, loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; +import type { TargetType } from '../../targets/target-adapter'; +import type { Settings } from '../../types/config'; +import type { CLIProxyProvider } from '../../cliproxy/types'; +import type { + CliproxyBridgeMetadata, + CliproxyBridgeProviderInfo, + ModelMapping, + ResolvedCliproxyBridgeProfile, +} from './profile-types'; + +const DEFAULT_PROFILE_SUFFIX = '-api'; + +function normalizeBridgeUrl(value: string): string { + try { + const parsed = new URL(value); + const hostname = parsed.hostname === 'localhost' ? '127.0.0.1' : parsed.hostname; + const pathname = parsed.pathname.replace(/\/+$/, '') || '/'; + return `${parsed.protocol}//${hostname}:${parsed.port}${pathname}`; + } catch { + return value.trim().replace(/\/+$/, ''); + } +} + +function resolveProviderFromBaseUrl(baseUrl: unknown): CLIProxyProvider | null { + if (typeof baseUrl !== 'string' || baseUrl.trim().length === 0) { + return null; + } + + try { + const parsed = new URL(baseUrl); + const extracted = extractProviderFromPathname(parsed.pathname); + return extracted ? mapExternalProviderName(extracted) : null; + } catch { + const extracted = extractProviderFromPathname(baseUrl); + return extracted ? mapExternalProviderName(extracted) : null; + } +} + +function hasConfiguredProfile(name: string): boolean { + if (isUnifiedMode()) { + const config = loadOrCreateUnifiedConfig(); + return name in config.profiles; + } + + const config = loadConfigSafe(); + return name in config.profiles; +} + +function hasSettingsFile(name: string): boolean { + return fs.existsSync(path.join(getCcsDir(), `${name}.settings.json`)); +} + +export function getDefaultCliproxyBridgeName(provider: CLIProxyProvider): string { + return `${provider}${DEFAULT_PROFILE_SUFFIX}`; +} + +export function suggestCliproxyBridgeName(provider: CLIProxyProvider): string { + const baseName = getDefaultCliproxyBridgeName(provider); + if (!hasConfiguredProfile(baseName) && !hasSettingsFile(baseName)) { + return baseName; + } + + for (let index = 2; index < 1000; index += 1) { + const candidate = `${baseName}-${index}`; + if (!hasConfiguredProfile(candidate) && !hasSettingsFile(candidate)) { + return candidate; + } + } + + return `${baseName}-${Date.now()}`; +} + +function resolveBridgeModelMapping(provider: CLIProxyProvider): ModelMapping { + const mapping = getModelMappingFromConfig(provider); + return { + default: mapping.defaultModel, + opus: mapping.opusModel || mapping.defaultModel, + sonnet: mapping.sonnetModel || mapping.defaultModel, + haiku: mapping.haikuModel || mapping.defaultModel, + }; +} + +export function listCliproxyBridgeProviders(): CliproxyBridgeProviderInfo[] { + return CLIPROXY_PROVIDER_IDS.map((provider) => ({ + provider, + displayName: getProviderDisplayName(provider), + description: getProviderDescription(provider), + defaultProfileName: getDefaultCliproxyBridgeName(provider), + routePath: `/api/provider/${provider}`, + })); +} + +export function resolveCliproxyBridgeProfile( + provider: CLIProxyProvider, + options: { + name?: string; + target?: TargetType; + } = {} +): ResolvedCliproxyBridgeProfile { + const target = getProxyTarget(); + const profileName = options.name?.trim() || suggestCliproxyBridgeName(provider); + const baseUrl = buildProxyUrl(target, `/api/provider/${provider}`); + const apiKey = target.authToken ?? getEffectiveApiKey(); + + return { + name: profileName, + provider, + providerDisplayName: getProviderDisplayName(provider), + baseUrl, + apiKey, + models: resolveBridgeModelMapping(provider), + target: options.target || 'claude', + routePath: `/api/provider/${provider}`, + source: target.isRemote ? 'remote' : 'local', + }; +} + +export function resolveCliproxyBridgeMetadata( + settings: Pick | null | undefined +): CliproxyBridgeMetadata | null { + const provider = resolveProviderFromBaseUrl(settings?.env?.ANTHROPIC_BASE_URL); + if (!provider) { + return null; + } + + const resolved = resolveCliproxyBridgeProfile(provider); + const actualBaseUrl = settings?.env?.ANTHROPIC_BASE_URL?.trim() || ''; + const actualAuthToken = settings?.env?.ANTHROPIC_AUTH_TOKEN?.trim() || ''; + + return { + provider, + providerDisplayName: resolved.providerDisplayName, + routePath: resolved.routePath, + currentBaseUrl: resolved.baseUrl, + source: resolved.source, + usesCurrentTarget: normalizeBridgeUrl(actualBaseUrl) === normalizeBridgeUrl(resolved.baseUrl), + usesCurrentAuthToken: actualAuthToken.length > 0 && actualAuthToken === resolved.apiKey, + }; +} diff --git a/src/api/services/index.ts b/src/api/services/index.ts index ae519d48..fb6ad360 100644 --- a/src/api/services/index.ts +++ b/src/api/services/index.ts @@ -14,8 +14,12 @@ export { type CliproxyVariantInfo, type ApiListResult, type CreateApiProfileResult, + type CreateCliproxyBridgeProfileResult, type RemoveApiProfileResult, type UpdateApiProfileTargetResult, + type CliproxyBridgeProviderInfo, + type CliproxyBridgeMetadata, + type ResolvedCliproxyBridgeProfile, type ProfileValidationIssue, type ProfileValidationSummary, type ApiProfileOrphanCandidate, @@ -38,6 +42,14 @@ export { // Profile write operations export { createApiProfile, removeApiProfile, updateApiProfileTarget } from './profile-writer'; +export { createCliproxyBridgeProfile } from './profile-writer'; +export { + getDefaultCliproxyBridgeName, + listCliproxyBridgeProviders, + resolveCliproxyBridgeMetadata, + resolveCliproxyBridgeProfile, + suggestCliproxyBridgeName, +} from './cliproxy-profile-bridge'; // Lifecycle validation and operations export { validateApiProfileSettingsPayload } from './profile-lifecycle-validation'; diff --git a/src/api/services/profile-reader.ts b/src/api/services/profile-reader.ts index ee0efc2f..4bb99b31 100644 --- a/src/api/services/profile-reader.ts +++ b/src/api/services/profile-reader.ts @@ -6,11 +6,13 @@ */ import * as fs from 'fs'; -import * as path from 'path'; -import { getCcsDir, loadConfigSafe } from '../../utils/config-manager'; +import { loadConfigSafe } from '../../utils/config-manager'; import { loadOrCreateUnifiedConfig, isUnifiedMode } from '../../config/unified-config-loader'; +import { expandPath } from '../../utils/helpers'; import type { TargetType } from '../../targets/target-adapter'; +import type { Settings } from '../../types/config'; import type { ApiProfileInfo, CliproxyVariantInfo, ApiListResult } from './profile-types'; +import { resolveCliproxyBridgeMetadata } from './cliproxy-profile-bridge'; const VALID_TARGETS: ReadonlySet = new Set(['claude', 'droid']); @@ -37,23 +39,43 @@ export function apiProfileExists(name: string): boolean { } } +/** + * Load settings file from a config reference such as ~/.ccs/name.settings.json. + */ +function loadProfileSettings(settingsReference: string | undefined): Settings | null { + if (!settingsReference || settingsReference === 'config.yaml') { + return null; + } + + try { + const settingsPath = expandPath(settingsReference); + if (!fs.existsSync(settingsPath)) return null; + return JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as Settings; + } catch { + return null; + } +} + +function isConfiguredFromSettings(settings: Settings | null): boolean { + const token = settings?.env?.ANTHROPIC_AUTH_TOKEN || settings?.env?.ANTHROPIC_API_KEY || ''; + return token.length > 0 && !token.includes('YOUR_') && !token.includes('your-'); +} + +function resolveProfileSettingsReference(name: string): string | undefined { + if (isUnifiedMode()) { + const config = loadOrCreateUnifiedConfig(); + return config.profiles[name]?.settings; + } + + const config = loadConfigSafe(); + return config.profiles[name]; +} + /** * Check if API profile has real API key (not placeholder) */ export function isApiProfileConfigured(apiName: string): boolean { - try { - const ccsDir = getCcsDir(); - const settingsPath = path.join(ccsDir, `${apiName}.settings.json`); - - // Check settings.json file for API key - if (!fs.existsSync(settingsPath)) return false; - - const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); - const token = settings?.env?.ANTHROPIC_AUTH_TOKEN || settings?.env?.ANTHROPIC_API_KEY || ''; - return token.length > 0 && !token.includes('YOUR_') && !token.includes('your-'); - } catch { - return false; - } + return isConfiguredFromSettings(loadProfileSettings(resolveProfileSettingsReference(apiName))); } /** @@ -73,12 +95,15 @@ export function listApiProfiles(): ApiListResult { if (name === 'default' && profile.settings?.includes('.claude/settings.json')) { continue; } + const settingsPath = profile.settings || 'config.yaml'; + const settings = loadProfileSettings(settingsPath); profiles.push({ name, - settingsPath: profile.settings || 'config.yaml', - isConfigured: isApiProfileConfigured(name), + settingsPath, + isConfigured: isConfiguredFromSettings(settings), configSource: 'unified', target: sanitizeTarget(profile.target), + cliproxyBridge: resolveCliproxyBridgeMetadata(settings), }); } // CLIProxy variants @@ -103,12 +128,14 @@ export function listApiProfiles(): ApiListResult { if (name === 'default' && (settingsPath as string).includes('.claude/settings.json')) { continue; } + const settings = loadProfileSettings(settingsPath as string); profiles.push({ name, settingsPath: settingsPath as string, - isConfigured: isApiProfileConfigured(name), + isConfigured: isConfiguredFromSettings(settings), configSource: 'legacy', target: sanitizeTarget(legacyTargetMap?.[name]), + cliproxyBridge: resolveCliproxyBridgeMetadata(settings), }); } // CLIProxy variants diff --git a/src/api/services/profile-types.ts b/src/api/services/profile-types.ts index 54d63ebe..2f8bd40a 100644 --- a/src/api/services/profile-types.ts +++ b/src/api/services/profile-types.ts @@ -5,6 +5,7 @@ */ import type { TargetType } from '../../targets/target-adapter'; +import type { CLIProxyProvider } from '../../cliproxy/types'; /** Model mapping for API profiles */ export interface ModelMapping { @@ -21,6 +22,7 @@ export interface ApiProfileInfo { isConfigured: boolean; configSource: 'unified' | 'legacy'; target: TargetType; + cliproxyBridge?: CliproxyBridgeMetadata | null; } /** CLIProxy variant info */ @@ -44,6 +46,43 @@ export interface CreateApiProfileResult { error?: string; } +export interface CliproxyBridgeProviderInfo { + provider: CLIProxyProvider; + displayName: string; + description: string; + defaultProfileName: string; + routePath: string; +} + +export interface CliproxyBridgeMetadata { + provider: CLIProxyProvider; + providerDisplayName: string; + routePath: string; + currentBaseUrl: string; + source: 'local' | 'remote'; + usesCurrentTarget: boolean; + usesCurrentAuthToken: boolean; +} + +export interface ResolvedCliproxyBridgeProfile { + name: string; + provider: CLIProxyProvider; + providerDisplayName: string; + baseUrl: string; + apiKey: string; + models: ModelMapping; + target: TargetType; + routePath: string; + source: 'local' | 'remote'; +} + +export interface CreateCliproxyBridgeProfileResult extends CreateApiProfileResult { + name?: string; + provider?: CLIProxyProvider; + target?: TargetType; + cliproxyBridge?: CliproxyBridgeMetadata | null; +} + /** Result from remove operation */ export interface RemoveApiProfileResult { success: boolean; diff --git a/src/api/services/profile-writer.ts b/src/api/services/profile-writer.ts index 2404b202..ae77908c 100644 --- a/src/api/services/profile-writer.ts +++ b/src/api/services/profile-writer.ts @@ -6,6 +6,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { getCcsDir, getConfigPath, loadConfigSafe } from '../../utils/config-manager'; import { expandPath } from '../../utils/helpers'; +import { validateApiName } from './validation-service'; import { loadOrCreateUnifiedConfig, saveUnifiedConfig, @@ -14,6 +15,7 @@ import { import { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector'; import type { TargetType } from '../../targets/target-adapter'; import { resolveDroidProvider } from '../../targets/droid-provider'; +import { isReservedName } from '../../config/reserved-names'; import { mapExternalProviderName } from '../../cliproxy/provider-capabilities'; import { extractProviderFromPathname, @@ -23,9 +25,15 @@ import type { CLIProxyProvider } from '../../cliproxy/types'; import type { ModelMapping, CreateApiProfileResult, + CreateCliproxyBridgeProfileResult, RemoveApiProfileResult, UpdateApiProfileTargetResult, } from './profile-types'; +import { apiProfileExists } from './profile-reader'; +import { + resolveCliproxyBridgeMetadata, + resolveCliproxyBridgeProfile, +} from './cliproxy-profile-bridge'; /** Check if URL is an OpenRouter endpoint */ function isOpenRouterUrl(baseUrl: string): boolean { @@ -233,6 +241,63 @@ export function createApiProfile( } } +export function createCliproxyBridgeProfile( + provider: CLIProxyProvider, + options: { + name?: string; + force?: boolean; + target?: TargetType; + } = {} +): CreateCliproxyBridgeProfileResult { + const providedName = options.name?.trim(); + if (providedName) { + const nameError = validateApiName(providedName); + if (nameError) { + return { success: false, settingsFile: '', error: nameError }; + } + if (isReservedName(providedName)) { + return { + success: false, + settingsFile: '', + error: `Profile name '${providedName}' is reserved`, + }; + } + } + + const resolved = resolveCliproxyBridgeProfile(provider, options); + const settingsPath = path.join(getCcsDir(), `${resolved.name}.settings.json`); + if (!options.force && (apiProfileExists(resolved.name) || fs.existsSync(settingsPath))) { + return { + success: false, + settingsFile: '', + error: `Profile already exists: ${resolved.name}`, + }; + } + + const result = createApiProfile( + resolved.name, + resolved.baseUrl, + resolved.apiKey, + resolved.models, + resolved.target, + provider + ); + + return { + ...result, + name: resolved.name, + provider, + target: resolved.target, + cliproxyBridge: + resolveCliproxyBridgeMetadata({ + env: { + ANTHROPIC_BASE_URL: resolved.baseUrl, + ANTHROPIC_AUTH_TOKEN: resolved.apiKey, + }, + }) ?? null, + }; +} + /** * Update API profile target (claude/droid). * Persists to config.yaml in unified mode and config.json profile_targets in legacy mode. diff --git a/src/auth/commands/create-command.ts b/src/auth/commands/create-command.ts index f9edd2dc..ef59fb60 100644 --- a/src/auth/commands/create-command.ts +++ b/src/auth/commands/create-command.ts @@ -163,7 +163,7 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise if (!profileExistedBeforeCreate) { try { - ctx.instanceMgr.deleteInstance(profileName); + await ctx.instanceMgr.deleteInstance(profileName); } catch { // Best-effort cleanup. } diff --git a/src/auth/commands/remove-command.ts b/src/auth/commands/remove-command.ts index cfef25cd..b7df32fa 100644 --- a/src/auth/commands/remove-command.ts +++ b/src/auth/commands/remove-command.ts @@ -68,7 +68,7 @@ export async function handleRemove(ctx: CommandContext, args: string[]): Promise } // Delete instance - ctx.instanceMgr.deleteInstance(profileName); + await ctx.instanceMgr.deleteInstance(profileName); // Delete profile from appropriate config if (isUnifiedMode() && existsUnified) { diff --git a/src/cliproxy/ai-providers/config-store.ts b/src/cliproxy/ai-providers/config-store.ts new file mode 100644 index 00000000..2cc665f4 --- /dev/null +++ b/src/cliproxy/ai-providers/config-store.ts @@ -0,0 +1,128 @@ +import * as fs from 'fs'; +import * as yaml from 'js-yaml'; +import { configExists, getCliproxyConfigPath, regenerateConfig } from '../config-generator'; +import { getProxyTarget } from '../proxy-target-resolver'; +import { createManagementClient } from '../management-api-client'; +import { rewriteTopLevelYamlSection } from './config-yaml-sections'; +import type { + AiProviderApiKeyEntry, + AiProviderFamilyId, + LocalAiProviderConfig, + OpenAICompatEntry, +} from './types'; + +type FamilyEntriesMap = { + 'gemini-api-key': AiProviderApiKeyEntry[]; + 'codex-api-key': AiProviderApiKeyEntry[]; + 'claude-api-key': AiProviderApiKeyEntry[]; + 'vertex-api-key': AiProviderApiKeyEntry[]; + 'openai-compatibility': OpenAICompatEntry[]; +}; + +export type FamilyEntries = FamilyEntriesMap[F]; + +function ensureLocalConfigPath(): string { + if (!configExists()) { + regenerateConfig(); + } + return getCliproxyConfigPath(); +} + +function readLocalConfig(): LocalAiProviderConfig { + const configPath = ensureLocalConfigPath(); + if (!fs.existsSync(configPath)) { + return {}; + } + + try { + const content = fs.readFileSync(configPath, 'utf8'); + return (yaml.load(content) as LocalAiProviderConfig) || {}; + } catch { + return {}; + } +} + +function writeLocalFamilySection( + family: F, + entries: FamilyEntries +): void { + const configPath = ensureLocalConfigPath(); + const content = fs.existsSync(configPath) ? fs.readFileSync(configPath, 'utf8') : ''; + const sectionYaml = + entries.length > 0 + ? yaml.dump( + { [family]: entries }, + { + indent: 2, + lineWidth: -1, + quotingType: "'", + forceQuotes: false, + } + ) + : null; + const nextContent = rewriteTopLevelYamlSection(content, family, sectionYaml); + const tempPath = `${configPath}.tmp`; + fs.writeFileSync(tempPath, nextContent, { mode: 0o600 }); + fs.renameSync(tempPath, configPath); +} + +export function getAiProvidersSourceSummary() { + const target = getProxyTarget(); + const managementAuth = target.isRemote + ? target.managementKey + ? 'configured' + : target.authToken + ? 'fallback' + : 'missing' + : 'configured'; + + return { + mode: target.isRemote ? 'remote' : 'local', + label: target.isRemote ? 'Remote CLIProxy' : 'Local CLIProxy', + target: `${target.protocol}://${target.host}:${target.port}`, + managementAuth, + } as const; +} + +export async function readFamilyEntries( + family: F +): Promise> { + const target = getProxyTarget(); + + if (!target.isRemote) { + const config = readLocalConfig(); + return (config[family] || []) as FamilyEntries; + } + + const client = createManagementClient({ + host: target.host, + port: target.port, + protocol: target.protocol, + management_key: target.managementKey, + auth_token: target.authToken, + }); + + return client.getSection[number]>(family) as Promise>; +} + +export async function writeFamilyEntries( + family: F, + entries: FamilyEntries +): Promise { + const target = getProxyTarget(); + + if (!target.isRemote) { + writeLocalFamilySection(family, entries); + return; + } + + const client = createManagementClient({ + host: target.host, + port: target.port, + protocol: target.protocol, + management_key: target.managementKey, + auth_token: target.authToken, + }); + + await client.putSection[number]>(family, entries as FamilyEntries[number][]); +} diff --git a/src/cliproxy/ai-providers/config-yaml-sections.ts b/src/cliproxy/ai-providers/config-yaml-sections.ts new file mode 100644 index 00000000..a5502b22 --- /dev/null +++ b/src/cliproxy/ai-providers/config-yaml-sections.ts @@ -0,0 +1,49 @@ +export function rewriteTopLevelYamlSection( + content: string, + sectionKey: string, + newSection: string | null +): string { + const lines = content.split('\n'); + const result: string[] = []; + let inSection = false; + let sectionFound = false; + + for (const line of lines) { + const trimmed = line.trimStart(); + if (trimmed.startsWith(`${sectionKey}:`)) { + inSection = true; + sectionFound = true; + if (newSection) { + result.push(newSection.trimEnd()); + } + continue; + } + + if (inSection) { + const isTopLevelKey = + line.length > 0 && + !line.startsWith(' ') && + !line.startsWith('\t') && + !line.startsWith('#') && + /^[a-zA-Z_][a-zA-Z0-9_-]*\s*:/.test(line); + + if (isTopLevelKey) { + inSection = false; + result.push(line); + } + continue; + } + + result.push(line); + } + + if (!sectionFound && newSection) { + result.push(''); + result.push(newSection.trimEnd()); + } + + return `${result + .join('\n') + .replace(/\n{3,}/g, '\n\n') + .trimEnd()}\n`; +} diff --git a/src/cliproxy/ai-providers/index.ts b/src/cliproxy/ai-providers/index.ts new file mode 100644 index 00000000..e052a979 --- /dev/null +++ b/src/cliproxy/ai-providers/index.ts @@ -0,0 +1,19 @@ +export type { + AiProviderApiKeyEntry, + AiProviderEntryView, + AiProviderFamilyDefinition, + AiProviderFamilyId, + AiProviderFamilyState, + AiProviderModelAlias, + AiProvidersSourceSummary, + ListAiProvidersResult, + OpenAICompatEntry, + UpsertAiProviderEntryInput, +} from './types'; +export { AI_PROVIDER_FAMILY_DEFINITIONS, AI_PROVIDER_FAMILY_IDS } from './types'; +export { + listAiProviders, + createAiProviderEntry, + updateAiProviderEntry, + deleteAiProviderEntry, +} from './service'; diff --git a/src/cliproxy/ai-providers/service.ts b/src/cliproxy/ai-providers/service.ts new file mode 100644 index 00000000..a03f959a --- /dev/null +++ b/src/cliproxy/ai-providers/service.ts @@ -0,0 +1,246 @@ +import { + AI_PROVIDER_FAMILY_DEFINITIONS, + AI_PROVIDER_FAMILY_IDS, + type AiProviderApiKeyEntry, + type AiProviderEntryView, + type AiProviderFamilyId, + type AiProviderFamilyState, + type AiProviderModelAlias, + type ListAiProvidersResult, + type OpenAICompatEntry, + type UpsertAiProviderEntryInput, +} from './types'; +import { getAiProvidersSourceSummary, readFamilyEntries, writeFamilyEntries } from './config-store'; + +function maskSecret(value: string | undefined): string | undefined { + if (!value) return undefined; + return value.length > 8 ? `...${value.slice(-4)}` : '***'; +} + +function normalizeHeaders( + headers: Array<{ key: string; value: string }> | undefined +): Record | undefined { + if (!headers) return undefined; + const normalized = headers.reduce>((acc, header) => { + const key = header.key.trim(); + if (!key) return acc; + acc[key] = header.value; + return acc; + }, {}); + return Object.keys(normalized).length > 0 ? normalized : undefined; +} + +function toHeaderPairs( + headers: Record | undefined +): Array<{ key: string; value: string }> { + return Object.entries(headers || {}).map(([key, value]) => ({ key, value })); +} + +function normalizeModelAliases(models: AiProviderModelAlias[] | undefined): AiProviderModelAlias[] { + return (models || []) + .map((model) => ({ + name: model.name.trim(), + alias: model.alias.trim(), + })) + .filter((model) => model.name.length > 0 || model.alias.length > 0); +} + +function buildApiKeyEntryView( + family: AiProviderFamilyId, + entry: AiProviderApiKeyEntry, + index: number +): AiProviderEntryView { + return { + id: `${family}:${index}`, + index, + label: entry.prefix?.trim() || entry['base-url']?.trim() || `Entry ${index + 1}`, + baseUrl: entry['base-url']?.trim() || undefined, + proxyUrl: entry['proxy-url']?.trim() || undefined, + prefix: entry.prefix?.trim() || undefined, + headers: toHeaderPairs(entry.headers), + excludedModels: [...(entry['excluded-models'] || [])], + models: normalizeModelAliases(entry.models), + apiKeyMasked: maskSecret(entry['api-key']), + secretConfigured: Boolean(entry['api-key']), + }; +} + +function buildOpenAiCompatEntryView(entry: OpenAICompatEntry, index: number): AiProviderEntryView { + return { + id: `openai-compatibility:${index}`, + index, + name: entry.name, + label: entry.name, + baseUrl: entry['base-url']?.trim() || undefined, + headers: toHeaderPairs(entry.headers), + excludedModels: [], + models: normalizeModelAliases(entry.models), + apiKeysMasked: (entry['api-key-entries'] || []).map( + (apiKeyEntry) => maskSecret(apiKeyEntry['api-key']) || '***' + ), + secretConfigured: (entry['api-key-entries'] || []).length > 0, + }; +} + +function resolveFamilyStatus(entries: AiProviderEntryView[]): AiProviderFamilyState['status'] { + if (entries.length === 0) return 'empty'; + return entries.every((entry) => entry.secretConfigured) ? 'ready' : 'partial'; +} + +export async function listAiProviders(): Promise { + const families = await Promise.all( + AI_PROVIDER_FAMILY_IDS.map(async (familyId) => { + const definition = AI_PROVIDER_FAMILY_DEFINITIONS[familyId]; + if (familyId === 'openai-compatibility') { + const entries = await readFamilyEntries(familyId); + const entryViews = entries.map((entry, index) => buildOpenAiCompatEntryView(entry, index)); + return { + ...definition, + status: resolveFamilyStatus(entryViews), + entries: entryViews, + }; + } + + const entries = await readFamilyEntries(familyId); + const entryViews = entries.map((entry, index) => + buildApiKeyEntryView(familyId, entry, index) + ); + return { + ...definition, + status: resolveFamilyStatus(entryViews), + entries: entryViews, + }; + }) + ); + + return { + source: getAiProvidersSourceSummary(), + families, + }; +} + +function toApiKeyEntry( + input: UpsertAiProviderEntryInput, + existing?: AiProviderApiKeyEntry +): AiProviderApiKeyEntry { + const nextSecret = + input.apiKey !== undefined + ? input.apiKey.trim() + : input.preserveSecrets + ? existing?.['api-key'] || '' + : existing?.['api-key'] || ''; + + return { + 'api-key': nextSecret, + 'base-url': input.baseUrl?.trim() || undefined, + 'proxy-url': input.proxyUrl?.trim() || undefined, + prefix: input.prefix?.trim() || undefined, + headers: normalizeHeaders(input.headers), + 'excluded-models': (input.excludedModels || []) + .map((value) => value.trim()) + .filter((value) => value.length > 0), + models: normalizeModelAliases(input.models), + }; +} + +function toOpenAiCompatEntry( + input: UpsertAiProviderEntryInput, + existing?: OpenAICompatEntry +): OpenAICompatEntry { + const nextApiKeys = + input.apiKeys !== undefined + ? input.apiKeys.map((value) => value.trim()).filter((value) => value.length > 0) + : input.preserveSecrets + ? (existing?.['api-key-entries'] || []).map((entry) => entry['api-key']) + : (existing?.['api-key-entries'] || []).map((entry) => entry['api-key']); + + return { + name: input.name?.trim() || existing?.name || 'connector', + 'base-url': input.baseUrl?.trim() || existing?.['base-url'] || '', + headers: normalizeHeaders(input.headers), + 'api-key-entries': nextApiKeys.map((apiKey) => ({ 'api-key': apiKey })), + models: normalizeModelAliases(input.models), + }; +} + +function assertIndex(entries: unknown[], index: number): void { + if (!Number.isInteger(index) || index < 0 || index >= entries.length) { + throw new Error('Entry not found'); + } +} + +function validateFamilyInput(family: AiProviderFamilyId, input: UpsertAiProviderEntryInput): void { + if (family === 'openai-compatibility') { + if (!(input.name?.trim() || '').length) { + throw new Error('name is required'); + } + if (!(input.baseUrl?.trim() || '').length) { + throw new Error('baseUrl is required'); + } + if (!input.preserveSecrets && !(input.apiKeys || []).some((value) => value.trim().length > 0)) { + throw new Error('At least one api key is required'); + } + return; + } + + if (!input.preserveSecrets && !(input.apiKey?.trim() || '').length) { + throw new Error('apiKey is required'); + } +} + +export async function createAiProviderEntry( + family: AiProviderFamilyId, + input: UpsertAiProviderEntryInput +): Promise { + validateFamilyInput(family, input); + + if (family === 'openai-compatibility') { + const entries = await readFamilyEntries(family); + entries.push(toOpenAiCompatEntry(input)); + await writeFamilyEntries(family, entries); + return; + } + + const entries = await readFamilyEntries(family); + entries.push(toApiKeyEntry(input)); + await writeFamilyEntries(family, entries); +} + +export async function updateAiProviderEntry( + family: AiProviderFamilyId, + index: number, + input: UpsertAiProviderEntryInput +): Promise { + if (family === 'openai-compatibility') { + const entries = await readFamilyEntries(family); + assertIndex(entries, index); + validateFamilyInput(family, input); + entries[index] = toOpenAiCompatEntry(input, entries[index]); + await writeFamilyEntries(family, entries); + return; + } + + const entries = await readFamilyEntries(family); + assertIndex(entries, index); + validateFamilyInput(family, input); + entries[index] = toApiKeyEntry(input, entries[index]); + await writeFamilyEntries(family, entries); +} + +export async function deleteAiProviderEntry( + family: AiProviderFamilyId, + index: number +): Promise { + if (family === 'openai-compatibility') { + const entries = await readFamilyEntries(family); + assertIndex(entries, index); + entries.splice(index, 1); + await writeFamilyEntries(family, entries); + return; + } + + const entries = await readFamilyEntries(family); + assertIndex(entries, index); + entries.splice(index, 1); + await writeFamilyEntries(family, entries); +} diff --git a/src/cliproxy/ai-providers/types.ts b/src/cliproxy/ai-providers/types.ts new file mode 100644 index 00000000..3fe9cff6 --- /dev/null +++ b/src/cliproxy/ai-providers/types.ts @@ -0,0 +1,153 @@ +export const AI_PROVIDER_FAMILY_IDS = [ + 'gemini-api-key', + 'codex-api-key', + 'claude-api-key', + 'vertex-api-key', + 'openai-compatibility', +] as const; + +export type AiProviderFamilyId = (typeof AI_PROVIDER_FAMILY_IDS)[number]; + +export interface AiProviderModelAlias { + name: string; + alias: string; +} + +export interface AiProviderApiKeyEntry { + 'api-key': string; + 'base-url'?: string; + 'proxy-url'?: string; + prefix?: string; + headers?: Record; + 'excluded-models'?: string[]; + models?: AiProviderModelAlias[]; +} + +export interface OpenAICompatApiKeyEntry { + 'api-key': string; + 'proxy-url'?: string; +} + +export interface OpenAICompatEntry { + name: string; + 'base-url': string; + headers?: Record; + 'api-key-entries': OpenAICompatApiKeyEntry[]; + models?: AiProviderModelAlias[]; +} + +export interface AiProviderFamilyDefinition { + id: AiProviderFamilyId; + displayName: string; + description: string; + authMode: 'api-key' | 'hybrid' | 'connector'; + supportsNamedEntries: boolean; + routePath: string; +} + +export interface AiProviderEntryView { + id: string; + index: number; + name?: string; + label: string; + baseUrl?: string; + proxyUrl?: string; + prefix?: string; + headers: Array<{ key: string; value: string }>; + excludedModels: string[]; + models: AiProviderModelAlias[]; + apiKeyMasked?: string; + apiKeysMasked?: string[]; + secretConfigured: boolean; +} + +export interface AiProviderFamilyState { + id: AiProviderFamilyId; + displayName: string; + description: string; + authMode: 'api-key' | 'hybrid' | 'connector'; + routePath: string; + status: 'empty' | 'partial' | 'ready'; + supportsNamedEntries: boolean; + entries: AiProviderEntryView[]; +} + +export interface AiProvidersSourceSummary { + mode: 'local' | 'remote'; + label: string; + target: string; + managementAuth: 'configured' | 'fallback' | 'missing'; +} + +export interface ListAiProvidersResult { + source: AiProvidersSourceSummary; + families: AiProviderFamilyState[]; +} + +export interface UpsertAiProviderEntryInput { + name?: string; + baseUrl?: string; + proxyUrl?: string; + prefix?: string; + headers?: Array<{ key: string; value: string }>; + excludedModels?: string[]; + models?: AiProviderModelAlias[]; + apiKey?: string; + apiKeys?: string[]; + preserveSecrets?: boolean; +} + +export interface LocalAiProviderConfig { + 'gemini-api-key'?: AiProviderApiKeyEntry[]; + 'codex-api-key'?: AiProviderApiKeyEntry[]; + 'claude-api-key'?: AiProviderApiKeyEntry[]; + 'vertex-api-key'?: AiProviderApiKeyEntry[]; + 'openai-compatibility'?: OpenAICompatEntry[]; + [key: string]: unknown; +} + +export const AI_PROVIDER_FAMILY_DEFINITIONS: Record< + AiProviderFamilyId, + AiProviderFamilyDefinition +> = { + 'gemini-api-key': { + id: 'gemini-api-key', + displayName: 'Gemini', + description: 'Google Gemini API keys and route defaults', + authMode: 'hybrid', + supportsNamedEntries: false, + routePath: '/api/provider/gemini', + }, + 'codex-api-key': { + id: 'codex-api-key', + displayName: 'Codex', + description: 'OpenAI Codex API keys and endpoint overrides', + authMode: 'hybrid', + supportsNamedEntries: false, + routePath: '/api/provider/codex', + }, + 'claude-api-key': { + id: 'claude-api-key', + displayName: 'Claude', + description: 'Anthropic-compatible routing entries with aliases and filters', + authMode: 'api-key', + supportsNamedEntries: false, + routePath: '/api/provider/claude', + }, + 'vertex-api-key': { + id: 'vertex-api-key', + displayName: 'Vertex', + description: 'Vertex AI API keys and regional endpoint overrides', + authMode: 'api-key', + supportsNamedEntries: false, + routePath: '/api/provider/vertex', + }, + 'openai-compatibility': { + id: 'openai-compatibility', + displayName: 'OpenAI-Compatible', + description: 'Named connectors for OpenRouter, Together, and custom OpenAI-style APIs', + authMode: 'connector', + supportsNamedEntries: true, + routePath: '/api/provider/openai-compat', + }, +}; diff --git a/src/cliproxy/base-config-loader.ts b/src/cliproxy/base-config-loader.ts index 09de1603..ab774d06 100644 --- a/src/cliproxy/base-config-loader.ts +++ b/src/cliproxy/base-config-loader.ts @@ -9,7 +9,7 @@ import * as fs from 'fs'; import * as path from 'path'; -import { CLIProxyProvider, ProviderModelMapping } from './types'; +import type { CLIProxyProvider, ProviderModelMapping } from './types'; /** Base settings file structure */ interface BaseSettings { diff --git a/src/cliproxy/codex-plan-compatibility.ts b/src/cliproxy/codex-plan-compatibility.ts index 41177e00..459d31a4 100644 --- a/src/cliproxy/codex-plan-compatibility.ts +++ b/src/cliproxy/codex-plan-compatibility.ts @@ -1,4 +1,5 @@ import { getDefaultAccount } from './account-manager'; +import { getProviderCatalog } from './model-catalog'; import { fetchCodexQuota } from './quota-fetcher-codex'; import { getCachedQuota, setCachedQuota } from './quota-response-cache'; import type { CodexQuotaResult } from './quota-types'; @@ -12,6 +13,9 @@ const FREE_SAFE_FAST_MODEL = 'gpt-5-codex-mini'; const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i; const CODEX_PAREN_SUFFIX_REGEX = /\((xhigh|high|medium)\)$/i; const EXTENDED_CONTEXT_SUFFIX_REGEX = /\[1m\]$/i; +const KNOWN_CODEX_MODELS = new Set( + (getProviderCatalog('codex')?.models ?? []).map((model) => model.id.toLowerCase()) +); const FREE_PLAN_FALLBACKS = new Map([ ['gpt-5.3-codex', FREE_SAFE_DEFAULT_MODEL], @@ -19,7 +23,29 @@ const FREE_PLAN_FALLBACKS = new Map([ ['gpt-5.4', FREE_SAFE_DEFAULT_MODEL], ]); -function normalizeCodexModelId(model: string): string { +export interface CodexRuntimeFallbackModelMap { + defaultModel?: string; + opusModel?: string; + sonnetModel?: string; + haikuModel?: string; +} + +export interface CodexUnsupportedModelError { + message: string | null; + code: 'model_not_supported'; + param: string | null; + type: string | null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isKnownCodexModel(model: string): boolean { + return KNOWN_CODEX_MODELS.has(model); +} + +export function normalizeCodexModelId(model: string): string { return model .trim() .replace(EXTENDED_CONTEXT_SUFFIX_REGEX, '') @@ -37,6 +63,74 @@ export function getFreePlanFallbackCodexModel(model: string): string | null { return FREE_PLAN_FALLBACKS.get(normalizeCodexModelId(model)) ?? null; } +export function parseCodexUnsupportedModelError( + statusCode: number | undefined, + responseBody: string +): CodexUnsupportedModelError | null { + if (statusCode !== 400 || !responseBody.trim()) { + return null; + } + + try { + const parsed = JSON.parse(responseBody); + if ( + !isRecord(parsed) || + !isRecord(parsed.error) || + parsed.error.code !== 'model_not_supported' + ) { + return null; + } + + return { + message: typeof parsed.error.message === 'string' ? parsed.error.message : null, + code: 'model_not_supported', + param: typeof parsed.error.param === 'string' ? parsed.error.param : null, + type: typeof parsed.error.type === 'string' ? parsed.error.type : null, + }; + } catch { + return null; + } +} + +export function resolveRuntimeCodexFallbackModel(options: { + requestedModel: string; + modelMap: CodexRuntimeFallbackModelMap; + excludeModels?: string[]; +}): string | null { + const requestedModel = normalizeCodexModelId(options.requestedModel); + if (!requestedModel) { + return null; + } + + const excludedModels = new Set( + (options.excludeModels ?? []).map((model) => normalizeCodexModelId(model)).filter(Boolean) + ); + const candidates = [ + options.modelMap.defaultModel, + getFreePlanFallbackCodexModel(requestedModel), + options.modelMap.opusModel, + options.modelMap.sonnetModel, + options.modelMap.haikuModel, + getDefaultCodexModel(), + ]; + + for (const candidate of candidates) { + if (!candidate) continue; + const normalizedCandidate = normalizeCodexModelId(candidate); + if ( + !normalizedCandidate || + normalizedCandidate === requestedModel || + excludedModels.has(normalizedCandidate) || + !isKnownCodexModel(normalizedCandidate) + ) { + continue; + } + return normalizedCandidate; + } + + return null; +} + export async function reconcileCodexModelForActivePlan(options: { settingsPath: string; currentModel: string | undefined; diff --git a/src/cliproxy/codex-reasoning-proxy.ts b/src/cliproxy/codex-reasoning-proxy.ts index 57eb7e39..b24508e4 100644 --- a/src/cliproxy/codex-reasoning-proxy.ts +++ b/src/cliproxy/codex-reasoning-proxy.ts @@ -1,6 +1,11 @@ import * as http from 'http'; import * as https from 'https'; import { URL } from 'url'; +import { + normalizeCodexModelId, + parseCodexUnsupportedModelError, + resolveRuntimeCodexFallbackModel, +} from './codex-plan-compatibility'; import { getModelMaxLevel } from './model-catalog'; export type CodexReasoningEffort = 'medium' | 'high' | 'xhigh'; @@ -29,6 +34,14 @@ export interface CodexReasoningProxyConfig { disableEffort?: boolean; } +interface ForwardJsonContext { + requestPath: string; + requestedModel: string | null; + attemptedUpstreamModel: string | null; + effort: CodexReasoningEffort | null; + retryCount: number; +} + const EXTENDED_CONTEXT_SUFFIX_REGEX = /\[1m\]$/i; function stripExtendedContextSuffix(model: string): string { @@ -170,6 +183,7 @@ export class CodexReasoningProxy { > & Pick; private readonly modelEffort: Map; + private readonly sessionFallbackByModel = new Map(); private readonly recent: Array<{ at: string; model: string | null; @@ -193,6 +207,41 @@ export class CodexReasoningProxy { this.modelEffort = buildCodexModelEffortMap(this.config.modelMap, this.config.defaultEffort); } + private getRememberedFallback(model: string | null): string | null { + if (!model) return null; + return this.sessionFallbackByModel.get(normalizeCodexModelId(model)) ?? null; + } + + private rememberFallback(requestedModel: string, fallbackModel: string): void { + const normalizedRequestedModel = normalizeCodexModelId(requestedModel); + const normalizedFallbackModel = normalizeCodexModelId(fallbackModel); + if (!normalizedRequestedModel || !normalizedFallbackModel) return; + this.sessionFallbackByModel.set(normalizedRequestedModel, normalizedFallbackModel); + } + + private buildForwardBody( + body: unknown, + upstreamModel: string | null, + effort: CodexReasoningEffort | null + ): unknown { + const withUpstreamModel = + upstreamModel && isRecord(body) ? { ...body, model: upstreamModel } : body; + if (this.config.disableEffort || !effort) { + return withUpstreamModel; + } + return injectReasoningEffortIntoBody(withUpstreamModel, effort); + } + + private sendBufferedResponse( + clientRes: http.ServerResponse, + statusCode: number, + headers: http.IncomingHttpHeaders, + responseBody: string + ): void { + clientRes.writeHead(statusCode, headers); + clientRes.end(responseBody); + } + /** * Treat trailing "-high/-medium/-xhigh" as an effort alias only for known codex models. * Prevents stripping legitimate upstream model IDs that happen to end with those tokens. @@ -365,41 +414,48 @@ export class CodexReasoningProxy { ? stripExtendedContextSuffix(originalModel) : null; - // When effort is disabled (thinking mode: off), strip model suffix but don't inject reasoning - if (this.config.disableEffort) { - const suffixParsed = this.parseEffortAlias(normalizedRequestModel); - const upstreamModel = suffixParsed?.upstreamModel ?? normalizedRequestModel; - const forwarded = - upstreamModel && isRecord(parsed) ? { ...parsed, model: upstreamModel } : parsed; - - this.log(`[disabled] model=${originalModel ?? 'null'} -> passthrough (no reasoning)`); - await this.forwardJson(req, res, fullUpstreamUrl, forwarded); - return; - } - // Support "model aliases" like `gpt-5.2-codex-xhigh` by translating to: // - upstream model: `gpt-5.2-codex` // - reasoning.effort: `xhigh` // // This allows tier→effort mapping without inventing upstream model IDs. const suffixParsed = this.parseEffortAlias(normalizedRequestModel); - const upstreamModel = suffixParsed?.upstreamModel ?? normalizedRequestModel; - const effort = + const requestedUpstreamModel = suffixParsed?.upstreamModel ?? normalizedRequestModel; + const rememberedFallback = this.getRememberedFallback(requestedUpstreamModel); + const upstreamModel = rememberedFallback ?? requestedUpstreamModel; + const requestedEffort = suffixParsed?.effort ?? getEffortForModel(normalizedRequestModel, this.modelEffort, this.config.defaultEffort); + const effort = + !this.config.disableEffort && upstreamModel + ? capEffortAtModelMax(upstreamModel, requestedEffort) + : !this.config.disableEffort + ? requestedEffort + : null; + const rewritten = this.buildForwardBody(parsed, upstreamModel, effort); - const withUpstreamModel = - upstreamModel && isRecord(parsed) ? { ...parsed, model: upstreamModel } : parsed; - const rewritten = injectReasoningEffortIntoBody(withUpstreamModel, effort); + if (effort) { + this.record(originalModel, upstreamModel, effort, requestPath); + this.trace( + `[${new Date().toISOString()}] model=${originalModel ?? 'null'} upstreamModel=${ + upstreamModel ?? 'null' + } effort=${effort} path=${requestPath}` + ); + } else { + this.log(`[disabled] model=${originalModel ?? 'null'} -> passthrough (no reasoning)`); + } - this.record(originalModel, upstreamModel, effort, requestPath); - this.trace( - `[${new Date().toISOString()}] model=${originalModel ?? 'null'} upstreamModel=${ - upstreamModel ?? 'null' - } effort=${effort} path=${requestPath}` - ); + if (rememberedFallback && rememberedFallback !== requestedUpstreamModel) { + this.log(`Using remembered fallback ${requestedUpstreamModel} -> ${rememberedFallback}`); + } - await this.forwardJson(req, res, fullUpstreamUrl, rewritten); + await this.forwardJson(req, res, fullUpstreamUrl, rewritten, { + requestPath, + requestedModel: requestedUpstreamModel, + attemptedUpstreamModel: upstreamModel, + effort, + retryCount: 0, + }); } catch (error) { const err = error as Error; if (!res.headersSent) { @@ -487,8 +543,9 @@ export class CodexReasoningProxy { originalReq: http.IncomingMessage, clientRes: http.ServerResponse, upstreamUrl: URL, - body: unknown - ): Promise { + body: unknown, + context: ForwardJsonContext + ): Promise { return new Promise((resolve, reject) => { const bodyString = JSON.stringify(body); const requestFn = this.getRequestFn(upstreamUrl); @@ -503,9 +560,73 @@ export class CodexReasoningProxy { headers: this.buildForwardHeaders(originalReq.headers, bodyString), }, (upstreamRes) => { - clientRes.writeHead(upstreamRes.statusCode || 200, upstreamRes.headers); - upstreamRes.pipe(clientRes); - upstreamRes.on('end', () => resolve()); + const statusCode = upstreamRes.statusCode || 200; + if (statusCode >= 200 && statusCode < 300) { + clientRes.writeHead(statusCode, upstreamRes.headers); + upstreamRes.pipe(clientRes); + upstreamRes.on('end', () => resolve(statusCode)); + upstreamRes.on('error', reject); + return; + } + + const chunks: Buffer[] = []; + upstreamRes.on('data', (chunk: Buffer) => chunks.push(chunk)); + upstreamRes.on('end', async () => { + try { + const responseBody = Buffer.concat(chunks).toString('utf8'); + const unsupportedError = + context.retryCount === 0 + ? parseCodexUnsupportedModelError(statusCode, responseBody) + : null; + const fallbackModel = + unsupportedError && context.requestedModel + ? resolveRuntimeCodexFallbackModel({ + requestedModel: context.requestedModel, + modelMap: this.config.modelMap, + excludeModels: context.attemptedUpstreamModel + ? [context.attemptedUpstreamModel] + : undefined, + }) + : null; + + if (unsupportedError && fallbackModel && context.requestedModel) { + const retryEffort = + !this.config.disableEffort && context.effort + ? capEffortAtModelMax(fallbackModel, context.effort) + : null; + const retryBody = this.buildForwardBody(body, fallbackModel, retryEffort); + + this.log( + `Upstream rejected model "${context.attemptedUpstreamModel}". Retrying ${context.requestPath} with "${fallbackModel}".` + ); + + const retryStatusCode = await this.forwardJson( + originalReq, + clientRes, + upstreamUrl, + retryBody, + { + ...context, + attemptedUpstreamModel: fallbackModel, + effort: retryEffort, + retryCount: context.retryCount + 1, + } + ); + + if (retryStatusCode >= 200 && retryStatusCode < 300) { + this.rememberFallback(context.requestedModel, fallbackModel); + } + + resolve(retryStatusCode); + return; + } + + this.sendBufferedResponse(clientRes, statusCode, upstreamRes.headers, responseBody); + resolve(statusCode); + } catch (error) { + reject(error); + } + }); upstreamRes.on('error', reject); } ); diff --git a/src/cliproxy/composite-validator.ts b/src/cliproxy/composite-validator.ts index 52b24f3b..e2e24a53 100644 --- a/src/cliproxy/composite-validator.ts +++ b/src/cliproxy/composite-validator.ts @@ -3,7 +3,8 @@ * Used by API routes, service layer, and config loader to avoid contract drift. */ -import { CLIPROXY_SUPPORTED_PROVIDERS, CompositeTierConfig } from '../config/unified-config-types'; +import { CLIPROXY_SUPPORTED_PROVIDERS } from '../config/unified-config-types'; +import type { CompositeTierConfig } from '../config/unified-config-types'; import type { CLIProxyProvider } from './types'; import { getDeniedModelIdReasonForProvider } from './model-id-normalizer'; diff --git a/src/cliproxy/config/env-builder.ts b/src/cliproxy/config/env-builder.ts index 3a985f6b..dcace13b 100644 --- a/src/cliproxy/config/env-builder.ts +++ b/src/cliproxy/config/env-builder.ts @@ -5,13 +5,13 @@ import * as fs from 'fs'; import * as path from 'path'; -import { CLIProxyProvider, ProviderModelMapping } from '../types'; +import type { CLIProxyProvider, ProviderModelMapping } from '../types'; import { getModelMappingFromConfig, getEnvVarsFromConfig } from '../base-config-loader'; import { getGlobalEnvConfig } from '../../config/unified-config-loader'; import { getEffectiveApiKey } from '../auth-token-manager'; import { expandPath } from '../../utils/helpers'; import { warn } from '../../utils/ui'; -import { CompositeTierConfig } from '../../config/unified-config-types'; +import type { CompositeTierConfig } from '../../config/unified-config-types'; import { validatePort, validateRemotePort, diff --git a/src/cliproxy/config/extended-context-config.ts b/src/cliproxy/config/extended-context-config.ts index 60616e09..38727e7f 100644 --- a/src/cliproxy/config/extended-context-config.ts +++ b/src/cliproxy/config/extended-context-config.ts @@ -9,7 +9,7 @@ * - Claude (Anthropic): Opt-in via --1m flag */ -import { CLIProxyProvider } from '../types'; +import type { CLIProxyProvider } from '../types'; import { supportsExtendedContext } from '../model-catalog'; import { warn } from '../../utils/ui'; import { diff --git a/src/cliproxy/config/generator.ts b/src/cliproxy/config/generator.ts index e10a7671..dd4e65a7 100644 --- a/src/cliproxy/config/generator.ts +++ b/src/cliproxy/config/generator.ts @@ -5,7 +5,7 @@ import * as fs from 'fs'; import * as path from 'path'; -import { CLIProxyProvider, ProviderConfig } from '../types'; +import type { CLIProxyProvider, ProviderConfig } from '../types'; import { getProviderDisplayName } from '../provider-capabilities'; import { getModelMappingFromConfig } from '../base-config-loader'; import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; diff --git a/src/cliproxy/config/path-resolver.ts b/src/cliproxy/config/path-resolver.ts index 70615d35..21746fc7 100644 --- a/src/cliproxy/config/path-resolver.ts +++ b/src/cliproxy/config/path-resolver.ts @@ -5,7 +5,7 @@ import * as path from 'path'; import { getCcsDir } from '../../utils/config-manager'; -import { CLIProxyProvider } from '../types'; +import type { CLIProxyProvider } from '../types'; import { CLIPROXY_DEFAULT_PORT } from './port-manager'; /** diff --git a/src/cliproxy/config/thinking-config.ts b/src/cliproxy/config/thinking-config.ts index 1162b561..3988ccf7 100644 --- a/src/cliproxy/config/thinking-config.ts +++ b/src/cliproxy/config/thinking-config.ts @@ -3,8 +3,9 @@ * Manages thinking budget suffixes for CLIProxyAPIPlus */ -import { CLIProxyProvider } from '../types'; -import { ThinkingConfig, DEFAULT_THINKING_TIER_DEFAULTS } from '../../config/unified-config-types'; +import type { CLIProxyProvider } from '../types'; +import { DEFAULT_THINKING_TIER_DEFAULTS } from '../../config/unified-config-types'; +import type { ThinkingConfig } from '../../config/unified-config-types'; import { getThinkingConfig } from '../../config/unified-config-loader'; import { supportsThinking } from '../model-catalog'; import { isThinkingOffValue, validateThinking } from '../thinking-validator'; diff --git a/src/cliproxy/index.ts b/src/cliproxy/index.ts index 375a2b34..aef15b13 100644 --- a/src/cliproxy/index.ts +++ b/src/cliproxy/index.ts @@ -143,6 +143,23 @@ export { TOGETHER_TEMPLATE, } from './openai-compat-manager'; +// AI provider management +export type { + AiProviderFamilyId, + AiProviderFamilyState, + AiProviderEntryView, + ListAiProvidersResult, + UpsertAiProviderEntryInput, +} from './ai-providers'; +export { + AI_PROVIDER_FAMILY_DEFINITIONS, + AI_PROVIDER_FAMILY_IDS, + listAiProviders, + createAiProviderEntry, + updateAiProviderEntry, + deleteAiProviderEntry, +} from './ai-providers'; + // Service manager (background CLIProxy for dashboard) export type { ServiceStartResult } from './service-manager'; export { ensureCliproxyService, stopCliproxyService, getServiceStatus } from './service-manager'; diff --git a/src/cliproxy/management-api-client.ts b/src/cliproxy/management-api-client.ts index f33edd21..7afff06f 100644 --- a/src/cliproxy/management-api-client.ts +++ b/src/cliproxy/management-api-client.ts @@ -11,7 +11,6 @@ import type { ManagementHealthStatus, ManagementApiErrorCode, ClaudeKey, - GetClaudeKeysResponse, ClaudeKeyPatch, RemoteModelInfo, GetModelDefinitionsResponse, @@ -189,11 +188,7 @@ export class ManagementApiClient { * Get all claude-api-key entries from remote CLIProxy. */ async getClaudeKeys(): Promise { - const response = await this.request( - 'GET', - '/v0/management/claude-api-key' - ); - return response.data?.['claude-api-key'] ?? []; + return this.getSection('claude-api-key'); } /** @@ -201,7 +196,7 @@ export class ManagementApiClient { * This is an atomic operation - all entries are replaced at once. */ async putClaudeKeys(keys: ClaudeKey[]): Promise { - await this.request('PUT', '/v0/management/claude-api-key', keys); + await this.putSection('claude-api-key', keys); } /** @@ -232,6 +227,22 @@ export class ManagementApiClient { return response.data?.models ?? []; } + /** + * Get a management section from CLIProxyAPI. + * Example sections: claude-api-key, gemini-api-key, codex-api-key. + */ + async getSection(section: string): Promise { + const response = await this.request>('GET', `/v0/management/${section}`); + return response.data?.[section] ?? []; + } + + /** + * Replace an entire management section on CLIProxyAPI. + */ + async putSection(section: string, entries: T[]): Promise { + await this.request('PUT', `/v0/management/${section}`, entries); + } + /** * Make an HTTP request to the Management API. */ diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index d9644ed5..a9d7970e 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -5,7 +5,7 @@ * Models are mapped to their internal names used by the proxy backend. */ -import { CLIProxyProvider } from './types'; +import type { CLIProxyProvider } from './types'; import { isAntigravityProvider, migrateDeniedAntigravityModelAliases, diff --git a/src/cliproxy/model-id-normalizer.ts b/src/cliproxy/model-id-normalizer.ts index d291def2..99761bfa 100644 --- a/src/cliproxy/model-id-normalizer.ts +++ b/src/cliproxy/model-id-normalizer.ts @@ -5,7 +5,7 @@ * model version formats (e.g., 4.6 vs 4-6). */ -import { CLIProxyProvider } from './types'; +import type { CLIProxyProvider } from './types'; /** Env vars that carry model identifiers. */ export const MODEL_ENV_VAR_KEYS = [ diff --git a/src/cliproxy/thinking-validator.ts b/src/cliproxy/thinking-validator.ts index e749ecb7..5c58994f 100644 --- a/src/cliproxy/thinking-validator.ts +++ b/src/cliproxy/thinking-validator.ts @@ -7,8 +7,9 @@ * - Warns when model doesn't support thinking */ -import { getModelThinkingSupport, ThinkingSupport } from './model-catalog'; -import { CLIProxyProvider } from './types'; +import { getModelThinkingSupport } from './model-catalog'; +import type { ThinkingSupport } from './model-catalog'; +import type { CLIProxyProvider } from './types'; /** * Thinking budget bounds (used for validation across API and CLI) diff --git a/src/cliproxy/types.ts b/src/cliproxy/types.ts index f3b246de..2cb7b292 100644 --- a/src/cliproxy/types.ts +++ b/src/cliproxy/types.ts @@ -162,11 +162,33 @@ export interface CLIProxyConfig { 'api-key': string; 'base-url'?: string; }>; + 'claude-api-key'?: Array<{ + 'api-key': string; + 'base-url'?: string; + 'proxy-url'?: string; + prefix?: string; + headers?: Record; + 'excluded-models'?: string[]; + models?: Array<{ + name: string; + alias: string; + }>; + }>; + 'vertex-api-key'?: Array<{ + 'api-key': string; + 'base-url'?: string; + }>; 'openai-compatibility'?: Array<{ name: string; 'base-url': string; + headers?: Record; 'api-key-entries': Array<{ 'api-key': string; + 'proxy-url'?: string; + }>; + models?: Array<{ + name: string; + alias: string; }>; }>; } diff --git a/src/commands/api-command/create-command.ts b/src/commands/api-command/create-command.ts index 5941e9d1..0bcce273 100644 --- a/src/commands/api-command/create-command.ts +++ b/src/commands/api-command/create-command.ts @@ -1,6 +1,8 @@ import { apiProfileExists, + createCliproxyBridgeProfile, createApiProfile, + getDefaultCliproxyBridgeName, getPresetById, getPresetIds, getUrlWarning, @@ -8,11 +10,17 @@ import { isUsingUnifiedConfig, pickOpenRouterModel, sanitizeBaseUrl, + suggestCliproxyBridgeName, validateApiName, validateUrl, type ModelMapping, type ProviderPreset, } from '../../api/services'; +import { + CLIPROXY_PROVIDER_IDS, + getProviderDisplayName, + isCLIProxyProvider, +} from '../../cliproxy/provider-capabilities'; 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'; @@ -66,6 +74,34 @@ async function resolveProfileName( return name; } +async function resolveCliproxyProfileName( + provider: string, + providedName: string | undefined, + yes: boolean | undefined +): Promise { + if (providedName) { + const error = validateApiName(providedName); + if (error) { + console.log(fail(error)); + process.exit(1); + } + return providedName; + } + + const suggestedName = isCLIProxyProvider(provider) + ? suggestCliproxyBridgeName(provider) + : getDefaultCliproxyBridgeName('gemini'); + + if (yes) { + return suggestedName; + } + + return InteractivePrompt.input('API name', { + default: suggestedName, + validate: validateApiName, + }); +} + async function resolveBaseUrl( providedBaseUrl: string | undefined, preset: ProviderPreset | null @@ -248,6 +284,101 @@ export async function handleApiCreateCommand(args: string[]): Promise { console.log(header('Create API Profile')); console.log(''); + if (parsedArgs.cliproxyProvider) { + const cliproxyProvider = parsedArgs.cliproxyProvider.trim().toLowerCase(); + if (!isCLIProxyProvider(cliproxyProvider)) { + console.log(fail(`Unknown CLIProxy provider: ${cliproxyProvider}`)); + console.log(''); + console.log(`Available providers: ${CLIPROXY_PROVIDER_IDS.join(', ')}`); + process.exit(1); + } + + const incompatibleFlags = [ + parsedArgs.baseUrl && '--base-url', + parsedArgs.apiKey && '--api-key', + parsedArgs.model && '--model', + parsedArgs.preset && '--preset', + ].filter(Boolean); + + if (incompatibleFlags.length > 0) { + console.log( + fail(`--cliproxy-provider cannot be combined with ${incompatibleFlags.join(', ')}`) + ); + process.exit(1); + } + + const name = await resolveCliproxyProfileName( + cliproxyProvider, + parsedArgs.name, + parsedArgs.yes + ); + const target = await resolveDefaultTarget(parsedArgs.target, parsedArgs.yes); + + if (name && apiProfileExists(name) && !parsedArgs.force) { + console.log(fail(`API '${name}' already exists`)); + console.log(` Use ${color('--force', 'command')} to overwrite`); + process.exit(1); + } + + console.log( + info( + `Using CLIProxy provider: ${getProviderDisplayName(cliproxyProvider)} (${cliproxyProvider})` + ) + ); + console.log( + dim(' CCS will create a routed API profile. Provider credentials stay managed by CLIProxy.') + ); + console.log(''); + console.log(info('Creating API profile...')); + + const result = createCliproxyBridgeProfile(cliproxyProvider, { + force: parsedArgs.force === true, + name, + target, + }); + if (!result.success || !result.name || !result.cliproxyBridge) { + console.log(fail(`Failed to create CLIProxy bridge 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 details = + `API: ${result.name}\n` + + `Config: ${isUsingUnifiedConfig() ? '~/.ccs/config.yaml' : '~/.ccs/config.json'}\n` + + `Settings: ${result.settingsFile}\n` + + `Provider: ${result.cliproxyBridge.providerDisplayName}\n` + + `Route: ${result.cliproxyBridge.routePath}\n` + + `Proxy: ${result.cliproxyBridge.currentBaseUrl}\n` + + `Target: ${target}`; + + console.log(''); + console.log(infoBox(details, 'CLIProxy Bridge Created')); + console.log(''); + console.log(header('Usage')); + if (target === 'droid') { + console.log( + ` ${color(`ccs ${result.name} "your prompt"`, 'command')} ${dim('# uses droid by default')}` + ); + console.log( + ` ${color(`ccsd ${result.name} "your prompt"`, 'command')} ${dim('# explicit droid alias')}` + ); + } else { + console.log(` ${color(`ccs ${result.name} "your prompt"`, 'command')}`); + console.log( + ` ${color(`ccs ${result.name} --target droid "your prompt"`, 'command')} ${dim('# optional target override')}` + ); + } + console.log(''); + console.log(dim('Manage provider accounts, keys, and models in: ccs cliproxy')); + return; + } + showPresetDeprecationNotice(parsedArgs.preset); const preset = resolvePresetOrExit(parsedArgs.preset); const name = await resolveProfileName(parsedArgs.name, preset); diff --git a/src/commands/api-command/help.ts b/src/commands/api-command/help.ts index e9c5f26a..c9b3fb99 100644 --- a/src/commands/api-command/help.ts +++ b/src/commands/api-command/help.ts @@ -1,5 +1,6 @@ import { PROVIDER_PRESETS, + listCliproxyBridgeProviders, getPresetAliases, getPresetIds, type ProviderPreset, @@ -20,6 +21,7 @@ export async function showApiCommandHelp(): Promise { const presetIds = getPresetIds() .map((id) => sanitizeHelpText(id)) .filter(Boolean); + const cliproxyProviderIds = listCliproxyBridgeProviders().map((provider) => provider.provider); const presetAliases = getPresetAliases(); const presetIdWidth = Math.max(0, ...presetIds.map((id) => id.length)) + 2; @@ -47,6 +49,9 @@ export async function showApiCommandHelp(): Promise { console.log( ` ${color('--preset ', 'command')} Use provider preset (${presetIds.join(', ')})` ); + console.log( + ` ${color('--cliproxy-provider ', 'command')} Use routed CLIProxy provider (${cliproxyProviderIds.join(', ')})` + ); console.log(` ${color('--base-url ', 'command')} API base URL (create)`); console.log(` ${color('--api-key ', 'command')} API key (create)`); console.log(` ${color('--model ', 'command')} Default model (create)`); @@ -84,6 +89,12 @@ export async function showApiCommandHelp(): Promise { 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 routed profile from existing CLIProxy provider config')}`); + console.log(` ${color('ccs api create --cliproxy-provider gemini', 'command')}`); + console.log( + ` ${color('ccs api create gemini-droid --cliproxy-provider gemini --target droid', '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')}`); diff --git a/src/commands/api-command/shared.ts b/src/commands/api-command/shared.ts index d01a3107..de42e70b 100644 --- a/src/commands/api-command/shared.ts +++ b/src/commands/api-command/shared.ts @@ -9,6 +9,7 @@ export interface ApiCommandArgs { apiKey?: string; model?: string; preset?: string; + cliproxyProvider?: string; target?: TargetType; force?: boolean; yes?: boolean; @@ -21,6 +22,7 @@ export const API_VALUE_FLAGS = [ '--api-key', '--model', '--preset', + '--cliproxy-provider', '--target', ] as const; export const API_KNOWN_FLAGS: readonly string[] = [...API_BOOLEAN_FLAGS, ...API_VALUE_FLAGS]; @@ -210,6 +212,18 @@ export function parseApiCommandArgs( false ); + remaining = applyRepeatedOption( + remaining, + ['--cliproxy-provider'], + (value) => { + result.cliproxyProvider = value.trim().toLowerCase(); + }, + () => { + result.errors.push('Missing value for --cliproxy-provider'); + }, + false + ); + remaining = applyRepeatedOption( remaining, ['--target'], diff --git a/src/commands/config-dashboard-host.ts b/src/commands/config-dashboard-host.ts index e4dd8703..7a29242c 100644 --- a/src/commands/config-dashboard-host.ts +++ b/src/commands/config-dashboard-host.ts @@ -1,7 +1,7 @@ import * as os from 'os'; -const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']); -const WILDCARD_HOSTS = new Set(['0.0.0.0', '::', '[::]']); +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1']); +const WILDCARD_HOSTS = new Set(['0.0.0.0', '::']); interface NetworkInterfaceCandidate { address: string; diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 97e2af09..41d365aa 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -141,6 +141,10 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['ccs ollama-cloud', 'Ollama Cloud (API key required)'], ['', ''], // Spacer ['ccs api create --preset anthropic', 'Anthropic direct API key (sk-ant-...)'], + [ + 'ccs api create --cliproxy-provider gemini', + 'Create routed API profile from CLIProxy Gemini', + ], ['ccs api create', 'Create custom API profile'], ['ccs api discover --register', 'Discover/register orphan settings files'], ['ccs api copy ', 'Duplicate API profile'], diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 8bcc9445..19bd3266 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -10,7 +10,6 @@ import * as path from 'path'; import * as yaml from 'js-yaml'; import { getCcsDir } from '../utils/config-manager'; import { - UnifiedConfig, isUnifiedConfig, createEmptyUnifiedConfig, UNIFIED_CONFIG_VERSION, @@ -23,6 +22,9 @@ import { DEFAULT_THINKING_CONFIG, DEFAULT_DASHBOARD_AUTH_CONFIG, DEFAULT_IMAGE_ANALYSIS_CONFIG, +} from './unified-config-types'; +import type { + UnifiedConfig, CLIProxySafetyConfig, GlobalEnvConfig, ThinkingConfig, diff --git a/src/management/instance-manager.ts b/src/management/instance-manager.ts index 7cdbdc97..576bc828 100644 --- a/src/management/instance-manager.ts +++ b/src/management/instance-manager.ts @@ -26,11 +26,13 @@ class InstanceManager { private readonly instancesDir: string; private readonly sharedManager: SharedManager; private readonly contextSyncLock: ProfileContextSyncLock; + private readonly pluginLayoutLock: ProfileContextSyncLock; constructor() { this.instancesDir = path.join(getCcsDir(), 'instances'); this.sharedManager = new SharedManager(); this.contextSyncLock = new ProfileContextSyncLock(this.instancesDir); + this.pluginLayoutLock = new ProfileContextSyncLock(this.instancesDir); } /** @@ -56,9 +58,17 @@ class InstanceManager { // Apply context policy (isolated by default, optional shared group). await this.sharedManager.syncProjectContext(instancePath, contextPolicy); await this.sharedManager.syncAdvancedContinuityArtifacts(instancePath, contextPolicy); - }); - this.sharedManager.normalizeSharedPluginMetadataPaths(instancePath); + await this.pluginLayoutLock.withNamedLock('__plugin-layout__', async () => { + if (!options.bare) { + this.sharedManager.linkSharedDirectories(instancePath); + return; + } + + this.sharedManager.detachSharedDirectories(instancePath); + this.sharedManager.normalizeSharedPluginMetadataPaths(); + }); + }); // Sync MCP servers from global ~/.claude.json (unless bare) if (!options.bare) { @@ -82,7 +92,7 @@ class InstanceManager { private initializeInstance( profileName: string, instancePath: string, - options: InstanceOptions = {} + _options: InstanceOptions = {} ): void { try { // Create base directory @@ -106,10 +116,7 @@ class InstanceManager { } }); - // Bare profiles skip shared symlinks (commands, skills, agents, settings.json) - if (!options.bare) { - this.sharedManager.linkSharedDirectories(instancePath); - } + // Shared links are created during ensureInstance() under the plugin layout lock. } catch (error) { throw new Error( `Failed to initialize instance for ${profileName}: ${(error as Error).message}` @@ -146,15 +153,22 @@ class InstanceManager { /** * Delete instance for profile */ - deleteInstance(profileName: string): void { + async deleteInstance(profileName: string): Promise { const instancePath = this.getInstancePath(profileName); if (!fs.existsSync(instancePath)) { return; } - // Recursive delete - fs.rmSync(instancePath, { recursive: true, force: true }); + await this.contextSyncLock.withLock(profileName, async () => { + await this.pluginLayoutLock.withNamedLock('__plugin-layout__', async () => { + if (!fs.existsSync(instancePath)) { + return; + } + + fs.rmSync(instancePath, { recursive: true, force: true }); + }); + }); } /** @@ -166,6 +180,10 @@ class InstanceManager { } return fs.readdirSync(this.instancesDir).filter((name) => { + if (name.startsWith('.')) { + return false; + } + const instancePath = path.join(this.instancesDir, name); return fs.statSync(instancePath).isDirectory(); }); diff --git a/src/management/profile-context-sync-lock.ts b/src/management/profile-context-sync-lock.ts index c8158bc3..01727405 100644 --- a/src/management/profile-context-sync-lock.ts +++ b/src/management/profile-context-sync-lock.ts @@ -106,8 +106,8 @@ class ProfileContextSyncLock { return this.tryRemoveLockIfUnchanged(lockPath, snapshot.raw); } - async withLock(profileName: string, callback: () => Promise): Promise { - const lockPath = this.getLockPath(profileName); + async withNamedLock(lockName: string, callback: () => Promise): Promise { + const lockPath = this.getLockPath(lockName); const retryDelayMs = 50; const staleLockMs = 30000; const timeoutMs = staleLockMs + 5000; @@ -159,7 +159,7 @@ class ProfileContextSyncLock { } if (Date.now() - start > timeoutMs) { - throw new Error(`Timed out waiting for profile context lock: ${profileName}`); + throw new Error(`Timed out waiting for profile context lock: ${lockName}`); } await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); @@ -172,6 +172,85 @@ class ProfileContextSyncLock { this.tryRemoveLockIfUnchanged(lockPath, ownerPayloadRaw); } } + + async withLock(profileName: string, callback: () => Promise): Promise { + return this.withNamedLock(profileName, callback); + } + + withNamedLockSync(lockName: string, callback: () => T): T { + const lockPath = this.getLockPath(lockName); + const retryDelayMs = 50; + const staleLockMs = 30000; + const timeoutMs = staleLockMs + 5000; + const start = Date.now(); + const ownerPayload: ContextSyncLockPayload = { + version: 1, + pid: process.pid, + nonce: createHash('sha1') + .update(`${process.pid}:${Date.now()}:${Math.random()}`) + .digest('hex') + .slice(0, 16), + acquiredAtMs: Date.now(), + }; + const ownerPayloadRaw = JSON.stringify(ownerPayload); + + fs.mkdirSync(this.locksDir, { recursive: true, mode: 0o700 }); + + while (true) { + try { + const fd = fs.openSync(lockPath, 'wx', 0o600); + fs.writeFileSync(fd, ownerPayloadRaw, 'utf8'); + fs.closeSync(fd); + break; + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code !== 'EEXIST') { + throw error; + } + + const lockSnapshot = this.readContextSyncLockSnapshot(lockPath); + if (lockSnapshot) { + if (this.tryRemoveDeadOwnerLock(lockPath, lockSnapshot)) { + continue; + } + + if (!lockSnapshot.owner) { + try { + const lockStats = fs.statSync(lockPath); + if (Date.now() - lockStats.mtimeMs > staleLockMs) { + if (this.tryRemoveLockIfUnchanged(lockPath, lockSnapshot.raw)) { + continue; + } + } + } catch { + // Best-effort stale lock cleanup. + } + } + } + + if (Date.now() - start > timeoutMs) { + throw new Error(`Timed out waiting for profile context lock: ${lockName}`); + } + + const until = Date.now() + retryDelayMs; + while (Date.now() < until) { + // Sync callers need a synchronous retry path here. + // This lock only guards short local filesystem normalization work, so + // contention should be brief and limited to profile/bootstrap edges. + } + } + } + + try { + return callback(); + } finally { + this.tryRemoveLockIfUnchanged(lockPath, ownerPayloadRaw); + } + } + + withLockSync(profileName: string, callback: () => T): T { + return this.withNamedLockSync(profileName, callback); + } } export default ProfileContextSyncLock; diff --git a/src/management/shared-manager.ts b/src/management/shared-manager.ts index cc617d3b..b4054c22 100644 --- a/src/management/shared-manager.ts +++ b/src/management/shared-manager.ts @@ -9,6 +9,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +import ProfileContextSyncLock from './profile-context-sync-lock'; import { ok, info, warn } from '../utils/ui'; import { AccountContextPolicy, DEFAULT_ACCOUNT_CONTEXT_GROUP } from '../auth/account-context'; import { getCcsDir } from '../utils/config-manager'; @@ -18,23 +19,64 @@ interface SharedItem { type: 'directory' | 'file'; } -export function normalizePluginMetadataPathString(input: string): string { - return input.replace( - /([\\/])\.ccs\1instances\1[^\\/]+\1/g, - (_match, separator: string) => `${separator}.claude${separator}` +const DEFAULT_INSTALLED_PLUGIN_REGISTRY = JSON.stringify( + { + version: 2, + plugins: {}, + }, + null, + 2 +); + +function getPluginPathModule( + targetConfigDir: string, + input: string +): typeof path.posix | typeof path.win32 { + return targetConfigDir.includes('\\') || input.includes('\\') ? path.win32 : path.posix; +} + +function normalizeTargetConfigDir(targetConfigDir: string, input: string): string { + const pathModule = getPluginPathModule(targetConfigDir, input); + return pathModule.normalize( + pathModule === path.win32 + ? targetConfigDir.replace(/\//g, '\\') + : targetConfigDir.replace(/\\/g, '/') ); } -function normalizePluginMetadataValue(value: unknown): { normalized: unknown; changed: boolean } { +export function normalizePluginMetadataPathString( + input: string, + targetConfigDir = path.join(os.homedir(), '.claude') +): string { + const match = input.match( + /^(.*?)([\\/])(?:\.claude|\.ccs\2shared|\.ccs\2instances\2[^\\/]+)\2plugins(?:(\2.*))?$/ + ); + + if (!match) { + return input; + } + + const pathModule = getPluginPathModule(targetConfigDir, input); + const normalizedTargetConfigDir = normalizeTargetConfigDir(targetConfigDir, input); + const suffix = match[3] ?? ''; + const suffixSegments = suffix.split(/[\\/]+/).filter(Boolean); + + return pathModule.join(normalizedTargetConfigDir, 'plugins', ...suffixSegments); +} + +function normalizePluginMetadataValue( + value: unknown, + targetConfigDir: string +): { normalized: unknown; changed: boolean } { if (typeof value === 'string') { - const normalized = normalizePluginMetadataPathString(value); + const normalized = normalizePluginMetadataPathString(value, targetConfigDir); return { normalized, changed: normalized !== value }; } if (Array.isArray(value)) { let changed = false; const normalized = value.map((item) => { - const result = normalizePluginMetadataValue(item); + const result = normalizePluginMetadataValue(item, targetConfigDir); changed = changed || result.changed; return result.normalized; }); @@ -45,7 +87,7 @@ function normalizePluginMetadataValue(value: unknown): { normalized: unknown; ch let changed = false; const normalized = Object.fromEntries( Object.entries(value as Record).map(([key, item]) => { - const result = normalizePluginMetadataValue(item); + const result = normalizePluginMetadataValue(item, targetConfigDir); changed = changed || result.changed; return [key, result.normalized]; }) @@ -56,9 +98,12 @@ function normalizePluginMetadataValue(value: unknown): { normalized: unknown; ch return { normalized: value, changed: false }; } -export function normalizePluginMetadataContent(original: string): string { +export function normalizePluginMetadataContent( + original: string, + targetConfigDir = path.join(os.homedir(), '.claude') +): string { const parsed = JSON.parse(original) as unknown; - const result = normalizePluginMetadataValue(parsed); + const result = normalizePluginMetadataValue(parsed, targetConfigDir); return result.changed ? JSON.stringify(result.normalized, null, 2) : original; } @@ -70,7 +115,14 @@ class SharedManager { private readonly sharedDir: string; private readonly claudeDir: string; private readonly instancesDir: string; + private readonly pluginLayoutLock: ProfileContextSyncLock; private readonly sharedItems: SharedItem[]; + private readonly sharedPluginEntries: readonly SharedItem[] = [ + { name: 'cache', type: 'directory' }, + { name: 'marketplaces', type: 'directory' }, + { name: 'installed_plugins.json', type: 'file' }, + ]; + private readonly instanceLocalPluginMetadataFiles = new Set(['known_marketplaces.json']); private readonly advancedContinuityItems: readonly string[] = [ 'session-env', 'file-history', @@ -84,6 +136,7 @@ class SharedManager { this.sharedDir = path.join(ccsDir, 'shared'); this.claudeDir = path.join(this.homeDir, '.claude'); this.instancesDir = path.join(ccsDir, 'instances'); + this.pluginLayoutLock = new ProfileContextSyncLock(this.instancesDir); this.sharedItems = [ { name: 'commands', type: 'directory' }, { name: 'skills', type: 'directory' }, @@ -148,6 +201,8 @@ class SharedManager { fs.mkdirSync(this.sharedDir, { recursive: true, mode: 0o700 }); } + this.ensureSharedPluginLayoutDefaults(); + // Create symlinks ~/.ccs/shared/* → ~/.claude/* for (const item of this.sharedItems) { const claudePath = path.join(this.claudeDir, item.name); @@ -221,17 +276,15 @@ class SharedManager { this.ensureSharedDirectories(); for (const item of this.sharedItems) { + if (item.name === 'plugins') { + this.linkInstancePlugins(instancePath); + continue; + } + const linkPath = path.join(instancePath, item.name); const targetPath = path.join(this.sharedDir, item.name); - // Remove existing file/directory/link - if (fs.existsSync(linkPath)) { - if (item.type === 'directory') { - fs.rmSync(linkPath, { recursive: true, force: true }); - } else { - fs.unlinkSync(linkPath); - } - } + this.removeExistingPath(linkPath, item.type); // Create symlink try { @@ -257,6 +310,151 @@ class SharedManager { this.normalizeSharedPluginMetadataPaths(instancePath); } + detachSharedDirectories(instancePath: string): void { + this.ensureSharedDirectories(); + + for (const item of this.sharedItems) { + const managedPath = path.join(instancePath, item.name); + if (!fs.existsSync(managedPath)) { + continue; + } + + if (item.name === 'plugins') { + this.detachManagedPluginLayout(instancePath); + continue; + } + + const stats = fs.lstatSync(managedPath); + if (!stats.isSymbolicLink()) { + continue; + } + + if (this.symlinkPointsTo(managedPath, path.join(this.sharedDir, item.name))) { + this.removeExistingPath(managedPath, item.type); + } + } + } + + private ensureSharedPluginLayoutDefaults(): void { + const pluginsDir = path.join(this.claudeDir, 'plugins'); + fs.mkdirSync(pluginsDir, { recursive: true, mode: 0o700 }); + + for (const entry of this.sharedPluginEntries) { + const entryPath = path.join(pluginsDir, entry.name); + if (fs.existsSync(entryPath)) { + continue; + } + + if (entry.type === 'directory') { + fs.mkdirSync(entryPath, { recursive: true, mode: 0o700 }); + continue; + } + + fs.writeFileSync(entryPath, DEFAULT_INSTALLED_PLUGIN_REGISTRY, 'utf8'); + } + + const marketplaceRegistryPath = path.join(pluginsDir, 'known_marketplaces.json'); + if (!fs.existsSync(marketplaceRegistryPath)) { + fs.writeFileSync(marketplaceRegistryPath, JSON.stringify({}, null, 2), 'utf8'); + } + } + + private linkInstancePlugins(instancePath: string): void { + const linkPath = path.join(instancePath, 'plugins'); + const targetPath = path.join(this.sharedDir, 'plugins'); + let linkStats: fs.Stats | null = null; + + try { + linkStats = fs.lstatSync(linkPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + throw err; + } + } + + if (linkStats?.isSymbolicLink() || (linkStats && !linkStats.isDirectory())) { + this.removeExistingPath(linkPath, linkStats.isDirectory() ? 'directory' : 'file'); + } + + if (!linkStats || !linkStats.isDirectory()) { + fs.mkdirSync(linkPath, { recursive: true, mode: 0o700 }); + } + + for (const item of this.getSharedPluginLinkItems()) { + const targetEntryPath = path.join(targetPath, item.name); + const linkEntryPath = path.join(linkPath, item.name); + + this.removeExistingPath(linkEntryPath, item.type); + + try { + const symlinkType = item.type === 'directory' ? 'dir' : 'file'; + fs.symlinkSync(targetEntryPath, linkEntryPath, symlinkType); + } catch (_err) { + if (process.platform === 'win32') { + if (item.type === 'directory') { + this.copyDirectoryFallback(targetEntryPath, linkEntryPath); + } else { + fs.copyFileSync(targetEntryPath, linkEntryPath); + } + console.log( + warn(`Symlink failed for plugins/${item.name}, copied instead (enable Developer Mode)`) + ); + } else { + throw _err; + } + } + } + } + + private getSharedPluginLinkItems(): SharedItem[] { + const sharedPluginsPath = path.join(this.sharedDir, 'plugins'); + const items = new Map( + this.sharedPluginEntries.map((entry) => [entry.name, { ...entry }]) + ); + + for (const entry of fs.readdirSync(sharedPluginsPath, { withFileTypes: true })) { + if (items.has(entry.name) || this.instanceLocalPluginMetadataFiles.has(entry.name)) { + continue; + } + + const entryPath = path.join(sharedPluginsPath, entry.name); + const stats = fs.statSync(entryPath); + items.set(entry.name, { + name: entry.name, + type: stats.isDirectory() ? 'directory' : 'file', + }); + } + + return [...items.values()]; + } + + private removeExistingPath(targetPath: string, typeHint: SharedItem['type']): void { + try { + const stats = fs.lstatSync(targetPath); + if (stats.isDirectory() && !stats.isSymbolicLink()) { + fs.rmSync(targetPath, { recursive: true, force: true }); + return; + } + + if (stats.isSymbolicLink() || typeHint === 'file') { + fs.unlinkSync(targetPath); + return; + } + + fs.rmSync(targetPath, { recursive: true, force: true }); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return; + } + + if (typeHint === 'directory') { + fs.rmSync(targetPath, { recursive: true, force: true }); + } else { + fs.rmSync(targetPath, { force: true }); + } + } + } + /** * Sync project workspace context based on account policy. * @@ -583,13 +781,19 @@ class SharedManager { } /** - * Normalize shared plugin metadata files to canonical ~/.claude/ paths. + * Normalize plugin metadata and reconcile marketplace metadata for the active config dir. */ normalizeSharedPluginMetadataPaths(configDir?: string): void { this.normalizePluginRegistryPaths(configDir); this.normalizeMarketplaceRegistryPaths(configDir); } + normalizeSharedPluginMetadataPathsLocked(configDir?: string): void { + this.pluginLayoutLock.withNamedLockSync('__plugin-layout__', () => { + this.normalizeSharedPluginMetadataPaths(configDir); + }); + } + /** * Normalize plugin registry paths to use canonical ~/.claude/ paths * instead of instance-specific ~/.ccs/instances// paths. @@ -607,19 +811,31 @@ class SharedManager { } /** - * Normalize marketplace registry paths to use canonical ~/.claude/ paths - * instead of instance-specific ~/.ccs/instances// paths. - * - * This ensures known_marketplaces.json is consistent regardless of - * which CCS instance added the marketplace. + * Reconcile marketplace registry content into the active config dir while + * keeping the global ~/.claude copy up to date for non-instance flows. */ normalizeMarketplaceRegistryPaths(configDir?: string): void { - this.normalizePluginMetadataFiles( - 'known_marketplaces.json', - configDir, - 'Normalized marketplace registry paths', - 'marketplace registry' - ); + const successMessage = 'Synchronized marketplace registry paths'; + const warningLabel = 'marketplace registry'; + + try { + const sourcePaths = this.getMarketplaceRegistrySourcePaths(configDir); + this.writePluginMetadataFile( + path.join(this.claudeDir, 'plugins', 'known_marketplaces.json'), + this.buildMarketplaceRegistryContent(sourcePaths, this.claudeDir), + successMessage + ); + + if (configDir && path.resolve(configDir) !== path.resolve(this.claudeDir)) { + this.writePluginMetadataFile( + path.join(configDir, 'plugins', 'known_marketplaces.json'), + this.buildMarketplaceRegistryContent(sourcePaths, configDir), + successMessage + ); + } + } catch (err) { + console.log(warn(`Could not synchronize ${warningLabel}: ${(err as Error).message}`)); + } } private normalizePluginMetadataFiles( @@ -676,6 +892,117 @@ class SharedManager { } } + private getMarketplaceRegistrySourcePaths(configDir?: string): string[] { + const sourcePaths = new Set([ + path.join(this.claudeDir, 'plugins', 'known_marketplaces.json'), + ]); + + if (fs.existsSync(this.instancesDir)) { + for (const entry of fs.readdirSync(this.instancesDir, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.name.startsWith('.')) { + continue; + } + + sourcePaths.add( + path.join(this.instancesDir, entry.name, 'plugins', 'known_marketplaces.json') + ); + } + } + + if (configDir && path.resolve(configDir) !== path.resolve(this.claudeDir)) { + sourcePaths.add(path.join(configDir, 'plugins', 'known_marketplaces.json')); + } + + return [...sourcePaths]; + } + + private buildMarketplaceRegistryContent(sourcePaths: string[], targetConfigDir: string): string { + const merged: Record = {}; + + for (const registryPath of sourcePaths) { + if (!fs.existsSync(registryPath)) { + continue; + } + + try { + const parsed = JSON.parse(fs.readFileSync(registryPath, 'utf8')) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + continue; + } + + for (const [name, value] of Object.entries(parsed as Record)) { + merged[name] = normalizePluginMetadataValue(value, targetConfigDir).normalized; + } + } catch (err) { + console.log( + warn(`Skipping malformed marketplace registry ${registryPath}: ${(err as Error).message}`) + ); + } + } + + const discoveredEntries = this.discoverMarketplaceEntries(targetConfigDir); + + for (const [name, value] of Object.entries(discoveredEntries)) { + const existing = merged[name]; + if (existing && typeof existing === 'object' && !Array.isArray(existing)) { + merged[name] = { + ...(existing as Record), + installLocation: value.installLocation, + }; + continue; + } + + merged[name] = value; + } + + for (const name of Object.keys(merged)) { + if (!(name in discoveredEntries)) { + delete merged[name]; + } + } + + return JSON.stringify(merged, null, 2); + } + + private discoverMarketplaceEntries( + targetConfigDir: string + ): Record { + const marketplacesDir = path.join(targetConfigDir, 'plugins', 'marketplaces'); + if (!fs.existsSync(marketplacesDir)) { + return {}; + } + + const discovered: Record = {}; + + for (const entry of fs.readdirSync(marketplacesDir, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + + discovered[entry.name] = { + installLocation: path.join(targetConfigDir, 'plugins', 'marketplaces', entry.name), + }; + } + + return discovered; + } + + private writePluginMetadataFile( + registryPath: string, + content: string, + successMessage: string + ): void { + fs.mkdirSync(path.dirname(registryPath), { recursive: true, mode: 0o700 }); + const current = fs.existsSync(registryPath) ? fs.readFileSync(registryPath, 'utf8') : null; + + if (current === content) { + return; + } + + fs.writeFileSync(registryPath, content, 'utf8'); + console.log(ok(successMessage)); + } + /** * Migrate from v3.1.1 (copied data in ~/.ccs/shared/) to v3.2.0 (symlinks to ~/.claude/) * Runs once on upgrade @@ -1157,6 +1484,118 @@ class SharedManager { return candidate; } + private symlinkPointsTo(linkPath: string, expectedTarget: string): boolean { + try { + const currentTarget = fs.readlinkSync(linkPath); + const resolvedCurrentTarget = path.resolve(path.dirname(linkPath), currentTarget); + return ( + this.resolveCanonicalPath(resolvedCurrentTarget) === + this.resolveCanonicalPath(expectedTarget) + ); + } catch { + return false; + } + } + + private detachManagedPluginLayout(instancePath: string): void { + const pluginsPath = path.join(instancePath, 'plugins'); + if (!fs.existsSync(pluginsPath)) { + return; + } + + const stats = fs.lstatSync(pluginsPath); + const sharedPluginsPath = path.join(this.sharedDir, 'plugins'); + + if (stats.isSymbolicLink()) { + if (this.symlinkPointsTo(pluginsPath, sharedPluginsPath)) { + this.removeExistingPath(pluginsPath, 'directory'); + } + return; + } + + if (!stats.isDirectory()) { + return; + } + + let removedManagedEntries = false; + + for (const item of this.getSharedPluginLinkItems()) { + const pluginEntryPath = path.join(pluginsPath, item.name); + if (!fs.existsSync(pluginEntryPath)) { + continue; + } + + const entryStats = fs.lstatSync(pluginEntryPath); + if (!entryStats.isSymbolicLink()) { + continue; + } + + if (this.symlinkPointsTo(pluginEntryPath, path.join(sharedPluginsPath, item.name))) { + this.removeExistingPath(pluginEntryPath, item.type); + removedManagedEntries = true; + } + } + + if (!removedManagedEntries) { + return; + } + + this.reconcileLocalMarketplaceRegistry(instancePath); + + if (fs.readdirSync(pluginsPath).length === 0) { + fs.rmSync(pluginsPath, { recursive: true, force: true }); + } + } + + private reconcileLocalMarketplaceRegistry(configDir: string): void { + const registryPath = path.join(configDir, 'plugins', 'known_marketplaces.json'); + if (!fs.existsSync(registryPath)) { + return; + } + + const discoveredEntries = this.discoverMarketplaceEntries(configDir); + if (Object.keys(discoveredEntries).length === 0) { + this.removeExistingPath(registryPath, 'file'); + return; + } + + let parsed: Record = {}; + try { + const raw = JSON.parse(fs.readFileSync(registryPath, 'utf8')) as unknown; + if (raw && typeof raw === 'object' && !Array.isArray(raw)) { + parsed = raw as Record; + } + } catch { + parsed = {}; + } + + const reconciled = Object.fromEntries( + Object.entries(discoveredEntries).map(([name, value]) => { + const existing = parsed[name]; + if (existing && typeof existing === 'object' && !Array.isArray(existing)) { + return [ + name, + { + ...(normalizePluginMetadataValue(existing, configDir).normalized as Record< + string, + unknown + >), + installLocation: value.installLocation, + }, + ]; + } + + return [name, value]; + }) + ); + + this.writePluginMetadataFile( + registryPath, + JSON.stringify(reconciled, null, 2), + 'Synchronized marketplace registry paths' + ); + } + private resolveCanonicalPath(targetPath: string): string { try { return fs.realpathSync.native(targetPath); diff --git a/src/shared/claude-extension-setup.ts b/src/shared/claude-extension-setup.ts index 784c8560..9f6a9385 100644 --- a/src/shared/claude-extension-setup.ts +++ b/src/shared/claude-extension-setup.ts @@ -168,7 +168,7 @@ async function resolveExtensionEnv( profileType: result.type, target: 'claude', }); - new SharedManager().normalizeSharedPluginMetadataPaths(continuity.claudeConfigDir); + new SharedManager().normalizeSharedPluginMetadataPathsLocked(continuity.claudeConfigDir); if (continuity.claudeConfigDir) { notes.push(`Default profile inherits continuity from account "${continuity.sourceAccount}".`); return { @@ -250,7 +250,7 @@ async function resolveExtensionEnv( ); } - new SharedManager().normalizeSharedPluginMetadataPaths(env.CLAUDE_CONFIG_DIR); + new SharedManager().normalizeSharedPluginMetadataPathsLocked(env.CLAUDE_CONFIG_DIR); if (result.type === 'copilot') { warnings.push( diff --git a/src/types/cli.ts b/src/types/cli.ts index baf1870b..6737932c 100644 --- a/src/types/cli.ts +++ b/src/types/cli.ts @@ -1,4 +1,4 @@ -import { SpawnOptions as NodeSpawnOptions } from 'child_process'; +import type { SpawnOptions as NodeSpawnOptions } from 'child_process'; /** * CLI Runtime Types @@ -43,11 +43,13 @@ export interface ClaudeCliInfo { /** * Exit codes */ -export enum ExitCode { - SUCCESS = 0, - GENERIC_ERROR = 1, - CLAUDE_NOT_FOUND = 127, - CONFIG_ERROR = 2, - DELEGATION_ERROR = 3, - TIMEOUT = 124, -} +export const ExitCode = { + SUCCESS: 0, + GENERIC_ERROR: 1, + CLAUDE_NOT_FOUND: 127, + CONFIG_ERROR: 2, + DELEGATION_ERROR: 3, + TIMEOUT: 124, +} as const; + +export type ExitCode = (typeof ExitCode)[keyof typeof ExitCode]; diff --git a/src/types/utils.ts b/src/types/utils.ts index 51ccc6a7..fd573f03 100644 --- a/src/types/utils.ts +++ b/src/types/utils.ts @@ -9,12 +9,14 @@ export type { ErrorCode } from '../utils/error-codes'; /** * Log levels */ -export enum LogLevel { - DEBUG = 'debug', - INFO = 'info', - WARN = 'warn', - ERROR = 'error', -} +export const LogLevel = { + DEBUG: 'debug', + INFO: 'info', + WARN: 'warn', + ERROR: 'error', +} as const; + +export type LogLevel = (typeof LogLevel)[keyof typeof LogLevel]; /** * Color codes (TTY-aware) diff --git a/src/utils/claude-config-path.ts b/src/utils/claude-config-path.ts index e084cceb..0cd853ae 100644 --- a/src/utils/claude-config-path.ts +++ b/src/utils/claude-config-path.ts @@ -1,5 +1,5 @@ -import * as os from 'os'; import * as path from 'path'; +import { getCcsHome } from './config-manager'; /** * Resolve Claude config directory with test/dev overrides. @@ -13,11 +13,7 @@ export function getClaudeConfigDir(): string { return path.resolve(process.env.CLAUDE_CONFIG_DIR); } - if (process.env.CCS_HOME) { - return path.join(path.resolve(process.env.CCS_HOME), '.claude'); - } - - return path.join(os.homedir(), '.claude'); + return path.join(getCcsHome(), '.claude'); } /** Resolve Claude settings.json path. */ diff --git a/src/utils/config-manager.ts b/src/utils/config-manager.ts index 7b8ce3cb..e20e3660 100644 --- a/src/utils/config-manager.ts +++ b/src/utils/config-manager.ts @@ -1,14 +1,9 @@ +import { AsyncLocalStorage } from 'async_hooks'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { - Config, - isConfig, - Settings, - isSettings, - CLIProxyVariantsConfig, - CLIProxyVariantConfig, -} from '../types'; +import { isConfig, isSettings } from '../types'; +import type { Config, Settings, CLIProxyVariantsConfig, CLIProxyVariantConfig } from '../types'; import { expandPath, error } from './helpers'; import { info } from './ui'; import { isUnifiedMode, loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; @@ -19,6 +14,38 @@ import { isUnifiedMode, loadOrCreateUnifiedConfig } from '../config/unified-conf // Module-level state for --config-dir CLI flag override let _globalConfigDir: string | undefined; +const configScopeStorage = new AsyncLocalStorage<{ ccsHome?: string; ccsDir?: string }>(); + +function normalizeScopedPath(dir: string | undefined): string | undefined { + return dir ? path.resolve(dir) : undefined; +} + +export async function runWithScopedConfig( + scope: { ccsHome?: string; ccsDir?: string }, + fn: () => Promise | T +): Promise { + return await configScopeStorage.run( + { + ccsHome: normalizeScopedPath(scope.ccsHome), + ccsDir: normalizeScopedPath(scope.ccsDir), + }, + fn + ); +} + +export async function runWithScopedCcsHome( + ccsHome: string, + fn: () => Promise | T +): Promise { + return await runWithScopedConfig({ ccsHome }, fn); +} + +export async function runWithScopedConfigDir( + ccsDir: string, + fn: () => Promise | T +): Promise { + return await runWithScopedConfig({ ccsDir }, fn); +} /** * Set global config directory from --config-dir CLI flag. @@ -34,6 +61,10 @@ export function setGlobalConfigDir(dir: string | undefined): void { * @returns Home directory path */ export function getCcsHome(): string { + const scopedHome = configScopeStorage.getStore()?.ccsHome; + if (scopedHome) { + return scopedHome; + } return process.env.CCS_HOME || os.homedir(); } @@ -42,6 +73,10 @@ export function getCcsHome(): string { * Single source of truth for precedence logic. */ function _resolveCcsDir(): { source: string; dir: string } { + const scopedConfig = configScopeStorage.getStore(); + if (scopedConfig?.ccsDir) return { source: 'scoped:CCS_DIR', dir: scopedConfig.ccsDir }; + if (scopedConfig?.ccsHome) + return { source: 'scoped:CCS_HOME', dir: path.join(scopedConfig.ccsHome, '.ccs') }; if (_globalConfigDir) return { source: '--config-dir', dir: _globalConfigDir }; if (process.env.CCS_DIR) return { source: 'CCS_DIR', dir: path.resolve(process.env.CCS_DIR) }; if (process.env.CCS_HOME) diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index 16861f00..ff96458e 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -125,7 +125,7 @@ export function execClaude( if (profileType !== 'account') { try { - new SharedManager().normalizeSharedPluginMetadataPaths(env.CLAUDE_CONFIG_DIR); + new SharedManager().normalizeSharedPluginMetadataPathsLocked(env.CLAUDE_CONFIG_DIR); } catch { // Best-effort normalization should never block Claude launch. } diff --git a/src/web-server/routes/account-routes.ts b/src/web-server/routes/account-routes.ts index daa9c71a..f8c74e6d 100644 --- a/src/web-server/routes/account-routes.ts +++ b/src/web-server/routes/account-routes.ts @@ -8,7 +8,7 @@ import { Router, Request, Response } from 'express'; import ProfileRegistry from '../../auth/profile-registry'; import InstanceManager from '../../management/instance-manager'; -import { isUnifiedMode } from '../../config/unified-config-loader'; +import { isUnifiedMode, loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; import { getAllAccountsSummary, setDefaultAccount as setCliproxyDefault, @@ -30,12 +30,28 @@ import { parseCliproxyKey, type MergedAccountEntry, } from './account-route-helpers'; +import type { AccountConfig } from '../../config/unified-config-types'; const router = Router(); -const registry = new ProfileRegistry(); -const instanceMgr = new InstanceManager(); + +function createProfileRegistry(): ProfileRegistry { + return new ProfileRegistry(); +} + +function createInstanceManager(): InstanceManager { + return new InstanceManager(); +} + +function getUnifiedAccountsRaw(): Record { + if (!isUnifiedMode()) { + return {}; + } + + return loadOrCreateUnifiedConfig().accounts; +} function hasAuthAccount(name: string): boolean { + const registry = createProfileRegistry(); return registry.hasAccountUnified(name) || registry.hasProfile(name); } @@ -44,8 +60,11 @@ function hasAuthAccount(name: string): boolean { */ router.get('/', (_req: Request, res: Response): void => { try { + const registry = createProfileRegistry(); + // Get profiles from both legacy and unified config (same logic as CLI) const legacyProfiles = registry.getAllProfiles(); + const rawUnifiedAccounts = getUnifiedAccountsRaw(); const unifiedAccounts = registry.getAllAccountsUnified(); // Get CLIProxy OAuth accounts (gemini, codex, agy, etc.) @@ -76,11 +95,12 @@ router.get('/', (_req: Request, res: Response): void => { // Override with unified config accounts (takes precedence) for (const [name, account] of Object.entries(unifiedAccounts)) { + const rawAccount = rawUnifiedAccounts[name]; const contextPolicy = resolveAccountContextPolicy(account); const hasExplicitContextMode = - account.context_mode === 'isolated' || account.context_mode === 'shared'; + rawAccount?.context_mode === 'isolated' || rawAccount?.context_mode === 'shared'; const hasExplicitContinuityMode = - account.continuity_mode === 'standard' || account.continuity_mode === 'deeper'; + rawAccount?.continuity_mode === 'standard' || rawAccount?.continuity_mode === 'deeper'; merged[name] = { type: 'account', created: account.created, @@ -138,6 +158,7 @@ router.get('/', (_req: Request, res: Response): void => { */ router.post('/default', (req: Request, res: Response): void => { try { + const registry = createProfileRegistry(); const { name } = req.body; if (!name) { @@ -175,6 +196,8 @@ router.post('/default', (req: Request, res: Response): void => { */ router.put('/:name/context', async (req: Request, res: Response): Promise => { try { + const registry = createProfileRegistry(); + const instanceMgr = createInstanceManager(); const { name } = req.params; if (!name) { @@ -310,6 +333,7 @@ router.put('/:name/context', async (req: Request, res: Response): Promise */ router.delete('/reset-default', (_req: Request, res: Response): void => { try { + const registry = createProfileRegistry(); if (isUnifiedMode()) { registry.clearDefaultUnified(); } else { @@ -324,8 +348,10 @@ router.delete('/reset-default', (_req: Request, res: Response): void => { /** * DELETE /api/accounts/:name - Delete an account */ -router.delete('/:name', (req: Request, res: Response): void => { +router.delete('/:name', async (req: Request, res: Response): Promise => { try { + const registry = createProfileRegistry(); + const instanceMgr = createInstanceManager(); const { name } = req.params; if (!name) { @@ -371,7 +397,7 @@ router.delete('/:name', (req: Request, res: Response): void => { } // Match CLI remove ordering: delete instance first, metadata second. - instanceMgr.deleteInstance(name); + await instanceMgr.deleteInstance(name); if (existsUnified) { registry.removeAccountUnified(name); diff --git a/src/web-server/routes/ai-provider-routes.ts b/src/web-server/routes/ai-provider-routes.ts new file mode 100644 index 00000000..cb2a3492 --- /dev/null +++ b/src/web-server/routes/ai-provider-routes.ts @@ -0,0 +1,128 @@ +import { Router, type Request, type Response } from 'express'; +import { + AI_PROVIDER_FAMILY_IDS, + createAiProviderEntry, + deleteAiProviderEntry, + listAiProviders, + updateAiProviderEntry, + type AiProviderFamilyId, + type UpsertAiProviderEntryInput, +} from '../../cliproxy/ai-providers'; + +const router = Router(); + +function isAiProviderFamilyId(value: string): value is AiProviderFamilyId { + return AI_PROVIDER_FAMILY_IDS.includes(value as AiProviderFamilyId); +} + +function parseFamily(req: Request, res: Response): AiProviderFamilyId | null { + const family = req.params.family?.trim(); + if (!family || !isAiProviderFamilyId(family)) { + res.status(400).json({ error: 'Invalid AI provider family' }); + return null; + } + return family; +} + +function parseIndex(req: Request, res: Response): number | null { + const index = Number.parseInt(req.params.index || '', 10); + if (!Number.isInteger(index) || index < 0) { + res.status(400).json({ error: 'Invalid entry index' }); + return null; + } + return index; +} + +function parseInput(body: unknown): UpsertAiProviderEntryInput { + const payload = + typeof body === 'object' && body !== null ? (body as Record) : {}; + return { + name: typeof payload.name === 'string' ? payload.name : undefined, + baseUrl: typeof payload.baseUrl === 'string' ? payload.baseUrl : undefined, + proxyUrl: typeof payload.proxyUrl === 'string' ? payload.proxyUrl : undefined, + prefix: typeof payload.prefix === 'string' ? payload.prefix : undefined, + headers: Array.isArray(payload.headers) + ? payload.headers + .filter( + (item): item is { key?: unknown; value?: unknown } => + typeof item === 'object' && item !== null + ) + .map((item) => ({ + key: typeof item.key === 'string' ? item.key : '', + value: typeof item.value === 'string' ? item.value : '', + })) + : undefined, + excludedModels: Array.isArray(payload.excludedModels) + ? payload.excludedModels.filter((item): item is string => typeof item === 'string') + : undefined, + models: Array.isArray(payload.models) + ? payload.models + .filter( + (item): item is { name?: unknown; alias?: unknown } => + typeof item === 'object' && item !== null + ) + .map((item) => ({ + name: typeof item.name === 'string' ? item.name : '', + alias: typeof item.alias === 'string' ? item.alias : '', + })) + : undefined, + apiKey: typeof payload.apiKey === 'string' ? payload.apiKey : undefined, + apiKeys: Array.isArray(payload.apiKeys) + ? payload.apiKeys.filter((item): item is string => typeof item === 'string') + : undefined, + preserveSecrets: payload.preserveSecrets === true, + }; +} + +router.get('/', async (_req: Request, res: Response) => { + try { + res.json(await listAiProviders()); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +router.post('/:family', async (req: Request, res: Response) => { + const family = parseFamily(req, res); + if (!family) return; + + try { + await createAiProviderEntry(family, parseInput(req.body)); + res.status(201).json({ success: true }); + } catch (error) { + const message = (error as Error).message; + res.status(message === 'Entry not found' ? 404 : 400).json({ error: message }); + } +}); + +router.put('/:family/:index', async (req: Request, res: Response) => { + const family = parseFamily(req, res); + if (!family) return; + const index = parseIndex(req, res); + if (index === null) return; + + try { + await updateAiProviderEntry(family, index, parseInput(req.body)); + res.json({ success: true }); + } catch (error) { + const message = (error as Error).message; + res.status(message === 'Entry not found' ? 404 : 400).json({ error: message }); + } +}); + +router.delete('/:family/:index', async (req: Request, res: Response) => { + const family = parseFamily(req, res); + if (!family) return; + const index = parseIndex(req, res); + if (index === null) return; + + try { + await deleteAiProviderEntry(family, index); + res.json({ success: true }); + } catch (error) { + const message = (error as Error).message; + res.status(message === 'Entry not found' ? 404 : 400).json({ error: message }); + } +}); + +export default router; diff --git a/src/web-server/routes/index.ts b/src/web-server/routes/index.ts index 9fafe3f7..49066a51 100644 --- a/src/web-server/routes/index.ts +++ b/src/web-server/routes/index.ts @@ -19,6 +19,7 @@ import websearchRoutes from './websearch-routes'; import cliproxyAuthRoutes from './cliproxy-auth-routes'; import cliproxyStatsRoutes from './cliproxy-stats-routes'; import cliproxySyncRoutes from './cliproxy-sync-routes'; +import aiProviderRoutes from './ai-provider-routes'; import copilotRoutes from './copilot-routes'; import cursorRoutes from './cursor-routes'; import droidRoutes from './droid-routes'; @@ -59,6 +60,7 @@ apiRoutes.use('/cliproxy/auth', cliproxyAuthRoutes); apiRoutes.use('/cliproxy', cliproxyStatsRoutes); apiRoutes.use('/cliproxy/sync', cliproxySyncRoutes); apiRoutes.use('/cliproxy/catalog', catalogRoutes); +apiRoutes.use('/cliproxy/ai-providers', aiProviderRoutes); apiRoutes.use('/cliproxy/openai-compat', providerRoutes); // ==================== WebSearch ==================== diff --git a/src/web-server/routes/profile-routes.ts b/src/web-server/routes/profile-routes.ts index 81a63055..800ca5c1 100644 --- a/src/web-server/routes/profile-routes.ts +++ b/src/web-server/routes/profile-routes.ts @@ -9,6 +9,7 @@ import { Router, Request, Response } from 'express'; import { isReservedName, RESERVED_PROFILE_NAMES } from '../../config/reserved-names'; import { createApiProfile, + createCliproxyBridgeProfile, removeApiProfile, updateApiProfileTarget, discoverApiProfileOrphans, @@ -17,10 +18,12 @@ import { exportApiProfile, importApiProfileBundle, apiProfileExists, + listCliproxyBridgeProviders, listApiProfiles, validateApiName, } from '../../api/services'; import { normalizeDroidProvider } from '../../targets/droid-provider'; +import { isCLIProxyProvider } from '../../cliproxy/provider-capabilities'; import { isAnthropicDirectProfile, updateSettingsFile, parseTarget } from './route-helpers'; const router = Router(); @@ -66,6 +69,7 @@ router.get('/', (_req: Request, res: Response): void => { settingsPath: p.settingsPath, configured: p.isConfigured, target: p.target, + cliproxyBridge: p.cliproxyBridge ?? null, })); res.json({ profiles }); } catch (error) { @@ -73,6 +77,54 @@ router.get('/', (_req: Request, res: Response): void => { } }); +router.get('/cliproxy-bridge/providers', (_req: Request, res: Response): void => { + try { + res.json({ providers: listCliproxyBridgeProviders() }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +router.post('/cliproxy-bridge', (req: Request, res: Response): void => { + const shape = validatePayloadShape(req.body, ['provider', 'name', 'target']); + if (!shape.ok) { + res.status(400).json({ error: shape.error }); + return; + } + + const provider = typeof shape.payload.provider === 'string' ? shape.payload.provider.trim() : ''; + if (!isCLIProxyProvider(provider)) { + res.status(400).json({ error: 'Invalid provider. Expected a supported CLIProxy provider ID.' }); + return; + } + + const target = parseTarget(shape.payload.target); + if (shape.payload.target !== undefined && target === null) { + res.status(400).json({ error: 'Invalid target. Expected: claude or droid' }); + return; + } + + const result = createCliproxyBridgeProfile(provider, { + name: typeof shape.payload.name === 'string' ? shape.payload.name : undefined, + target: target || 'claude', + }); + + if (!result.success || !result.name) { + const errorMessage = result.error || 'Failed to create CLIProxy bridge profile'; + res.status(errorMessage.toLowerCase().includes('already exists') ? 409 : 400).json({ + error: errorMessage, + }); + return; + } + + res.status(201).json({ + name: result.name, + settingsPath: result.settingsFile, + target: result.target || 'claude', + cliproxyBridge: result.cliproxyBridge ?? null, + }); +}); + /** * POST /api/profiles - Create new profile */ @@ -160,6 +212,7 @@ router.post('/', (req: Request, res: Response): void => { name, settingsPath: result.settingsFile, target: parsedTarget || 'claude', + cliproxyBridge: null, }); }); diff --git a/src/web-server/routes/settings-routes.ts b/src/web-server/routes/settings-routes.ts index 4f293f7b..9f2eec1d 100644 --- a/src/web-server/routes/settings-routes.ts +++ b/src/web-server/routes/settings-routes.ts @@ -19,6 +19,7 @@ import { } from '../../cliproxy'; import { regenerateConfig } from '../../cliproxy/config-generator'; import { deduplicateCcsHooks } from '../../utils/websearch/hook-utils'; +import { resolveCliproxyBridgeMetadata } from '../../api/services'; import { getDashboardAuthConfig, loadOrCreateUnifiedConfig, @@ -327,6 +328,7 @@ router.get('/:profile', (req: Request, res: Response): void => { settings: masked, mtime: stat.mtime.getTime(), path: settingsPath, + cliproxyBridge: resolveCliproxyBridgeMetadata(settings), }); } catch (error) { respondInternalError(res, error, 'Internal server error.'); @@ -354,6 +356,7 @@ router.get('/:profile/raw', (req: Request, res: Response): void => { settings, mtime: stat.mtime.getTime(), path: settingsPath, + cliproxyBridge: resolveCliproxyBridgeMetadata(settings), }); } catch (error) { respondInternalError(res, error, 'Internal server error.'); diff --git a/tests/unit/api/cliproxy-profile-bridge.test.ts b/tests/unit/api/cliproxy-profile-bridge.test.ts new file mode 100644 index 00000000..07c3be60 --- /dev/null +++ b/tests/unit/api/cliproxy-profile-bridge.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { getEffectiveApiKey } from '../../../src/cliproxy/auth-token-manager'; +import { + resolveCliproxyBridgeMetadata, + resolveCliproxyBridgeProfile, + suggestCliproxyBridgeName, +} from '../../../src/api/services/cliproxy-profile-bridge'; + +describe('cliproxy-profile-bridge', () => { + let tempHome = ''; + let originalCcsHome: string | undefined; + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-bridge-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + }); + + afterEach(() => { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('resolves routed profile payload for a local CLIProxy provider', () => { + const bridge = resolveCliproxyBridgeProfile('gemini'); + + expect(bridge.name).toBe('gemini-api'); + expect(bridge.baseUrl).toBe('http://127.0.0.1:8317/api/provider/gemini'); + expect(bridge.routePath).toBe('/api/provider/gemini'); + expect(bridge.models.default.length).toBeGreaterThan(0); + }); + + it('suggests a unique name when the default bridge settings file already exists', () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync(path.join(ccsDir, 'gemini-api.settings.json'), '{}\n'); + + expect(suggestCliproxyBridgeName('gemini')).toBe('gemini-api-2'); + }); + + it('detects CLIProxy-backed profile metadata and normalizes localhost loopback URLs', () => { + const metadata = resolveCliproxyBridgeMetadata({ + env: { + ANTHROPIC_BASE_URL: 'http://localhost:8317/api/provider/gemini', + ANTHROPIC_AUTH_TOKEN: getEffectiveApiKey(), + }, + }); + + expect(metadata?.provider).toBe('gemini'); + expect(metadata?.usesCurrentTarget).toBe(true); + expect(metadata?.usesCurrentAuthToken).toBe(true); + }); +}); diff --git a/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts b/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts index b22ac1fa..7c104741 100644 --- a/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts +++ b/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts @@ -2,8 +2,10 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test'; +import { clearQuotaCache } from '../../../src/cliproxy/quota-response-cache'; afterEach(() => { + clearQuotaCache(); mock.restore(); }); @@ -57,10 +59,6 @@ describe('codex plan compatibility reconcile', () => { accountId: 'free@example.com', }), })); - mock.module('../../../src/cliproxy/quota-response-cache', () => ({ - getCachedQuota: () => null, - setCachedQuota: () => {}, - })); mock.module('../../../src/utils/ui', () => ({ info: (message: string) => message, warn: (message: string) => message, @@ -103,10 +101,6 @@ describe('codex plan compatibility reconcile', () => { throw new Error('should not fetch quota without a default account'); }, })); - mock.module('../../../src/cliproxy/quota-response-cache', () => ({ - getCachedQuota: () => null, - setCachedQuota: () => {}, - })); mock.module('../../../src/utils/ui', () => ({ info: (message: string) => message, warn: (message: string) => message, @@ -153,10 +147,6 @@ describe('codex plan compatibility reconcile', () => { accountId: `${planType}@example.com`, }), })); - mock.module('../../../src/cliproxy/quota-response-cache', () => ({ - getCachedQuota: () => null, - setCachedQuota: () => {}, - })); mock.module('../../../src/utils/ui', () => ({ info: (message: string) => message, warn: (message: string) => message, @@ -202,10 +192,6 @@ describe('codex plan compatibility reconcile', () => { error: 'network timeout', }), })); - mock.module('../../../src/cliproxy/quota-response-cache', () => ({ - getCachedQuota: () => null, - setCachedQuota: () => {}, - })); mock.module('../../../src/utils/ui', () => ({ info: (message: string) => message, warn: (message: string) => message, @@ -250,10 +236,6 @@ describe('codex plan compatibility reconcile', () => { accountId: 'missing-plan@example.com', }), })); - mock.module('../../../src/cliproxy/quota-response-cache', () => ({ - getCachedQuota: () => null, - setCachedQuota: () => {}, - })); mock.module('../../../src/utils/ui', () => ({ info: (message: string) => message, warn: (message: string) => message, diff --git a/tests/unit/cliproxy/codex-plan-compatibility.test.ts b/tests/unit/cliproxy/codex-plan-compatibility.test.ts index cf84fd28..23bb7bc9 100644 --- a/tests/unit/cliproxy/codex-plan-compatibility.test.ts +++ b/tests/unit/cliproxy/codex-plan-compatibility.test.ts @@ -3,6 +3,8 @@ import { getProviderCatalog, getModelMaxLevel } from '../../../src/cliproxy/mode import { getDefaultCodexModel, getFreePlanFallbackCodexModel, + parseCodexUnsupportedModelError, + resolveRuntimeCodexFallbackModel, } from '../../../src/cliproxy/codex-plan-compatibility'; describe('codex plan compatibility', () => { @@ -25,6 +27,50 @@ describe('codex plan compatibility', () => { expect(getFreePlanFallbackCodexModel('gpt-5.1-codex-mini')).toBeNull(); }); + it('detects upstream Codex model_not_supported responses', () => { + expect( + parseCodexUnsupportedModelError( + 400, + JSON.stringify({ + error: { + message: 'The requested model is not supported.', + code: 'model_not_supported', + param: 'model', + type: 'invalid_request_error', + }, + }) + ) + ).toEqual({ + message: 'The requested model is not supported.', + code: 'model_not_supported', + param: 'model', + type: 'invalid_request_error', + }); + expect( + parseCodexUnsupportedModelError(500, '{"error":{"code":"model_not_supported"}}') + ).toBeNull(); + }); + + it('resolves runtime fallbacks without retrying the rejected model again', () => { + expect( + resolveRuntimeCodexFallbackModel({ + requestedModel: 'gpt-5.4', + modelMap: { defaultModel: 'gpt-5-codex' }, + }) + ).toBe('gpt-5-codex'); + + expect( + resolveRuntimeCodexFallbackModel({ + requestedModel: 'gpt-5.4', + modelMap: { + defaultModel: 'gpt-5.4', + haikuModel: 'gpt-5-codex-mini', + }, + excludeModels: ['gpt-5-codex'], + }) + ).toBe('gpt-5-codex-mini'); + }); + it('tracks Codex thinking caps for current safe defaults and paid models', () => { expect(getModelMaxLevel('codex', 'gpt-5-codex')).toBe('high'); expect(getModelMaxLevel('codex', 'gpt-5-codex-mini')).toBe('high'); diff --git a/tests/unit/cliproxy/codex-reasoning-proxy-extended-context.test.ts b/tests/unit/cliproxy/codex-reasoning-proxy-extended-context.test.ts index 1e6f0cd3..5a6dc3a2 100644 --- a/tests/unit/cliproxy/codex-reasoning-proxy-extended-context.test.ts +++ b/tests/unit/cliproxy/codex-reasoning-proxy-extended-context.test.ts @@ -230,6 +230,90 @@ describe('CodexReasoningProxy extended-context compatibility', () => { expect(capturedBody?.model).toBe('enterprise-internal-high'); }); + it('retries unsupported live-session models once and remembers the fallback', async () => { + const capturedModels: string[] = []; + const capturedEfforts: Array = []; + + const upstream = http.createServer((req, res) => { + let rawBody = ''; + req.setEncoding('utf8'); + req.on('data', (chunk) => { + rawBody += chunk; + }); + req.on('end', () => { + const requestBody = rawBody ? (JSON.parse(rawBody) as JsonRecord) : {}; + const reasoning = requestBody.reasoning as JsonRecord | undefined; + const model = String(requestBody.model ?? ''); + const effort = typeof reasoning?.effort === 'string' ? reasoning.effort : undefined; + + capturedModels.push(model); + capturedEfforts.push(effort); + + if (model === 'gpt-5.4') { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + error: { + message: 'The requested model is not supported.', + code: 'model_not_supported', + param: 'model', + type: 'invalid_request_error', + }, + }) + ); + return; + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + ok: true, + model, + effort: effort ?? null, + }) + ); + }); + }); + cleanupServers.push(upstream); + + const upstreamPort = await listenOnRandomPort(upstream); + const proxy = new CodexReasoningProxy({ + upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}`, + modelMap: { + defaultModel: 'gpt-5.4', + haikuModel: 'gpt-5-codex-mini', + }, + defaultEffort: 'medium', + }); + + const proxyPort = await proxy.start(); + const firstResponse = await postJson( + `http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`, + { + model: 'gpt-5.4-xhigh', + messages: [], + } + ); + const secondResponse = await postJson( + `http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`, + { + model: 'gpt-5.4-xhigh', + messages: [], + } + ); + + proxy.stop(); + + expect(firstResponse.statusCode).toBe(200); + expect(secondResponse.statusCode).toBe(200); + expect(firstResponse.body.model).toBe('gpt-5-codex'); + expect(firstResponse.body.effort).toBe('high'); + expect(secondResponse.body.model).toBe('gpt-5-codex'); + expect(secondResponse.body.effort).toBe('high'); + expect(capturedModels).toEqual(['gpt-5.4', 'gpt-5-codex', 'gpt-5-codex']); + expect(capturedEfforts).toEqual(['xhigh', 'high', 'high']); + }); + it('keeps reasoning enabled when CCS_THINKING=high overrides config off', async () => { let capturedBody: JsonRecord | null = null; diff --git a/tests/unit/cliproxy/composite-env-routing.test.ts b/tests/unit/cliproxy/composite-env-routing.test.ts index 281aae1b..0ef51fc7 100644 --- a/tests/unit/cliproxy/composite-env-routing.test.ts +++ b/tests/unit/cliproxy/composite-env-routing.test.ts @@ -90,6 +90,6 @@ describe('buildClaudeEnvironment - composite remote routing', () => { expect(env.ANTHROPIC_MODEL).toMatch(/^claude-opus-4-6-thinking(\([^)]+\))?$/); expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toMatch(/^claude-opus-4-6-thinking(\([^)]+\))?$/); expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toMatch(/^gemini-2.5-pro(\([^)]+\))?$/); - expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.1-codex-mini'); + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toMatch(/^gpt-5.1-codex-mini(?:-medium)?$/); }); }); diff --git a/tests/unit/commands/api-command-args.test.ts b/tests/unit/commands/api-command-args.test.ts index 3ee6916b..94032699 100644 --- a/tests/unit/commands/api-command-args.test.ts +++ b/tests/unit/commands/api-command-args.test.ts @@ -55,6 +55,14 @@ describe('api-command arg parser', () => { expect(parsed.errors).toEqual([]); }); + test('parses --cliproxy-provider for routed API profile creation', () => { + const parsed = parseApiCommandArgs(['my-api', '--cliproxy-provider', 'Gemini']); + + expect(parsed.name).toBe('my-api'); + expect(parsed.cliproxyProvider).toBe('gemini'); + expect(parsed.errors).toEqual([]); + }); + test('validates invalid --target values', () => { const parsed = parseApiCommandArgs(['my-api', '--target', 'invalid-target']); diff --git a/tests/unit/commands/config-command.test.ts b/tests/unit/commands/config-command.test.ts index 27c5b44c..d5c539f9 100644 --- a/tests/unit/commands/config-command.test.ts +++ b/tests/unit/commands/config-command.test.ts @@ -1,7 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; const startServerCalls: Array> = []; -const resolveDashboardUrlsCalls: Array<[string | undefined, number]> = []; const configAuthCalls: string[][] = []; let logLines: string[] = []; let errorLines: string[] = []; @@ -14,7 +13,6 @@ let originalProcessExit: typeof process.exit; beforeEach(() => { startServerCalls.length = 0; - resolveDashboardUrlsCalls.length = 0; configAuthCalls.length = 0; logLines = []; errorLines = []; @@ -91,42 +89,6 @@ beforeEach(() => { mock.module('../../../src/utils/ui', () => uiModule); mock.module('../../../src/utils/ui.ts', () => uiModule); - mock.module('../../../src/commands/config-dashboard-host', () => ({ - normalizeDashboardHost: (host: string | undefined) => { - if (!host) { - return undefined; - } - - if (host.startsWith('[') && host.endsWith(']') && host.includes(':')) { - return host.slice(1, -1); - } - - return host; - }, - isLoopbackHost: (host: string) => - ['localhost', '127.0.0.1', '::1', '[::1]'].includes(host.trim().toLowerCase()), - isWildcardHost: (host: string) => ['0.0.0.0', '::', '[::]'].includes(host.trim().toLowerCase()), - resolveDashboardUrls: (host: string | undefined, port: number) => { - resolveDashboardUrlsCalls.push([host, port]); - if (!host) { - return { browserUrl: `http://localhost:${port}` }; - } - - if (host === '0.0.0.0' || host === '::') { - return { - bindHost: host, - browserUrl: `http://localhost:${port}`, - networkUrls: [`http://192.168.1.25:${port}`, `http://100.64.0.12:${port}`], - }; - } - - return { - bindHost: host, - browserUrl: `http://${host}:${port}`, - }; - }, - })); - mock.module('../../../src/commands/config-auth', () => ({ handleConfigAuthCommand: async (args: string[]) => { configAuthCalls.push([...args]); @@ -158,7 +120,6 @@ describe('config command dashboard startup', () => { await expect(handleConfigCommand(['help'])).rejects.toThrow('process.exit(0)'); expect(startServerCalls).toHaveLength(0); - expect(resolveDashboardUrlsCalls).toHaveLength(0); expect(logLines.join('\n')).toContain('Usage: ccs config [command] [options]'); }); @@ -169,7 +130,6 @@ describe('config command dashboard startup', () => { expect(configAuthCalls).toEqual([['setup']]); expect(startServerCalls).toHaveLength(0); - expect(resolveDashboardUrlsCalls).toHaveLength(0); }); it('rejects unknown config subcommands before dashboard startup', async () => { @@ -181,7 +141,6 @@ describe('config command dashboard startup', () => { await expect(handleConfigCommand(['bogus'])).rejects.toThrow('process.exit(1)'); expect(startServerCalls).toHaveLength(0); - expect(resolveDashboardUrlsCalls).toHaveLength(0); expect(errorLines.join('\n')).toContain('Unexpected arguments: bogus'); }); @@ -192,12 +151,11 @@ describe('config command dashboard startup', () => { expect(startServerCalls).toHaveLength(1); expect(startServerCalls[0]).toEqual({ port: 3000, dev: false }); - expect(resolveDashboardUrlsCalls).toEqual([['::', 3000]]); const rendered = logLines.join('\n'); expect(rendered).toContain('Dashboard: http://localhost:3000'); expect(rendered).toContain('Bind host: ::'); - expect(rendered).toContain('Network URLs:'); + expect(rendered).toContain('Dashboard may be reachable from other devices that can connect to this machine.'); expect(rendered).toContain('Protect it before sharing: ccs config auth setup'); expect(errorLines).toHaveLength(0); }); @@ -210,13 +168,10 @@ describe('config command dashboard startup', () => { expect(startServerCalls).toHaveLength(1); expect(startServerCalls[0]).toEqual({ port: 4100, dev: false, host: '0.0.0.0' }); - expect(resolveDashboardUrlsCalls).toEqual([['0.0.0.0', 4100]]); const rendered = logLines.join('\n'); + expect(rendered).toContain('Dashboard: http://localhost:4100'); expect(rendered).toContain('Bind host: 0.0.0.0'); - expect(rendered).toContain('Network URLs:'); - expect(rendered).toContain('http://192.168.1.25:4100'); - expect(rendered).toContain('http://100.64.0.12:4100'); expect(rendered).toContain( 'Dashboard may be reachable from other devices that can connect to this machine.' ); diff --git a/tests/unit/commands/persist-command-handler.test.ts b/tests/unit/commands/persist-command-handler.test.ts index a298c09d..6235be55 100644 --- a/tests/unit/commands/persist-command-handler.test.ts +++ b/tests/unit/commands/persist-command-handler.test.ts @@ -6,6 +6,7 @@ import * as lockfile from 'proper-lockfile'; import { handlePersistCommand } from '../../../src/commands/persist-command'; import { createEmptyUnifiedConfig } from '../../../src/config/unified-config-types'; import { saveUnifiedConfig } from '../../../src/config/unified-config-loader'; +import { runWithScopedCcsHome } from '../../../src/utils/config-manager'; interface RestoreFixture { claudeDir: string; @@ -18,11 +19,14 @@ interface RestoreFixture { let tempRoot: string; let originalClaudeConfigDir: string | undefined; -let originalCcsHome: string | undefined; let originalProcessExit: typeof process.exit; let originalFsOpen: typeof fs.promises.open; let originalFsRename: typeof fs.promises.rename; +async function withScopedHome(fn: () => Promise): Promise { + return await runWithScopedCcsHome(tempRoot, fn); +} + async function pathExists(filePath: string): Promise { try { await fs.promises.access(filePath, fs.constants.F_OK); @@ -69,11 +73,9 @@ function stubProcessExit(): void { beforeEach(async () => { tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'ccs-persist-handler-test-')); originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; - originalCcsHome = process.env.CCS_HOME; originalProcessExit = process.exit; originalFsOpen = fs.promises.open; originalFsRename = fs.promises.rename; - process.env.CCS_HOME = tempRoot; }); afterEach(async () => { @@ -87,12 +89,6 @@ afterEach(async () => { process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir; } - if (originalCcsHome === undefined) { - delete process.env.CCS_HOME; - } else { - process.env.CCS_HOME = originalCcsHome; - } - if (tempRoot) { await fs.promises.rm(tempRoot, { recursive: true, force: true }); } @@ -100,50 +96,50 @@ afterEach(async () => { describe('persist command real handler paths', () => { it('throws parseError for missing --permission-mode before profile detection', async () => { - await expect(handlePersistCommand(['glm', '--permission-mode'])).rejects.toThrow( + await expect(withScopedHome(() => handlePersistCommand(['glm', '--permission-mode']))).rejects.toThrow( 'Missing value for --permission-mode' ); }); it('throws parseError for empty inline --permission-mode before profile detection', async () => { - await expect(handlePersistCommand(['glm', '--permission-mode='])).rejects.toThrow( + await expect(withScopedHome(() => handlePersistCommand(['glm', '--permission-mode=']))).rejects.toThrow( 'Missing value for --permission-mode' ); }); it('throws parseError for invalid --permission-mode before profile detection', async () => { - await expect(handlePersistCommand(['glm', '--permission-mode', 'invalid-mode'])).rejects.toThrow( - /Invalid --permission-mode/ - ); + await expect( + withScopedHome(() => handlePersistCommand(['glm', '--permission-mode', 'invalid-mode'])) + ).rejects.toThrow(/Invalid --permission-mode/); }); it('throws parseError for unknown flags on real handler path', async () => { - await expect(handlePersistCommand(['glm', '--unknown-flag'])).rejects.toThrow( + await expect(withScopedHome(() => handlePersistCommand(['glm', '--unknown-flag']))).rejects.toThrow( /Unknown option\(s\)/ ); }); it('throws parseError for list/restore conflict on real handler path', async () => { - await expect(handlePersistCommand(['--list-backups', '--restore'])).rejects.toThrow( + await expect(withScopedHome(() => handlePersistCommand(['--list-backups', '--restore']))).rejects.toThrow( '--list-backups cannot be used with --restore' ); }); it('throws parseError for permission flags with --restore on real handler path', async () => { - await expect(handlePersistCommand(['--restore', '--auto-approve'])).rejects.toThrow( + await expect(withScopedHome(() => handlePersistCommand(['--restore', '--auto-approve']))).rejects.toThrow( /Permission flags are not valid with backup operations/ ); }); it('shows help when --help is present even with other invalid args', async () => { - await expect(handlePersistCommand(['--help', '--permission-mode'])).resolves.toBeUndefined(); + await expect(withScopedHome(() => handlePersistCommand(['--help', '--permission-mode']))).resolves.toBeUndefined(); }); it('does not create CLAUDE_CONFIG_DIR on parseError path', async () => { const isolatedClaudeDir = path.join(tempRoot, '.claude-parse-early'); process.env.CLAUDE_CONFIG_DIR = isolatedClaudeDir; - await expect(handlePersistCommand(['glm', '--permission-mode='])).rejects.toThrow( + await expect(withScopedHome(() => handlePersistCommand(['glm', '--permission-mode=']))).rejects.toThrow( 'Missing value for --permission-mode' ); expect(await pathExists(isolatedClaudeDir)).toBe(false); @@ -163,9 +159,9 @@ describe('persist command restore failure handling', () => { stubProcessExit(); try { - await expect(handlePersistCommand(['--restore', fixture.timestamp, '--yes'])).rejects.toThrow( - 'process.exit(1)' - ); + await expect( + withScopedHome(() => handlePersistCommand(['--restore', fixture.timestamp, '--yes'])) + ).rejects.toThrow('process.exit(1)'); } finally { await release(); } @@ -186,9 +182,9 @@ describe('persist command restore failure handling', () => { }) as typeof fs.promises.open; stubProcessExit(); - await expect(handlePersistCommand(['--restore', fixture.timestamp, '--yes'])).rejects.toThrow( - 'process.exit(1)' - ); + await expect( + withScopedHome(() => handlePersistCommand(['--restore', fixture.timestamp, '--yes'])) + ).rejects.toThrow('process.exit(1)'); }); it('exits when backup read fails with ELOOP (symlink rejection)', async () => { @@ -206,9 +202,9 @@ describe('persist command restore failure handling', () => { }) as typeof fs.promises.open; stubProcessExit(); - await expect(handlePersistCommand(['--restore', fixture.timestamp, '--yes'])).rejects.toThrow( - 'process.exit(1)' - ); + await expect( + withScopedHome(() => handlePersistCommand(['--restore', fixture.timestamp, '--yes'])) + ).rejects.toThrow('process.exit(1)'); }); it('exits when backup path resolves to a non-regular file', async () => { @@ -229,9 +225,9 @@ describe('persist command restore failure handling', () => { }) as typeof fs.promises.open; stubProcessExit(); - await expect(handlePersistCommand(['--restore', fixture.timestamp, '--yes'])).rejects.toThrow( - 'process.exit(1)' - ); + await expect( + withScopedHome(() => handlePersistCommand(['--restore', fixture.timestamp, '--yes'])) + ).rejects.toThrow('process.exit(1)'); }); it('rolls back settings when restore write fails mid-flight', async () => { @@ -248,9 +244,9 @@ describe('persist command restore failure handling', () => { }) as typeof fs.promises.rename; stubProcessExit(); - await expect(handlePersistCommand(['--restore', fixture.timestamp, '--yes'])).rejects.toThrow( - 'process.exit(1)' - ); + await expect( + withScopedHome(() => handlePersistCommand(['--restore', fixture.timestamp, '--yes'])) + ).rejects.toThrow('process.exit(1)'); const finalContent = await fs.promises.readFile(fixture.settingsPath, 'utf8'); const finalSettings = JSON.parse(finalContent); @@ -273,9 +269,9 @@ describe('persist command restore failure handling', () => { stubProcessExit(); try { - await expect(handlePersistCommand(['--restore', fixture.timestamp, '--yes'])).rejects.toThrow( - 'process.exit(1)' - ); + await expect( + withScopedHome(() => handlePersistCommand(['--restore', fixture.timestamp, '--yes'])) + ).rejects.toThrow('process.exit(1)'); expect(capturedLogs.some((line) => line.includes('Rollback also failed'))).toBe(true); } finally { console.log = originalConsoleLog; @@ -313,7 +309,9 @@ describe('persist command Claude extension parity', () => { ) + '\n', 'utf8' ); - saveUnifiedConfig(config); + await withScopedHome(async () => { + saveUnifiedConfig(config); + }); } it('persists account profiles via CLAUDE_CONFIG_DIR and clears stale managed env keys', async () => { @@ -337,7 +335,7 @@ describe('persist command Claude extension parity', () => { 'utf8' ); - await handlePersistCommand(['work', '--yes']); + await withScopedHome(() => handlePersistCommand(['work', '--yes'])); const persisted = JSON.parse(await fs.promises.readFile(settingsPath, 'utf8')) as { env: Record; @@ -371,7 +369,7 @@ describe('persist command Claude extension parity', () => { 'utf8' ); - await handlePersistCommand(['default', '--yes']); + await withScopedHome(() => handlePersistCommand(['default', '--yes'])); const persisted = JSON.parse(await fs.promises.readFile(settingsPath, 'utf8')) as { env: Record; diff --git a/tests/unit/commands/proxy-lifecycle-subcommand.test.ts b/tests/unit/commands/proxy-lifecycle-subcommand.test.ts index 3488ecfe..0cc8835b 100644 --- a/tests/unit/commands/proxy-lifecycle-subcommand.test.ts +++ b/tests/unit/commands/proxy-lifecycle-subcommand.test.ts @@ -4,9 +4,9 @@ import * as os from 'os'; import * as path from 'path'; import { resolveLifecyclePort } from '../../../src/commands/cliproxy/proxy-lifecycle-subcommand'; import { CLIPROXY_DEFAULT_PORT } from '../../../src/cliproxy/config/port-manager'; +import { runWithScopedConfigDir } from '../../../src/utils/config-manager'; let tempDir: string; -let originalCcsDir: string | undefined; function writeUnifiedConfig(localPort: number): void { const configPath = path.join(tempDir, 'config.yaml'); @@ -34,33 +34,31 @@ cliproxy_server: describe('resolveLifecyclePort', () => { beforeEach(() => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-proxy-lifecycle-')); - originalCcsDir = process.env.CCS_DIR; - process.env.CCS_DIR = tempDir; }); afterEach(() => { - if (originalCcsDir !== undefined) { - process.env.CCS_DIR = originalCcsDir; - } else { - delete process.env.CCS_DIR; - } - if (tempDir && fs.existsSync(tempDir)) { fs.rmSync(tempDir, { recursive: true, force: true }); } }); - it('uses configured cliproxy_server.local.port', () => { + it('uses configured cliproxy_server.local.port', async () => { writeUnifiedConfig(9456); - expect(resolveLifecyclePort()).toBe(9456); + await runWithScopedConfigDir(tempDir, () => { + expect(resolveLifecyclePort()).toBe(9456); + }); }); - it('falls back to default port when configured local port is invalid', () => { + it('falls back to default port when configured local port is invalid', async () => { writeUnifiedConfig(70000); - expect(resolveLifecyclePort()).toBe(CLIPROXY_DEFAULT_PORT); + await runWithScopedConfigDir(tempDir, () => { + expect(resolveLifecyclePort()).toBe(CLIPROXY_DEFAULT_PORT); + }); }); - it('falls back to default port when config file is missing', () => { - expect(resolveLifecyclePort()).toBe(CLIPROXY_DEFAULT_PORT); + it('falls back to default port when config file is missing', async () => { + await runWithScopedConfigDir(tempDir, () => { + expect(resolveLifecyclePort()).toBe(CLIPROXY_DEFAULT_PORT); + }); }); }); diff --git a/tests/unit/instance-manager-mcp-sync.test.ts b/tests/unit/instance-manager-mcp-sync.test.ts index 1a39e803..8eec45a7 100644 --- a/tests/unit/instance-manager-mcp-sync.test.ts +++ b/tests/unit/instance-manager-mcp-sync.test.ts @@ -11,6 +11,16 @@ describe('InstanceManager MCP sync', () => { let originalCcsHome: string | undefined; let originalCcsDir: string | undefined; + const claudeDir = () => path.join(tempRoot, '.claude'); + const marketplacePath = (configDir: string, name = 'claude-code-plugins') => + path.join(configDir, 'plugins', 'marketplaces', name); + const readJson = (filePath: string) => + JSON.parse(fs.readFileSync(filePath, 'utf8')) as Record; + + function ensureMarketplacePayload(configDir: string, name = 'claude-code-plugins'): void { + fs.mkdirSync(marketplacePath(configDir, name), { recursive: true }); + } + function writeMarketplaceRegistry(registryPath: string, installLocation: string): void { fs.mkdirSync(path.dirname(registryPath), { recursive: true }); fs.writeFileSync( @@ -28,6 +38,33 @@ describe('InstanceManager MCP sync', () => { ); } + function writeMarketplaceRegistryWithMetadata( + registryPath: string, + installLocation: string, + metadata: Record + ): void { + fs.mkdirSync(path.dirname(registryPath), { recursive: true }); + fs.writeFileSync( + registryPath, + JSON.stringify( + { + 'claude-code-plugins': { + installLocation, + ...metadata, + }, + }, + null, + 2 + ), + 'utf8' + ); + } + + function expectMarketplaceLocation(registryPath: string, expectedLocation: string): void { + const parsed = readJson(registryPath) as Record; + expect(parsed['claude-code-plugins']?.installLocation).toBe(expectedLocation); + } + beforeEach(() => { tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-instance-mcp-test-')); originalHome = process.env.HOME; @@ -95,9 +132,10 @@ describe('InstanceManager MCP sync', () => { const synced = manager.syncMcpServers(instancePath); expect(synced).toBe(true); - const instanceContent = JSON.parse( - fs.readFileSync(path.join(instancePath, '.claude.json'), 'utf8') - ); + const instanceContent = readJson(path.join(instancePath, '.claude.json')) as { + otherKey: string; + mcpServers: Record; + }; expect(instanceContent.otherKey).toBe('keep-me'); expect(instanceContent.mcpServers).toEqual({ globalOnly: { command: 'global-cmd' }, @@ -121,6 +159,17 @@ describe('InstanceManager MCP sync', () => { expect(String(warnSpy.mock.calls[0]?.[0] || '')).toContain('MCP sync skipped'); }); + it('does not list lock housekeeping as an instance', async () => { + spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined); + spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined); + spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(() => false); + + const manager = new InstanceManager(); + await manager.ensureInstance('work', { mode: 'isolated' }); + + expect(manager.listInstances()).toEqual(['work']); + }); + it('skips shared symlinks and MCP sync for bare instance creation', async () => { const linkSharedSpy = spyOn( SharedManager.prototype, @@ -134,9 +183,10 @@ describe('InstanceManager MCP sync', () => { const manager = new InstanceManager(); const instancePath = manager.getInstancePath('sandbox'); - const sharedRegistryPath = path.join(tempRoot, '.claude', 'plugins', 'known_marketplaces.json'); + const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json'); + ensureMarketplacePayload(claudeDir()); writeMarketplaceRegistry( - sharedRegistryPath, + globalRegistryPath, path.join( tempRoot, '.ccs', @@ -150,20 +200,100 @@ describe('InstanceManager MCP sync', () => { await manager.ensureInstance('sandbox', { mode: 'isolated' }, { bare: true }); - const normalized = JSON.parse(fs.readFileSync(sharedRegistryPath, 'utf8')); - expect(linkSharedSpy).not.toHaveBeenCalled(); expect(fs.existsSync(instancePath)).toBe(true); - expect(normalized['claude-code-plugins'].installLocation).toBe( - path.join(tempRoot, '.claude', 'plugins', 'marketplaces', 'claude-code-plugins') - ); + expectMarketplaceLocation(globalRegistryPath, marketplacePath(claudeDir())); expect(fs.existsSync(path.join(instancePath, 'plugins', 'known_marketplaces.json'))).toBe( false ); expect(syncMcpSpy).not.toHaveBeenCalled(); }); - it('normalizes shared plugin metadata for existing non-bare instances', async () => { + it('detaches existing shared layout when an instance is reopened as bare', async () => { + spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined); + spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined); + const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation( + () => false + ); + + const manager = new InstanceManager(); + const instancePath = await manager.ensureInstance('work', { mode: 'isolated' }); + syncMcpSpy.mockClear(); + + await manager.ensureInstance('work', { mode: 'isolated' }, { bare: true }); + + expect(fs.existsSync(path.join(instancePath, 'settings.json'))).toBe(false); + expect(fs.existsSync(path.join(instancePath, 'commands'))).toBe(false); + expect(fs.existsSync(path.join(instancePath, 'skills'))).toBe(false); + expect(fs.existsSync(path.join(instancePath, 'agents'))).toBe(false); + expect(fs.existsSync(path.join(instancePath, 'plugins'))).toBe(false); + expect(syncMcpSpy).not.toHaveBeenCalled(); + }); + + it('restores the shared layout when a bare-reopened instance is switched back to non-bare', async () => { + spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined); + spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined); + const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation( + () => false + ); + + const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json'); + ensureMarketplacePayload(claudeDir()); + writeMarketplaceRegistry(globalRegistryPath, marketplacePath(claudeDir())); + + const manager = new InstanceManager(); + const instancePath = await manager.ensureInstance('work', { mode: 'isolated' }); + await manager.ensureInstance('work', { mode: 'isolated' }, { bare: true }); + syncMcpSpy.mockClear(); + + await manager.ensureInstance('work', { mode: 'isolated' }); + + expect(fs.lstatSync(path.join(instancePath, 'settings.json')).isSymbolicLink()).toBe(true); + expectMarketplaceLocation( + path.join(instancePath, 'plugins', 'known_marketplaces.json'), + marketplacePath(instancePath) + ); + expect(syncMcpSpy).toHaveBeenCalledWith(instancePath); + }); + + it('preserves genuine bare-local content when re-ensuring a bare instance', async () => { + spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined); + spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined); + const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation( + () => false + ); + + const manager = new InstanceManager(); + const instancePath = manager.getInstancePath('sandbox'); + fs.mkdirSync(path.join(instancePath, 'plugins', 'marketplaces', 'custom-market'), { + recursive: true, + }); + fs.mkdirSync(path.join(instancePath, 'commands'), { recursive: true }); + fs.writeFileSync( + path.join(instancePath, 'settings.json'), + JSON.stringify({ local: true }, null, 2), + 'utf8' + ); + fs.writeFileSync(path.join(instancePath, 'commands', 'local.md'), '# local', 'utf8'); + writeMarketplaceRegistry( + path.join(instancePath, 'plugins', 'known_marketplaces.json'), + marketplacePath(instancePath, 'custom-market') + ); + + await manager.ensureInstance('sandbox', { mode: 'isolated' }, { bare: true }); + + expect(readJson(path.join(instancePath, 'settings.json'))).toEqual({ local: true }); + expect(fs.readFileSync(path.join(instancePath, 'commands', 'local.md'), 'utf8')).toBe( + '# local' + ); + expectMarketplaceLocation( + path.join(instancePath, 'plugins', 'known_marketplaces.json'), + marketplacePath(instancePath, 'custom-market') + ); + expect(syncMcpSpy).not.toHaveBeenCalled(); + }); + + it('rewrites existing non-bare instance marketplace metadata to the instance-local plugin dir', async () => { spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined); spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined); const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation( @@ -172,40 +302,32 @@ describe('InstanceManager MCP sync', () => { const manager = new InstanceManager(); const instancePath = manager.getInstancePath('work'); + ensureMarketplacePayload(claudeDir()); writeMarketplaceRegistry( path.join(instancePath, 'plugins', 'known_marketplaces.json'), - path.join( - tempRoot, - '.ccs', - 'instances', - 'work', - 'plugins', - 'marketplaces', - 'claude-code-plugins' - ) + path.join(tempRoot, '.claude', 'plugins', 'marketplaces', 'claude-code-plugins') ); await manager.ensureInstance('work', { mode: 'isolated' }); - const normalized = JSON.parse( - fs.readFileSync(path.join(instancePath, 'plugins', 'known_marketplaces.json'), 'utf8') - ); - expect(normalized['claude-code-plugins'].installLocation).toBe( - path.join(tempRoot, '.claude', 'plugins', 'marketplaces', 'claude-code-plugins') + expectMarketplaceLocation( + path.join(instancePath, 'plugins', 'known_marketplaces.json'), + marketplacePath(instancePath) ); expect(syncMcpSpy).toHaveBeenCalledWith(instancePath); }); - it('normalizes shared plugin metadata during new non-bare instance creation', async () => { + it('writes new non-bare instance marketplace metadata without clobbering the global copy', async () => { spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined); spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined); const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation( () => false ); - const registryPath = path.join(tempRoot, '.claude', 'plugins', 'known_marketplaces.json'); + const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json'); + ensureMarketplacePayload(claudeDir()); writeMarketplaceRegistry( - registryPath, + globalRegistryPath, path.join( tempRoot, '.ccs', @@ -220,20 +342,110 @@ describe('InstanceManager MCP sync', () => { const manager = new InstanceManager(); const instancePath = await manager.ensureInstance('work', { mode: 'isolated' }); - const expected = path.join( - tempRoot, - '.claude', - 'plugins', - 'marketplaces', - 'claude-code-plugins' + expectMarketplaceLocation(globalRegistryPath, marketplacePath(claudeDir())); + expectMarketplaceLocation( + path.join(instancePath, 'plugins', 'known_marketplaces.json'), + marketplacePath(instancePath) ); - const normalizedShared = JSON.parse(fs.readFileSync(registryPath, 'utf8')); - const normalizedInstance = JSON.parse( - fs.readFileSync(path.join(instancePath, 'plugins', 'known_marketplaces.json'), 'utf8') - ); - - expect(normalizedShared['claude-code-plugins'].installLocation).toBe(expected); - expect(normalizedInstance['claude-code-plugins'].installLocation).toBe(expected); expect(syncMcpSpy).toHaveBeenCalledWith(instancePath); }); + + it('reconciles marketplace metadata across isolated instances without losing refresh fields', async () => { + spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined); + spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined); + + const manager = new InstanceManager(); + ensureMarketplacePayload(claudeDir()); + const workPath = await manager.ensureInstance('work', { mode: 'isolated' }); + const workRegistryPath = path.join(workPath, 'plugins', 'known_marketplaces.json'); + writeMarketplaceRegistryWithMetadata(workRegistryPath, marketplacePath(workPath), { + label: 'Official marketplace', + refreshToken: 'refresh-token', + metadata: { + source: 'refresh-flow', + lastSyncedAt: '2026-03-18T00:00:00Z', + }, + }); + + const personalPath = await manager.ensureInstance('personal', { mode: 'isolated' }); + const workRegistry = readJson(workRegistryPath) as Record< + string, + { + installLocation?: string; + label?: string; + refreshToken?: string; + metadata?: Record; + } + >; + const personalRegistry = readJson(path.join(personalPath, 'plugins', 'known_marketplaces.json')) as Record< + string, + { + installLocation?: string; + label?: string; + refreshToken?: string; + metadata?: Record; + } + >; + + expect(workRegistry['claude-code-plugins']).toMatchObject({ + installLocation: marketplacePath(workPath), + label: 'Official marketplace', + refreshToken: 'refresh-token', + metadata: { + source: 'refresh-flow', + lastSyncedAt: '2026-03-18T00:00:00Z', + }, + }); + expect(personalRegistry['claude-code-plugins']).toMatchObject({ + installLocation: marketplacePath(personalPath), + label: 'Official marketplace', + refreshToken: 'refresh-token', + metadata: { + source: 'refresh-flow', + lastSyncedAt: '2026-03-18T00:00:00Z', + }, + }); + }); + + it('upgrades a legacy shared plugins symlink to an instance-local layout', async () => { + spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined); + spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined); + + const manager = new InstanceManager(); + const legacyPath = manager.getInstancePath('legacy'); + const sharedPluginsPath = path.join(tempRoot, '.ccs', 'shared', 'plugins'); + fs.mkdirSync(sharedPluginsPath, { recursive: true }); + fs.mkdirSync(legacyPath, { recursive: true }); + fs.symlinkSync(sharedPluginsPath, path.join(legacyPath, 'plugins'), 'dir'); + + ensureMarketplacePayload(claudeDir()); + writeMarketplaceRegistryWithMetadata( + path.join(claudeDir(), 'plugins', 'known_marketplaces.json'), + path.join(tempRoot, '.ccs', 'shared', 'plugins', 'marketplaces', 'claude-code-plugins'), + { + label: 'Legacy marketplace', + refreshToken: 'legacy-refresh-token', + } + ); + + await manager.ensureInstance('legacy', { mode: 'isolated' }); + + expect(fs.lstatSync(path.join(legacyPath, 'plugins')).isSymbolicLink()).toBe(false); + expectMarketplaceLocation( + path.join(legacyPath, 'plugins', 'known_marketplaces.json'), + marketplacePath(legacyPath) + ); + + const legacyRegistry = readJson( + path.join(legacyPath, 'plugins', 'known_marketplaces.json') + ) as Record< + string, + { installLocation?: string; label?: string; refreshToken?: string } + >; + expect(legacyRegistry['claude-code-plugins']).toMatchObject({ + installLocation: marketplacePath(legacyPath), + label: 'Legacy marketplace', + refreshToken: 'legacy-refresh-token', + }); + }); }); diff --git a/tests/unit/profile-context-sync-lock.test.ts b/tests/unit/profile-context-sync-lock.test.ts new file mode 100644 index 00000000..323810e7 --- /dev/null +++ b/tests/unit/profile-context-sync-lock.test.ts @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { createHash } from 'crypto'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import ProfileContextSyncLock from '../../src/management/profile-context-sync-lock'; + +describe('ProfileContextSyncLock', () => { + let tempRoot = ''; + let instancesDir = ''; + + const getLockPath = (lockName: string): string => { + const safeName = lockName.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase(); + const profileHash = createHash('sha1').update(lockName).digest('hex').slice(0, 8); + return path.join(instancesDir, '.locks', `${safeName}-${profileHash}.lock`); + }; + + beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-context-lock-test-')); + instancesDir = path.join(tempRoot, 'instances'); + fs.mkdirSync(instancesDir, { recursive: true }); + }); + + afterEach(() => { + if (tempRoot && fs.existsSync(tempRoot)) { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('acquires and releases synchronous named locks', () => { + const lock = new ProfileContextSyncLock(instancesDir); + const lockPath = getLockPath('__plugin-layout__'); + + let sawLockInsideCallback = false; + const result = lock.withNamedLockSync('__plugin-layout__', () => { + sawLockInsideCallback = fs.existsSync(lockPath); + expect(fs.readFileSync(lockPath, 'utf8')).toContain(`"pid":${process.pid}`); + return 'ok'; + }); + + expect(result).toBe('ok'); + expect(sawLockInsideCallback).toBe(true); + expect(fs.existsSync(lockPath)).toBe(false); + }); + + it('releases synchronous named locks when the callback throws', () => { + const lock = new ProfileContextSyncLock(instancesDir); + const lockPath = getLockPath('__plugin-layout__'); + + expect(() => + lock.withNamedLockSync('__plugin-layout__', () => { + throw new Error('boom'); + }) + ).toThrow('boom'); + expect(fs.existsSync(lockPath)).toBe(false); + }); + + it('reclaims dead-owner locks before entering the callback', () => { + const lock = new ProfileContextSyncLock(instancesDir); + const lockPath = getLockPath('__plugin-layout__'); + + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + fs.writeFileSync( + lockPath, + JSON.stringify({ + version: 1, + pid: 999999, + nonce: 'dead-owner', + acquiredAtMs: Date.now() - 1000, + }), + 'utf8' + ); + + const result = lock.withNamedLockSync('__plugin-layout__', () => 'reclaimed'); + + expect(result).toBe('reclaimed'); + expect(fs.existsSync(lockPath)).toBe(false); + }); + + it('reclaims malformed stale locks before entering the callback', () => { + const lock = new ProfileContextSyncLock(instancesDir); + const lockPath = getLockPath('__plugin-layout__'); + const staleDate = new Date(Date.now() - 60_000); + + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + fs.writeFileSync(lockPath, 'not-json', 'utf8'); + fs.utimesSync(lockPath, staleDate, staleDate); + + const result = lock.withNamedLockSync('__plugin-layout__', () => 'stale-reclaimed'); + + expect(result).toBe('stale-reclaimed'); + expect(fs.existsSync(lockPath)).toBe(false); + }); +}); diff --git a/tests/unit/shared-manager.test.ts b/tests/unit/shared-manager.test.ts index 96ee5eee..3cd142f8 100644 --- a/tests/unit/shared-manager.test.ts +++ b/tests/unit/shared-manager.test.ts @@ -1,19 +1,12 @@ -/** - * Unit tests for SharedManager - plugin registry path normalization - */ -import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'; import * as fs from 'fs'; -import * as path from 'path'; import * as os from 'os'; +import * as path from 'path'; import SharedManager, { + normalizePluginMetadataContent, normalizePluginMetadataPathString, } from '../../src/management/shared-manager'; -// Test the normalization regex pattern directly -const normalizePluginPaths = (content: string): string => { - return normalizePluginMetadataPathString(content); -}; - describe('SharedManager', () => { let tempRoot = ''; let originalHome: string | undefined; @@ -21,6 +14,28 @@ describe('SharedManager', () => { let originalCcsDir: string | undefined; let originalPlatform: PropertyDescriptor | undefined; + const claudeDir = () => path.join(tempRoot, '.claude'); + const ccsDir = () => path.join(tempRoot, '.ccs'); + const instanceDir = (name: string) => path.join(ccsDir(), 'instances', name); + const marketplacePath = (configDir: string, name = 'claude-code-plugins') => + path.join(configDir, 'plugins', 'marketplaces', name); + const readJson = (filePath: string) => + JSON.parse(fs.readFileSync(filePath, 'utf8')) as Record; + + function ensureMarketplacePayload(configDir: string, name = 'claude-code-plugins'): void { + fs.mkdirSync(marketplacePath(configDir, name), { recursive: true }); + } + + function writeJson(filePath: string, value: unknown): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8'); + } + + function readMarketplaceLocation(filePath: string, name = 'claude-code-plugins'): string { + const parsed = readJson(filePath) as Record; + return parsed[name]?.installLocation ?? ''; + } + beforeEach(() => { tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-shared-manager-test-')); originalHome = process.env.HOME; @@ -55,218 +70,244 @@ describe('SharedManager', () => { } }); - describe('normalizePluginRegistryPaths', () => { - describe('regex pattern', () => { - it('should replace instance paths with canonical claude path', () => { - const input = '/home/user/.ccs/instances/ck/plugins/cache/plugin/0.0.2'; - const expected = '/home/user/.claude/plugins/cache/plugin/0.0.2'; - expect(normalizePluginPaths(input)).toBe(expected); - }); + describe('plugin metadata path normalization', () => { + it('rewrites instance plugin paths to the requested target config dir', () => { + const targetConfigDir = path.join('/home/user', '.claude'); + const input = '/home/user/.ccs/instances/work/plugins/cache/plugin/0.0.2'; - it('should handle different instance names', () => { - const inputs = [ - '/home/user/.ccs/instances/work/plugins/cache/plugin/1.0.0', - '/home/user/.ccs/instances/personal/plugins/cache/plugin/1.0.0', - '/home/user/.ccs/instances/test-account/plugins/cache/plugin/1.0.0', - ]; - for (const input of inputs) { - expect(normalizePluginPaths(input)).toContain('/.claude/'); - expect(normalizePluginPaths(input)).not.toContain('/.ccs/instances/'); - } - }); + expect(normalizePluginMetadataPathString(input, targetConfigDir)).toBe( + '/home/user/.claude/plugins/cache/plugin/0.0.2' + ); + }); - it('should handle multiple occurrences', () => { - const input = JSON.stringify({ + it('rewrites shared plugin paths to an instance-local target config dir', () => { + const targetConfigDir = instanceDir('personal'); + const input = path.join(tempRoot, '.ccs', 'shared', 'plugins', 'marketplaces', 'official'); + + expect(normalizePluginMetadataPathString(input, targetConfigDir)).toBe( + marketplacePath(targetConfigDir, 'official') + ); + }); + + it('normalizes all matching JSON string values without changing the structure', () => { + const targetConfigDir = instanceDir('work'); + const input = JSON.stringify( + { plugins: { - 'plugin-a': [{ installPath: '/home/user/.ccs/instances/ck/plugins/a' }], - 'plugin-b': [{ installPath: '/home/user/.ccs/instances/work/plugins/b' }], - }, - }); - const result = normalizePluginPaths(input); - expect(result).not.toContain('/.ccs/instances/'); - expect(result.match(/\.claude/g)?.length).toBe(2); - }); - - it('should not modify already-canonical paths', () => { - const input = '/home/user/.claude/plugins/cache/plugin/0.0.2'; - expect(normalizePluginPaths(input)).toBe(input); - }); - - it('should be idempotent', () => { - const input = '/home/user/.ccs/instances/ck/plugins/cache/plugin/0.0.2'; - const first = normalizePluginPaths(input); - const second = normalizePluginPaths(first); - expect(first).toBe(second); - }); - - it('should preserve JSON structure', () => { - const original = { - version: 2, - plugins: { - 'claude-hud@claude-hud': [ + 'plugin-a': [ { - scope: 'user', - installPath: - '/home/kai/.ccs/instances/ck/plugins/cache/claude-hud/claude-hud/0.0.2', - version: '0.0.2', + installPath: path.join( + tempRoot, + '.ccs', + 'instances', + 'old', + 'plugins', + 'cache', + 'plugin-a' + ), }, ], }, - }; - const input = JSON.stringify(original, null, 2); - const result = normalizePluginPaths(input); - - // Should be valid JSON - expect(() => JSON.parse(result)).not.toThrow(); - - // Should have normalized path - const parsed = JSON.parse(result); - expect(parsed.plugins['claude-hud@claude-hud'][0].installPath).toBe( - '/home/kai/.claude/plugins/cache/claude-hud/claude-hud/0.0.2' - ); - }); - - it('should normalize marketplace installLocation values', () => { - const original = { - 'claude-code-plugins': { - installLocation: - '/home/kai/.ccs/instances/work/plugins/marketplaces/claude-code-plugins', + marketplaces: { + official: { + installLocation: path.join( + tempRoot, + '.claude', + 'plugins', + 'marketplaces', + 'official' + ), + }, }, - }; - const input = JSON.stringify(original, null, 2); - const result = normalizePluginPaths(input); + }, + null, + 2 + ); - expect(() => JSON.parse(result)).not.toThrow(); + const normalized = JSON.parse(normalizePluginMetadataContent(input, targetConfigDir)) as { + plugins: { 'plugin-a': [{ installPath: string }] }; + marketplaces: { official: { installLocation: string } }; + }; - const parsed = JSON.parse(result); - expect(parsed['claude-code-plugins'].installLocation).toBe( - '/home/kai/.claude/plugins/marketplaces/claude-code-plugins' - ); - }); + expect(normalized.plugins['plugin-a'][0].installPath).toBe( + path.join(targetConfigDir, 'plugins', 'cache', 'plugin-a') + ); + expect(normalized.marketplaces.official.installLocation).toBe( + marketplacePath(targetConfigDir, 'official') + ); }); - describe('edge cases', () => { - it('should handle empty object', () => { - const input = JSON.stringify({}); - expect(normalizePluginPaths(input)).toBe(input); - }); + it('preserves paths already rooted at the target config dir', () => { + const targetConfigDir = instanceDir('work'); + const input = path.join(targetConfigDir, 'plugins', 'cache', 'plugin-a'); - it('should handle plugins without installPath', () => { - const input = JSON.stringify({ plugins: {} }); - expect(normalizePluginPaths(input)).toBe(input); - }); + expect(normalizePluginMetadataPathString(input, targetConfigDir)).toBe(input); + }); - it('should handle Windows-style paths (backslash)', () => { - const input = 'C:\\Users\\user\\.ccs\\instances\\ck\\plugins\\cache'; - expect(normalizePluginPaths(input)).toBe('C:\\Users\\user\\.claude\\plugins\\cache'); - }); + it('handles Windows path separators', () => { + const targetConfigDir = 'C:\\Users\\user\\.claude'; + const input = 'C:\\Users\\user\\.ccs\\instances\\work\\plugins\\marketplaces\\official'; + + expect(normalizePluginMetadataPathString(input, targetConfigDir)).toBe( + 'C:\\Users\\user\\.claude\\plugins\\marketplaces\\official' + ); }); }); - describe('normalizeMarketplaceRegistryPaths', () => { - it('rewrites known_marketplaces.json on disk', () => { - const pluginsDir = path.join(tempRoot, '.claude', 'plugins'); - fs.mkdirSync(pluginsDir, { recursive: true }); + describe('marketplace registry ownership', () => { + it('writes global and instance registries with different authoritative install locations', () => { + const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json'); + ensureMarketplacePayload(claudeDir()); + writeJson(globalRegistryPath, { + 'claude-code-plugins': { + installLocation: path.join( + tempRoot, + '.ccs', + 'instances', + 'work', + 'plugins', + 'marketplaces', + 'claude-code-plugins' + ), + }, + }); - const registryPath = path.join(pluginsDir, 'known_marketplaces.json'); - fs.writeFileSync( - registryPath, - JSON.stringify( - { - 'claude-code-plugins': { - installLocation: - '/home/kai/.ccs/instances/work/plugins/marketplaces/claude-code-plugins', - }, - }, - null, - 2 - ), - 'utf8' - ); + const instancePath = instanceDir('personal'); + fs.mkdirSync(instancePath, { recursive: true }); const manager = new SharedManager(); - manager.normalizeMarketplaceRegistryPaths(); + manager.linkSharedDirectories(instancePath); - const normalized = JSON.parse(fs.readFileSync(registryPath, 'utf8')); - expect(normalized['claude-code-plugins'].installLocation).toBe( - '/home/kai/.claude/plugins/marketplaces/claude-code-plugins' - ); + const instanceRegistryPath = path.join(instancePath, 'plugins', 'known_marketplaces.json'); + expect(readMarketplaceLocation(globalRegistryPath)).toBe(marketplacePath(claudeDir())); + expect(readMarketplaceLocation(instanceRegistryPath)).toBe(marketplacePath(instancePath)); + expect(fs.lstatSync(path.join(instancePath, 'plugins')).isSymbolicLink()).toBe(false); + expect(fs.lstatSync(instanceRegistryPath).isSymbolicLink()).toBe(false); }); - it('rewrites Windows-style known_marketplaces.json paths on disk', () => { - const pluginsDir = path.join(tempRoot, '.claude', 'plugins'); - fs.mkdirSync(pluginsDir, { recursive: true }); - - const registryPath = path.join(pluginsDir, 'known_marketplaces.json'); - fs.writeFileSync( - registryPath, - JSON.stringify( - { - 'claude-code-plugins': { - installLocation: - 'C:\\Users\\kai\\.ccs\\instances\\work\\plugins\\marketplaces\\claude-code-plugins', - }, - }, - null, - 2 - ), - 'utf8' - ); - + it('self-heals missing installLocation from discovered marketplace payloads', () => { const manager = new SharedManager(); - manager.normalizeMarketplaceRegistryPaths(); + const instancePath = instanceDir('work'); + fs.mkdirSync(instancePath, { recursive: true }); + manager.linkSharedDirectories(instancePath); - const normalized = JSON.parse(fs.readFileSync(registryPath, 'utf8')); - expect(normalized['claude-code-plugins'].installLocation).toBe( - 'C:\\Users\\kai\\.claude\\plugins\\marketplaces\\claude-code-plugins' - ); + fs.mkdirSync(marketplacePath(claudeDir()), { recursive: true }); + writeJson(path.join(instancePath, 'plugins', 'known_marketplaces.json'), { + 'claude-code-plugins': { + label: 'Official marketplace', + }, + }); + + manager.normalizeMarketplaceRegistryPaths(instancePath); + + const repaired = readJson( + path.join(instancePath, 'plugins', 'known_marketplaces.json') + ) as Record; + expect(repaired['claude-code-plugins']).toEqual({ + label: 'Official marketplace', + installLocation: marketplacePath(instancePath), + }); }); - it('normalizes copied shared and instance metadata under Windows fallback', () => { + it('prunes stale marketplace entries whose payload directories no longer exist', () => { + const manager = new SharedManager(); + const instancePath = instanceDir('work'); + fs.mkdirSync(instancePath, { recursive: true }); + manager.linkSharedDirectories(instancePath); + + fs.mkdirSync(marketplacePath(claudeDir(), 'claude-code-plugins'), { recursive: true }); + writeJson(path.join(instancePath, 'plugins', 'known_marketplaces.json'), { + 'claude-code-plugins': { + installLocation: marketplacePath(instancePath, 'claude-code-plugins'), + label: 'Official marketplace', + }, + stale: { + installLocation: marketplacePath(instancePath, 'stale'), + label: 'Stale marketplace', + }, + }); + + manager.normalizeMarketplaceRegistryPaths(instancePath); + + const reconciled = readJson( + path.join(instancePath, 'plugins', 'known_marketplaces.json') + ) as Record; + expect(reconciled['claude-code-plugins']).toEqual({ + installLocation: marketplacePath(instancePath, 'claude-code-plugins'), + label: 'Official marketplace', + }); + expect(reconciled.stale).toBeUndefined(); + }); + + it('warns and skips malformed marketplace registries while keeping valid sources', () => { + const manager = new SharedManager(); + const instancePath = instanceDir('work'); + fs.mkdirSync(instancePath, { recursive: true }); + manager.linkSharedDirectories(instancePath); + + const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json'); + ensureMarketplacePayload(claudeDir()); + writeJson(globalRegistryPath, { + 'claude-code-plugins': { + installLocation: path.join( + tempRoot, + '.ccs', + 'instances', + 'work', + 'plugins', + 'marketplaces', + 'claude-code-plugins' + ), + label: 'Official marketplace', + }, + }); + + const malformedRegistryPath = path.join(instancePath, 'plugins', 'known_marketplaces.json'); + fs.writeFileSync(malformedRegistryPath, '{invalid-json', 'utf8'); + const logSpy = spyOn(console, 'log').mockImplementation(() => {}); + + manager.normalizeMarketplaceRegistryPaths(instancePath); + + expect(readMarketplaceLocation(malformedRegistryPath)).toBe(marketplacePath(instancePath)); + expect( + logSpy.mock.calls.some( + ([message]) => + String(message).includes('Skipping malformed marketplace registry') && + String(message).includes(malformedRegistryPath) + ) + ).toBe(true); + }); + + it('keeps the instance-local registry valid under Windows copy fallback', () => { Object.defineProperty(process, 'platform', { value: 'win32' }); spyOn(fs, 'symlinkSync').mockImplementation(() => { throw Object.assign(new Error('simulated symlink failure'), { code: 'EPERM' }); }); - const pluginsDir = path.join(tempRoot, '.claude', 'plugins'); - fs.mkdirSync(pluginsDir, { recursive: true }); + const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json'); + ensureMarketplacePayload(claudeDir()); + writeJson(globalRegistryPath, { + 'claude-code-plugins': { + installLocation: path.join( + tempRoot, + '.claude', + 'plugins', + 'marketplaces', + 'claude-code-plugins' + ), + }, + }); - const registryPath = path.join(pluginsDir, 'known_marketplaces.json'); - fs.writeFileSync( - registryPath, - JSON.stringify( - { - 'claude-code-plugins': { - installLocation: - '/home/kai/.ccs/instances/work/plugins/marketplaces/claude-code-plugins', - }, - }, - null, - 2 - ), - 'utf8' - ); + const instancePath = instanceDir('personal'); + fs.mkdirSync(instancePath, { recursive: true }); const manager = new SharedManager(); - const instancePath = path.join(tempRoot, '.ccs', 'instances', 'personal'); - fs.mkdirSync(instancePath, { recursive: true }); manager.linkSharedDirectories(instancePath); - const expected = '/home/kai/.claude/plugins/marketplaces/claude-code-plugins'; - const claudeRegistry = JSON.parse(fs.readFileSync(registryPath, 'utf8')); - const sharedRegistry = JSON.parse( - fs.readFileSync( - path.join(tempRoot, '.ccs', 'shared', 'plugins', 'known_marketplaces.json'), - 'utf8' - ) - ); - const instanceRegistry = JSON.parse( - fs.readFileSync(path.join(instancePath, 'plugins', 'known_marketplaces.json'), 'utf8') - ); - - expect(claudeRegistry['claude-code-plugins'].installLocation).toBe(expected); - expect(sharedRegistry['claude-code-plugins'].installLocation).toBe(expected); - expect(instanceRegistry['claude-code-plugins'].installLocation).toBe(expected); + const instanceRegistryPath = path.join(instancePath, 'plugins', 'known_marketplaces.json'); + expect(readMarketplaceLocation(globalRegistryPath)).toBe(marketplacePath(claudeDir())); + expect(readMarketplaceLocation(instanceRegistryPath)).toBe(marketplacePath(instancePath)); + expect(fs.existsSync(path.join(instancePath, 'plugins', 'marketplaces'))).toBe(true); }); }); }); diff --git a/tests/unit/web-server/claude-extension-routes.test.ts b/tests/unit/web-server/claude-extension-routes.test.ts index 8dbc458a..753e40fd 100644 --- a/tests/unit/web-server/claude-extension-routes.test.ts +++ b/tests/unit/web-server/claude-extension-routes.test.ts @@ -14,18 +14,30 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import type { Server } from 'http'; -import claudeExtensionRoutes from '../../../src/web-server/routes/claude-extension-routes'; -import SharedManager from '../../../src/management/shared-manager'; -import { createEmptyUnifiedConfig } from '../../../src/config/unified-config-types'; -import { saveUnifiedConfig } from '../../../src/config/unified-config-loader'; describe('web-server claude-extension-routes', () => { let server: Server; let baseUrl = ''; let tempHome = ''; let originalCcsHome: string | undefined; + let originalClaudeConfigDir: string | undefined; + let setGlobalConfigDir: (dir: string | undefined) => void; + let claudeExtensionRoutes: ReturnType; + let SharedManager: typeof import('../../../src/management/shared-manager').default; + let createEmptyUnifiedConfig: typeof import('../../../src/config/unified-config-types').createEmptyUnifiedConfig; + let saveUnifiedConfig: typeof import('../../../src/config/unified-config-loader').saveUnifiedConfig; beforeAll(async () => { + originalCcsHome = process.env.CCS_HOME; + originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + ({ setGlobalConfigDir } = await import('../../../src/utils/config-manager')); + ({ default: SharedManager } = await import('../../../src/management/shared-manager')); + ({ createEmptyUnifiedConfig } = await import('../../../src/config/unified-config-types')); + ({ saveUnifiedConfig } = await import('../../../src/config/unified-config-loader')); + ({ default: claudeExtensionRoutes } = await import( + '../../../src/web-server/routes/claude-extension-routes' + )); + const app = express(); app.use(express.json()); app.use('/api/claude-extension', claudeExtensionRoutes); @@ -48,13 +60,23 @@ describe('web-server claude-extension-routes', () => { }); afterAll(async () => { - await new Promise((resolve) => server.close(() => resolve())); + if (server) { + await new Promise((resolve) => server.close(() => resolve())); + } + setGlobalConfigDir(undefined); + + if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; + else delete process.env.CCS_HOME; + + if (originalClaudeConfigDir !== undefined) process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir; + else delete process.env.CLAUDE_CONFIG_DIR; }); beforeEach(() => { tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-claude-extension-routes-')); - originalCcsHome = process.env.CCS_HOME; process.env.CCS_HOME = tempHome; + process.env.CLAUDE_CONFIG_DIR = path.join(tempHome, '.claude'); + setGlobalConfigDir(path.join(tempHome, '.ccs')); const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); @@ -110,9 +132,9 @@ describe('web-server claude-extension-routes', () => { afterEach(() => { mock.restore(); - - if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; - else delete process.env.CCS_HOME; + setGlobalConfigDir(undefined); + delete process.env.CCS_HOME; + delete process.env.CLAUDE_CONFIG_DIR; if (tempHome && fs.existsSync(tempHome)) { fs.rmSync(tempHome, { recursive: true, force: true }); diff --git a/tests/unit/web-server/cliproxy-stats-routes-install.test.ts b/tests/unit/web-server/cliproxy-stats-routes-install.test.ts index a64465e7..40578506 100644 --- a/tests/unit/web-server/cliproxy-stats-routes-install.test.ts +++ b/tests/unit/web-server/cliproxy-stats-routes-install.test.ts @@ -1,81 +1,57 @@ -import { afterAll, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test'; +import { afterAll, beforeAll, describe, expect, it } from 'bun:test'; import express from 'express'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; import type { Server } from 'http'; -const installSpy = { - calls: 0, -}; - -mock.module('../../../src/config/unified-config-loader', () => ({ - loadOrCreateUnifiedConfig: () => ({ - cliproxy: { backend: 'plus' }, - }), -})); - -mock.module('../../../src/cliproxy/binary-manager', () => ({ - checkCliproxyUpdate: async () => ({ - hasUpdate: false, - currentVersion: '6.6.80', - latestVersion: '6.6.89', - fromCache: false, - checkedAt: Date.now(), - backend: 'plus', - backendLabel: 'CLIProxy Plus', - isStable: true, - maxStableVersion: '9.9.999-0', - }), - getInstalledCliproxyVersion: () => '6.6.80', - installCliproxyVersion: async () => {}, -})); - -mock.module('../../../src/cliproxy/binary/version-checker', () => ({ - fetchAllVersions: async () => ({ - versions: ['6.6.89', '6.6.88', '6.6.81', '6.6.80'], - latestStable: '6.6.89', - latest: '6.6.89', - fromCache: false, - checkedAt: Date.now(), - }), - isNewerVersion: (version: string, maxStable: string) => { - const normalize = (value: string) => value.replace(/-\d+$/, '').split('.').map(Number); - const versionParts = normalize(version); - const maxStableParts = normalize(maxStable); - - for (let index = 0; index < 3; index += 1) { - const versionPart = versionParts[index] || 0; - const maxStablePart = maxStableParts[index] || 0; - - if (versionPart > maxStablePart) return true; - if (versionPart < maxStablePart) return false; - } - - return false; - }, - isVersionFaulty: (version: string) => - ['6.6.81', '6.6.82', '6.6.83', '6.6.84', '6.6.85', '6.6.86', '6.6.87', '6.6.88'].includes( - version - ), -})); - -mock.module('../../../src/web-server/services/cliproxy-dashboard-install-service', () => ({ - installDashboardCliproxyVersion: async () => { - installSpy.calls += 1; - return { - success: true, - restarted: true, - port: 8317, - message: 'installed', - }; - }, -})); - let cliproxyStatsRoutes: typeof import('../../../src/web-server/routes/cliproxy-stats-routes').default; +let createEmptyUnifiedConfig: typeof import('../../../src/config/unified-config-types').createEmptyUnifiedConfig; +let saveUnifiedConfig: typeof import('../../../src/config/unified-config-loader').saveUnifiedConfig; +let setGlobalConfigDir: typeof import('../../../src/utils/config-manager').setGlobalConfigDir; +let writeInstalledVersion: typeof import('../../../src/cliproxy/binary/version-cache').writeInstalledVersion; +let writeVersionListCache: typeof import('../../../src/cliproxy/binary/version-cache').writeVersionListCache; + let server: Server; let baseUrl = ''; +let tempHome = ''; +let originalCcsHome: string | undefined; beforeAll(async () => { - cliproxyStatsRoutes = (await import('../../../src/web-server/routes/cliproxy-stats-routes')) - .default; + originalCcsHome = process.env.CCS_HOME; + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-install-route-')); + process.env.CCS_HOME = tempHome; + + ({ setGlobalConfigDir } = await import('../../../src/utils/config-manager')); + ({ createEmptyUnifiedConfig } = await import('../../../src/config/unified-config-types')); + ({ saveUnifiedConfig } = await import('../../../src/config/unified-config-loader')); + ({ writeInstalledVersion, writeVersionListCache } = await import( + '../../../src/cliproxy/binary/version-cache' + )); + + const ccsDir = path.join(tempHome, '.ccs'); + const plusBinDir = path.join(ccsDir, 'cliproxy', 'bin', 'plus'); + fs.mkdirSync(plusBinDir, { recursive: true }); + setGlobalConfigDir(ccsDir); + + const config = createEmptyUnifiedConfig(); + config.cliproxy = { backend: 'plus' }; + saveUnifiedConfig(config); + + writeInstalledVersion(plusBinDir, '6.6.80'); + writeVersionListCache( + { + versions: ['6.6.89', '6.6.88', '6.6.81', '6.6.80'], + latestStable: '6.6.89', + latest: '6.6.89', + checkedAt: Date.now(), + }, + 'plus' + ); + + ({ default: cliproxyStatsRoutes } = await import( + '../../../src/web-server/routes/cliproxy-stats-routes' + )); const app = express(); app.use(express.json()); @@ -95,16 +71,26 @@ beforeAll(async () => { if (!address || typeof address === 'string') { throw new Error('Unable to resolve test server port'); } + baseUrl = `http://127.0.0.1:${address.port}`; }); -beforeEach(() => { - installSpy.calls = 0; -}); - afterAll(async () => { - await new Promise((resolve) => server.close(() => resolve())); - mock.restore(); + if (server) { + await new Promise((resolve) => server.close(() => resolve())); + } + + setGlobalConfigDir(undefined); + + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } }); describe('cliproxy-stats-routes install contract', () => { @@ -120,7 +106,7 @@ describe('cliproxy-stats-routes install contract', () => { expect(body.faultyRange).toEqual({ min: '6.6.81-0', max: '6.6.88-0' }); }); - it('returns faulty confirmation metadata without calling the installer', async () => { + it('returns faulty confirmation metadata without attempting the install', async () => { const response = await fetch(`${baseUrl}/api/cliproxy/install`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -140,10 +126,9 @@ describe('cliproxy-stats-routes install contract', () => { expect(body.isFaulty).toBe(true); expect(body.isExperimental).toBe(false); expect(body.message).toContain('known bugs'); - expect(installSpy.calls).toBe(0); }); - it('returns experimental confirmation metadata without calling the installer', async () => { + it('returns experimental confirmation metadata without attempting the install', async () => { const response = await fetch(`${baseUrl}/api/cliproxy/install`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -163,6 +148,5 @@ describe('cliproxy-stats-routes install contract', () => { expect(body.isFaulty).toBe(false); expect(body.isExperimental).toBe(true); expect(body.message).toContain('experimental'); - expect(installSpy.calls).toBe(0); }); }); diff --git a/tests/unit/web-server/cliproxy-stats-routes-model-update.test.ts b/tests/unit/web-server/cliproxy-stats-routes-model-update.test.ts index 09029bd8..84cba3c9 100644 --- a/tests/unit/web-server/cliproxy-stats-routes-model-update.test.ts +++ b/tests/unit/web-server/cliproxy-stats-routes-model-update.test.ts @@ -4,7 +4,6 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import type { Server } from 'http'; -import cliproxyStatsRoutes from '../../../src/web-server/routes/cliproxy-stats-routes'; function writeSettings(filePath: string, env: Record): void { fs.mkdirSync(path.dirname(filePath), { recursive: true }); @@ -16,8 +15,16 @@ describe('cliproxy-stats-routes model update canonicalization', () => { let baseUrl = ''; let tempHome = ''; let originalCcsHome: string | undefined; + let setGlobalConfigDir: (dir: string | undefined) => void; + let cliproxyStatsRoutes: ReturnType; beforeAll(async () => { + originalCcsHome = process.env.CCS_HOME; + ({ setGlobalConfigDir } = await import('../../../src/utils/config-manager')); + ({ default: cliproxyStatsRoutes } = await import( + '../../../src/web-server/routes/cliproxy-stats-routes' + )); + const app = express(); app.use(express.json()); app.use('/api/cliproxy', cliproxyStatsRoutes); @@ -41,20 +48,24 @@ describe('cliproxy-stats-routes model update canonicalization', () => { afterAll(async () => { await new Promise((resolve) => server.close(() => resolve())); - }); + setGlobalConfigDir(undefined); - beforeEach(() => { - tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-model-route-')); - originalCcsHome = process.env.CCS_HOME; - process.env.CCS_HOME = tempHome; - }); - - afterEach(() => { if (originalCcsHome !== undefined) { process.env.CCS_HOME = originalCcsHome; } else { delete process.env.CCS_HOME; } + }); + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-model-route-')); + process.env.CCS_HOME = tempHome; + setGlobalConfigDir(path.join(tempHome, '.ccs')); + }); + + afterEach(() => { + setGlobalConfigDir(undefined); + delete process.env.CCS_HOME; if (tempHome && fs.existsSync(tempHome)) { fs.rmSync(tempHome, { recursive: true, force: true }); diff --git a/tests/unit/web-server/cliproxy-usage-syncer.test.ts b/tests/unit/web-server/cliproxy-usage-syncer.test.ts index 1d3f165e..d8f3fa34 100644 --- a/tests/unit/web-server/cliproxy-usage-syncer.test.ts +++ b/tests/unit/web-server/cliproxy-usage-syncer.test.ts @@ -3,15 +3,12 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import type { CliproxyUsageApiResponse } from '../../../src/cliproxy/stats-fetcher'; +import { runWithScopedConfigDir } from '../../../src/utils/config-manager'; let ccsDir = ''; let rawResponse: CliproxyUsageApiResponse | null = null; let fetchCalls = 0; -mock.module('../../../src/utils/config-manager', () => ({ - getCcsDir: () => ccsDir, -})); - mock.module('../../../src/cliproxy/stats-fetcher', () => ({ fetchCliproxyUsageRaw: async () => { fetchCalls++; @@ -69,12 +66,16 @@ afterAll(() => { describe('cliproxy usage syncer', () => { it('writes and loads snapshot data', async () => { - await syncer.syncCliproxyUsage(); + await runWithScopedConfigDir(ccsDir, async () => { + await syncer.syncCliproxyUsage(); + }); const snapshotPath = path.join(ccsDir, 'cache', 'cliproxy-usage', 'latest.json'); expect(fs.existsSync(snapshotPath)).toBe(true); - const cached = await syncer.loadCachedCliproxyData(); + const cached = await runWithScopedConfigDir(ccsDir, async () => { + return await syncer.loadCachedCliproxyData(); + }); expect(cached.daily).toHaveLength(1); expect(cached.daily[0].source).toBe('cliproxy'); expect(cached.daily[0].inputTokens).toBe(100); @@ -82,11 +83,13 @@ describe('cliproxy usage syncer', () => { expect(cached.monthly).toHaveLength(1); }); - it('startCliproxySync is idempotent and starts only one interval', () => { + it('startCliproxySync is idempotent and starts only one interval', async () => { const intervalSpy = spyOn(globalThis, 'setInterval'); - syncer.startCliproxySync(); - syncer.startCliproxySync(); + await runWithScopedConfigDir(ccsDir, async () => { + syncer.startCliproxySync(); + syncer.startCliproxySync(); + }); expect(intervalSpy).toHaveBeenCalledTimes(1); expect(fetchCalls).toBeGreaterThan(0); diff --git a/tests/unit/web-server/profile-routes-cliproxy-bridge.test.ts b/tests/unit/web-server/profile-routes-cliproxy-bridge.test.ts new file mode 100644 index 00000000..d12a28d6 --- /dev/null +++ b/tests/unit/web-server/profile-routes-cliproxy-bridge.test.ts @@ -0,0 +1,99 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test'; +import express from 'express'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import type { Server } from 'http'; +import profileRoutes from '../../../src/web-server/routes/profile-routes'; + +describe('profile-routes cliproxy bridge', () => { + let server: Server; + let baseUrl = ''; + let tempHome = ''; + let originalCcsHome: string | undefined; + + beforeAll(async () => { + const app = express(); + app.use(express.json()); + app.use('/api/profiles', profileRoutes); + + await new Promise((resolve, reject) => { + server = app.listen(0, '127.0.0.1'); + const onError = (error: Error) => reject(error); + server.once('error', onError); + server.once('listening', () => { + server.off('error', onError); + resolve(); + }); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to resolve test server port'); + } + baseUrl = `http://127.0.0.1:${address.port}`; + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-profile-routes-cliproxy-bridge-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('creates a routed CLIProxy-backed API profile and returns bridge metadata', async () => { + const response = await fetch(`${baseUrl}/api/profiles/cliproxy-bridge`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ provider: 'gemini' }), + }); + + expect(response.status).toBe(201); + const body = (await response.json()) as { + name: string; + settingsPath: string; + cliproxyBridge: { provider: string; usesCurrentTarget: boolean }; + }; + expect(body.name).toBe('gemini-api'); + expect(body.settingsPath).toBe('~/.ccs/gemini-api.settings.json'); + expect(body.cliproxyBridge.provider).toBe('gemini'); + expect(body.cliproxyBridge.usesCurrentTarget).toBe(true); + + const settingsPath = path.join(tempHome, '.ccs', 'gemini-api.settings.json'); + expect(fs.existsSync(settingsPath)).toBe(true); + }); + + it('auto-suggests the next routed profile name when the default bridge name is taken', async () => { + const firstResponse = await fetch(`${baseUrl}/api/profiles/cliproxy-bridge`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ provider: 'gemini' }), + }); + expect(firstResponse.status).toBe(201); + + const secondResponse = await fetch(`${baseUrl}/api/profiles/cliproxy-bridge`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ provider: 'gemini' }), + }); + + expect(secondResponse.status).toBe(201); + const body = (await secondResponse.json()) as { name: string }; + expect(body.name).toBe('gemini-api-2'); + }); +}); diff --git a/ui/public/assets/providers/codex-color.svg b/ui/public/assets/providers/codex-color.svg new file mode 100644 index 00000000..c2e67e43 --- /dev/null +++ b/ui/public/assets/providers/codex-color.svg @@ -0,0 +1,24 @@ + + Codex + + + + + + + + + + diff --git a/ui/public/assets/providers/vertex.svg b/ui/public/assets/providers/vertex.svg new file mode 100644 index 00000000..6413e233 --- /dev/null +++ b/ui/public/assets/providers/vertex.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 31b3f343..b065cbc1 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -23,6 +23,9 @@ const ApiPage = lazy(() => import('@/pages/api').then((m) => ({ default: m.ApiPa const CliproxyPage = lazy(() => import('@/pages/cliproxy').then((m) => ({ default: m.CliproxyPage })) ); +const CliproxyAiProvidersPage = lazy(() => + import('@/pages/cliproxy-ai-providers').then((m) => ({ default: m.CliproxyAiProvidersPage })) +); const CliproxyControlPanelPage = lazy(() => import('@/pages/cliproxy-control-panel').then((m) => ({ default: m.CliproxyControlPanelPage })) ); @@ -98,6 +101,14 @@ export default function App() { } /> + }> + + + } + /> void; +} + +function getStatusState(status: AiProviderFamilyState['status']) { + switch (status) { + case 'ready': + return { + icon: Check, + text: 'Ready', + className: 'text-green-600', + }; + case 'partial': + return { + icon: AlertCircle, + text: 'Needs attention', + className: 'text-amber-600', + }; + default: + return { + icon: Circle, + text: 'Not configured', + className: 'text-muted-foreground', + }; + } +} + +export function FamilyRail({ families, selectedFamily, onSelect }: FamilyRailProps) { + return ( +
+ {families.map((family) => { + const isSelected = family.id === selectedFamily; + const statusState = getStatusState(family.status); + const StatusIcon = statusState.icon; + + return ( + + ); + })} +
+ ); +} diff --git a/ui/src/components/cliproxy/ai-providers/index.ts b/ui/src/components/cliproxy/ai-providers/index.ts new file mode 100644 index 00000000..c3747f41 --- /dev/null +++ b/ui/src/components/cliproxy/ai-providers/index.ts @@ -0,0 +1,3 @@ +export { FamilyRail } from './family-rail'; +export { ProviderEntryCard } from './provider-entry-card'; +export { ProviderEntryDialog } from './provider-entry-dialog'; diff --git a/ui/src/components/cliproxy/ai-providers/provider-entry-card.tsx b/ui/src/components/cliproxy/ai-providers/provider-entry-card.tsx new file mode 100644 index 00000000..7457caae --- /dev/null +++ b/ui/src/components/cliproxy/ai-providers/provider-entry-card.tsx @@ -0,0 +1,291 @@ +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import type { + AiProviderEntryView, + AiProviderFamilyState, +} from '../../../../../src/cliproxy/ai-providers'; +import { Check, ChevronRight, Circle, KeyRound, Pencil, Trash2 } from 'lucide-react'; + +interface ProviderEntryCardProps { + family: AiProviderFamilyState; + entry: AiProviderEntryView; + onEdit: () => void; + onDelete: () => void; + onSelect?: () => void; + isSelected?: boolean; + variant?: 'row' | 'detail'; +} + +function renderCountLabel(count: number, singular: string, plural = `${singular}s`) { + return `${count} ${count === 1 ? singular : plural}`; +} + +function renderSecretBadge(entry: AiProviderEntryView) { + return ( + + {entry.secretConfigured ? 'Configured' : 'Missing secret'} + + ); +} + +export function ProviderEntryCard({ + family, + entry, + onEdit, + onDelete, + onSelect, + isSelected = false, + variant = 'detail', +}: ProviderEntryCardProps) { + const hasAdvancedRouting = entry.prefix || entry.proxyUrl || entry.excludedModels.length > 0; + + if (variant === 'row') { + return ( +
{ + if (!onSelect) return; + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onSelect(); + } + }} + > +
+
+ +
+ +
+
+ {entry.label} + {renderSecretBadge(entry)} +
+
+ {entry.baseUrl || family.routePath} +
+
+ + {renderCountLabel(entry.models.length, 'alias')} + + + {renderCountLabel(entry.headers.length, 'header')} + + {entry.excludedModels.length > 0 && ( + + {renderCountLabel(entry.excludedModels.length, 'rule')} + + )} + {(entry.proxyUrl || entry.prefix) && ( + + Routed + + )} +
+
+ +
+ + + {onSelect ? ( +
+ +
+ ) : null} +
+
+
+ ); + } + + return ( +
+
+
+
+

{entry.label}

+ {renderSecretBadge(entry)} +
+

+ Routed through {family.routePath} +

+
+
+ + +
+
+ +
+
+
Secret
+
+ + {entry.apiKeyMasked || entry.apiKeysMasked?.join(', ') || 'Not stored'} +
+
+
+
Base URL
+
+ {entry.baseUrl || 'Default runtime endpoint'} +
+
+
+
Aliases
+
{renderCountLabel(entry.models.length, 'mapping')}
+
+
+
Headers
+
{renderCountLabel(entry.headers.length, 'header')}
+
+
+ + {hasAdvancedRouting && ( +
+ {entry.prefix && Prefix {entry.prefix}} + {entry.proxyUrl && Proxy URL set} + {entry.excludedModels.length > 0 && ( + + {renderCountLabel(entry.excludedModels.length, 'excluded model')} + + )} +
+ )} + + {(entry.models.length > 0 || entry.headers.length > 0 || entry.excludedModels.length > 0) && ( +
+
+
+ Route metadata +
+
+
+ {entry.secretConfigured ? ( + + ) : ( + + )} + + {entry.secretConfigured ? 'Secret stored in CLIProxy' : 'Secret missing'} + +
+
+ {entry.proxyUrl || entry.baseUrl || 'Default runtime endpoint'} +
+
+
+ + {entry.models.length > 0 ? ( +
+
+ Model aliases +
+
+ {entry.models.map((model) => ( +
+ {model.name} + → + {model.alias} +
+ ))} +
+
+ ) : null} + + {entry.headers.length > 0 ? ( +
+
+ Headers +
+
+ {entry.headers.map((header) => ( +
+ {header.key} + : + {header.value} +
+ ))} +
+
+ ) : null} + + {entry.excludedModels.length > 0 ? ( +
+
+ Excluded models +
+
+ {entry.excludedModels.map((model) => ( + + {model} + + ))} +
+
+ ) : null} +
+ )} +
+ ); +} diff --git a/ui/src/components/cliproxy/ai-providers/provider-entry-dialog.tsx b/ui/src/components/cliproxy/ai-providers/provider-entry-dialog.tsx new file mode 100644 index 00000000..d9d602d7 --- /dev/null +++ b/ui/src/components/cliproxy/ai-providers/provider-entry-dialog.tsx @@ -0,0 +1,590 @@ +import { useMemo, useState } from 'react'; +import { ProviderLogo } from '@/components/cliproxy/provider-logo'; +import { getAiProviderFamilyVisual } from '@/lib/provider-config'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { cn } from '@/lib/utils'; +import { ChevronDown, KeyRound, SlidersHorizontal } from 'lucide-react'; +import type { + AiProviderEntryView, + AiProviderFamilyId, + UpsertAiProviderEntryInput, +} from '../../../../../src/cliproxy/ai-providers'; + +interface ProviderEntryDialogProps { + family: AiProviderFamilyId; + entry?: AiProviderEntryView | null; + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (data: UpsertAiProviderEntryInput) => Promise | void; + isSaving: boolean; +} + +type DialogGuide = { + familyName: string; + description: string; + requiredNow: string[]; + optionalLater: string[]; + keyLabel: string; + keyPlaceholder: string; + keyHelper: string; + connectorPlaceholder?: string; + connectorHelper?: string; + baseUrlPlaceholder: string; + baseUrlHelper: string; + aliasesPlaceholder: string; + aliasesHelper: string; + headersPlaceholder: string; +}; + +function getDialogGuide(family: AiProviderFamilyId): DialogGuide { + switch (family) { + case 'gemini-api-key': + return { + familyName: 'Gemini', + description: + 'Store the Gemini key here so CLIProxy can route Gemini requests without creating a separate CCS API Profile.', + requiredNow: [ + 'Paste the Gemini API key.', + 'Leave Base URL empty unless you use a custom Gemini host.', + ], + optionalLater: [ + 'Model mappings only when requested names and Gemini names differ.', + 'Headers only when your provider setup requires them.', + ], + keyLabel: 'Gemini API Key', + keyPlaceholder: 'AIza...', + keyHelper: 'This is the only field most Gemini setups need.', + baseUrlPlaceholder: 'https://generativelanguage.googleapis.com', + baseUrlHelper: 'Optional. Leave blank to keep the default Gemini endpoint.', + aliasesPlaceholder: 'claude-sonnet-4-5=gemini-2.5-pro', + aliasesHelper: + 'Format: requested=upstream. Leave this blank unless the upstream Gemini model name differs.', + headersPlaceholder: 'X-Goog-User-Project: your-project', + }; + case 'codex-api-key': + return { + familyName: 'Codex', + description: + 'Store the Codex or OpenAI key here so CLIProxy can route Codex requests without duplicating the setup in API Profiles.', + requiredNow: [ + 'Paste the Codex or OpenAI API key.', + 'Leave Base URL empty unless this route should target another OpenAI-style endpoint.', + ], + optionalLater: [ + 'Model mappings only when the upstream model ID differs.', + 'Headers only when org or project routing needs them.', + ], + keyLabel: 'Codex API Key', + keyPlaceholder: 'sk-...', + keyHelper: 'This is the only field most Codex setups need.', + baseUrlPlaceholder: 'https://api.openai.com/v1', + baseUrlHelper: 'Optional. Leave blank to keep the default Codex endpoint.', + aliasesPlaceholder: 'claude-sonnet-4-5=gpt-5', + aliasesHelper: + 'Format: requested=upstream. Add a mapping only when the upstream model name differs.', + headersPlaceholder: 'OpenAI-Organization: org_...', + }; + case 'claude-api-key': + return { + familyName: 'Claude', + description: + 'Store the Anthropic or compatible key here for CLIProxy-managed Claude routing. Save the key first, then add rewrites only if this route needs them.', + requiredNow: [ + 'Paste the Claude or Anthropic-compatible API key.', + 'Leave Base URL empty unless this route should target another compatible endpoint.', + ], + optionalLater: [ + 'Model mappings only when the requested and upstream Claude model IDs differ.', + 'Proxy, prefix, exclusions, and headers only for advanced routing cases.', + ], + keyLabel: 'Claude API Key', + keyPlaceholder: 'sk-ant-...', + keyHelper: 'Most Claude routes can start with the key only.', + baseUrlPlaceholder: 'https://api.anthropic.com', + baseUrlHelper: 'Optional. Leave blank to keep the default Claude-compatible endpoint.', + aliasesPlaceholder: 'claude-sonnet-4-5=claude-3-7-sonnet-latest', + aliasesHelper: + 'Format: requested=upstream. Add a mapping only when the upstream model ID should differ.', + headersPlaceholder: 'X-Project: internal-routing', + }; + case 'vertex-api-key': + return { + familyName: 'Vertex', + description: + 'Store the Vertex key here so CLIProxy can route Vertex traffic without creating a separate CCS API Profile.', + requiredNow: [ + 'Paste the Vertex API key.', + 'Leave Base URL empty unless a regional or gateway endpoint is required.', + ], + optionalLater: [ + 'Model mappings only when the upstream name differs.', + 'Headers only when the provider expects extra routing context.', + ], + keyLabel: 'Vertex API Key', + keyPlaceholder: 'AIza...', + keyHelper: 'Most Vertex routes only need the key.', + baseUrlPlaceholder: 'https://vertex.googleapis.com', + baseUrlHelper: 'Optional. Leave blank to keep the default Vertex endpoint.', + aliasesPlaceholder: 'claude-sonnet-4-5=gemini-2.5-pro', + aliasesHelper: + 'Format: requested=upstream. Leave blank unless the upstream model name differs.', + headersPlaceholder: 'X-Goog-User-Project: your-project', + }; + case 'openai-compatibility': + return { + familyName: 'OpenAI-Compatible Connector', + description: + 'Create a named connector for OpenRouter, Together, or any OpenAI-style endpoint. This page owns the connector setup directly inside CLIProxy.', + requiredNow: [ + 'Pick a connector name such as openrouter or together.', + 'Set the connector Base URL.', + 'Add at least one API key before saving.', + ], + optionalLater: [ + 'Model mappings only when requested and upstream model names differ.', + 'Headers only when the connector requires provider-specific auth or routing.', + ], + keyLabel: 'API Keys', + keyPlaceholder: 'sk-...', + keyHelper: 'Add one key per line. Most connectors start with a single key.', + connectorPlaceholder: 'openrouter', + connectorHelper: 'This becomes the connector label in the saved entries list.', + baseUrlPlaceholder: 'https://openrouter.ai/api/v1', + baseUrlHelper: 'Required for connectors. This is the upstream OpenAI-style endpoint.', + aliasesPlaceholder: 'claude-sonnet-4-5=gpt-4.1', + aliasesHelper: + 'Format: requested=upstream. Leave blank unless the connector expects a different model ID.', + headersPlaceholder: 'HTTP-Referer: https://your-app.example', + }; + } +} + +function parseDelimitedLines(value: string): string[] { + return value + .split('\n') + .map((item) => item.trim()) + .filter((item) => item.length > 0); +} + +function parseKeyValueLines(value: string): Array<{ key: string; value: string }> { + return value + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => { + const separator = line.includes(':') ? ':' : '='; + const [key, ...rest] = line.split(separator); + return { key: key.trim(), value: rest.join(separator).trim() }; + }) + .filter((item) => item.key.length > 0); +} + +function parseModelAliasLines(value: string) { + return value + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => { + const separatorIndex = line.indexOf('='); + if (separatorIndex === -1) { + return { name: line.trim(), alias: '' }; + } + + return { + name: line.slice(0, separatorIndex).trim(), + alias: line.slice(separatorIndex + 1).trim(), + }; + }) + .filter((item) => item.name.length > 0 || item.alias.length > 0); +} + +function TextArea({ + value, + onChange, + placeholder, + rows = 4, +}: { + value: string; + onChange: (value: string) => void; + placeholder?: string; + rows?: number; +}) { + return ( +