From 0b394933aec723f77a552b7d951afaf065399a92 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Fri, 6 Feb 2026 23:54:03 -0500 Subject: [PATCH] feat: hybrid CLIProxy catalog sync + agent teams env fix (#486) * fix(teams): propagate CLAUDE_CONFIG_DIR to tmux session for agent teammates When CCS launches Claude with CLAUDE_CONFIG_DIR pointing to the isolated instance path, agent team teammates spawned via tmux split-window don't inherit it because tmux creates new panes from its own session environment. This causes teammates to use ~/.claude/ instead of ~/.ccs/instances/{profile}/, creating a split-brain in shared state (tasks, teams, mailbox). Sets key CCS env vars in the tmux session environment via `tmux setenv` so all new panes inherit the correct config directory. * refactor(teams): use spawnSync array args instead of execSync string Avoids shell interpretation of env values by passing args as array to spawnSync instead of interpolating into a shell command string. * chore(release): 7.38.0-dev.1 [skip ci] * feat(cliproxy): add hybrid catalog sync with CLIProxyAPI (#485) * chore(release): 7.38.0 [skip ci] ## [7.38.0](https://github.com/kaitranntt/ccs/compare/v7.37.1...v7.38.0) (2026-02-07) ### Features * **release:** v7.38.0 - Extended Context, Qwen Models, Bug Fixes ([#480](https://github.com/kaitranntt/ccs/issues/480)) ([b454834](https://github.com/kaitranntt/ccs/commit/b4548341750804339c87c48259dd8741425d2acf)), closes [#472](https://github.com/kaitranntt/ccs/issues/472) [#474](https://github.com/kaitranntt/ccs/issues/474) [#103](https://github.com/kaitranntt/ccs/issues/103) [#478](https://github.com/kaitranntt/ccs/issues/478) [#482](https://github.com/kaitranntt/ccs/issues/482) * feat(cliproxy): add hybrid catalog sync with CLIProxyAPI Add `ccs cliproxy catalog` command family for syncing model catalogs from CLIProxyAPI's model-definitions endpoint. Cached locally with 24h TTL, merges remote models with static catalog preserving broken/deprecated flags. Dashboard API endpoint at /api/cliproxy/catalog. Closes #477 --------- Co-authored-by: semantic-release-bot * chore(reviewer): upgrade review model from opus 4.5 to opus 4.6 - update default model in code-reviewer.ts - update REVIEW_MODEL, ANTHROPIC_MODEL, ANTHROPIC_DEFAULT_OPUS_MODEL in ai-review.yml workflow - aligns reviewer with AGY model catalog default * chore(release): 7.38.0-dev.2 [skip ci] * fix(hooks): add image analysis env vars for settings-based profiles (#484) * chore(release): 7.38.0 [skip ci] ## [7.38.0](https://github.com/kaitranntt/ccs/compare/v7.37.1...v7.38.0) (2026-02-07) ### Features * **release:** v7.38.0 - Extended Context, Qwen Models, Bug Fixes ([#480](https://github.com/kaitranntt/ccs/issues/480)) ([b454834](https://github.com/kaitranntt/ccs/commit/b4548341750804339c87c48259dd8741425d2acf)), closes [#472](https://github.com/kaitranntt/ccs/issues/472) [#474](https://github.com/kaitranntt/ccs/issues/474) [#103](https://github.com/kaitranntt/ccs/issues/103) [#478](https://github.com/kaitranntt/ccs/issues/478) [#482](https://github.com/kaitranntt/ccs/issues/482) * fix(hooks): add image analysis env vars for settings-based profiles Image analysis hook was injected into settings files for API profiles but the CCS_IMAGE_ANALYSIS_* env vars were never set, causing the hook to skip. Add getImageAnalysisHookEnv() call to both settings-based and GLMT proxy execution paths, matching CLIProxy profile behavior. Closes #440 * fix(hooks): add hook env vars to copilot executor Copilot profile had same gap as settings-based profiles: hooks installed but webSearch/imageAnalysis env vars and CCS_PROFILE_TYPE never set. Add missing env var injection matching other profile flows. --------- Co-authored-by: semantic-release-bot * chore(release): 7.38.0-dev.3 [skip ci] --------- Co-authored-by: Carlos Umanzor Co-authored-by: github-actions[bot] Co-authored-by: semantic-release-bot --- .github/workflows/ai-review.yml | 6 +- package.json | 2 +- scripts/code-reviewer.ts | 2 +- src/ccs.ts | 5 + src/cliproxy/catalog-cache.ts | 233 ++++++++++++++++++++ src/cliproxy/index.ts | 13 ++ src/cliproxy/management-api-client.ts | 15 ++ src/cliproxy/management-api-types.ts | 33 +++ src/commands/cliproxy/catalog-subcommand.ts | 142 ++++++++++++ src/commands/cliproxy/help-subcommand.ts | 8 + src/commands/cliproxy/index.ts | 20 ++ src/copilot/copilot-executor.ts | 9 +- src/utils/shell-executor.ts | 17 +- src/web-server/routes/catalog-routes.ts | 26 +++ src/web-server/routes/index.ts | 2 + 15 files changed, 526 insertions(+), 7 deletions(-) create mode 100644 src/cliproxy/catalog-cache.ts create mode 100644 src/commands/cliproxy/catalog-subcommand.ts create mode 100644 src/web-server/routes/catalog-routes.ts diff --git a/.github/workflows/ai-review.yml b/.github/workflows/ai-review.yml index 934f0bb2..a3e86981 100644 --- a/.github/workflows/ai-review.yml +++ b/.github/workflows/ai-review.yml @@ -70,10 +70,10 @@ jobs: # CLIProxy environment for model routing env: ANTHROPIC_BASE_URL: http://localhost:8317 - REVIEW_MODEL: gemini-claude-opus-4-5-thinking + REVIEW_MODEL: gemini-claude-opus-4-6-thinking ANTHROPIC_AUTH_TOKEN: ccs-internal-managed - ANTHROPIC_MODEL: gemini-claude-opus-4-5-thinking - ANTHROPIC_DEFAULT_OPUS_MODEL: gemini-claude-opus-4-5-thinking + ANTHROPIC_MODEL: gemini-claude-opus-4-6-thinking + ANTHROPIC_DEFAULT_OPUS_MODEL: gemini-claude-opus-4-6-thinking ANTHROPIC_DEFAULT_SONNET_MODEL: gemini-claude-sonnet-4-5-thinking ANTHROPIC_DEFAULT_HAIKU_MODEL: gemini-claude-sonnet-4-5 DISABLE_BUG_COMMAND: "1" diff --git a/package.json b/package.json index 0f7739f5..2b00fe32 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.38.0", + "version": "7.38.0-dev.3", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", diff --git a/scripts/code-reviewer.ts b/scripts/code-reviewer.ts index fe81ccc8..31ffa710 100755 --- a/scripts/code-reviewer.ts +++ b/scripts/code-reviewer.ts @@ -26,7 +26,7 @@ interface PRContext { // Config const MAX_DIFF_LINES = 10000; const CLIPROXY_URL = process.env.CLIPROXY_URL || 'http://localhost:8317'; -const MODEL = process.env.REVIEW_MODEL || 'gemini-claude-opus-4-5-thinking'; +const MODEL = process.env.REVIEW_MODEL || 'gemini-claude-opus-4-6-thinking'; // System prompt for code review - new style const CODE_REVIEWER_SYSTEM_PROMPT = `You are the CCS AGY Code Reviewer, an expert AI assistant reviewing pull requests for the CCS CLI project. diff --git a/src/ccs.ts b/src/ccs.ts index 1d447e48..5150ff00 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -14,6 +14,7 @@ import { } from './utils/websearch-manager'; import { getGlobalEnvConfig } from './config/unified-config-loader'; import { ensureProfileHooks as ensureImageAnalyzerHooks } from './utils/hooks/image-analyzer-profile-hook-injector'; +import { getImageAnalysisHookEnv } from './utils/hooks'; import { fail, info } from './utils/ui'; // Import centralized error handling @@ -165,10 +166,12 @@ async function execClaudeWithProxy( const isWindows = process.platform === 'win32'; const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli); const webSearchEnv = getWebSearchHookEnv(); + const imageAnalysisEnv = getImageAnalysisHookEnv(profileName); const env = { ...process.env, ...envVars, ...webSearchEnv, + ...imageAnalysisEnv, CCS_PROFILE_TYPE: 'settings', // Signal to WebSearch hook this is a third-party provider }; @@ -619,6 +622,7 @@ async function main(): Promise { // Use --settings flag (backward compatible) const expandedSettingsPath = getSettingsPath(profileInfo.name); const webSearchEnv = getWebSearchHookEnv(); + const imageAnalysisEnv = getImageAnalysisHookEnv(profileInfo.name); // Get global env vars (DISABLE_TELEMETRY, etc.) for third-party profiles const globalEnvConfig = getGlobalEnvConfig(); const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {}; @@ -640,6 +644,7 @@ async function main(): Promise { ...globalEnv, ...settingsEnv, // Explicitly inject all settings env vars ...webSearchEnv, + ...imageAnalysisEnv, CCS_PROFILE_TYPE: 'settings', // Signal to WebSearch hook this is a third-party provider }; execClaude(claudeCli, ['--settings', expandedSettingsPath, ...remainingArgs], envVars); diff --git a/src/cliproxy/catalog-cache.ts b/src/cliproxy/catalog-cache.ts new file mode 100644 index 00000000..3045bc6a --- /dev/null +++ b/src/cliproxy/catalog-cache.ts @@ -0,0 +1,233 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { getCcsDir } from '../utils/config-manager'; +import type { CLIProxyProvider } from './types'; +import type { ModelEntry, ProviderCatalog, ThinkingSupport } from './model-catalog'; +import { MODEL_CATALOG } from './model-catalog'; +import type { RemoteModelInfo, RemoteThinkingSupport } from './management-api-types'; + +const CACHE_FILE_NAME = 'model-catalog-cache.json'; +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours + +/** Cache structure stored on disk */ +interface CatalogCacheData { + providers: Record; + fetchedAt: number; +} + +/** Channel name → CCS provider mapping */ +const CHANNEL_TO_PROVIDER: Record = { + antigravity: 'agy', + claude: 'claude', + gemini: 'gemini', + codex: 'codex', + qwen: 'qwen', + iflow: 'iflow', +}; + +/** CCS provider → channel name mapping (reverse) */ +export const PROVIDER_TO_CHANNEL: Record = Object.fromEntries( + Object.entries(CHANNEL_TO_PROVIDER).map(([k, v]) => [v, k]) +); + +/** Providers to sync from CLIProxyAPI */ +export const SYNCABLE_PROVIDERS: CLIProxyProvider[] = ['agy', 'gemini', 'codex', 'claude']; + +function getCacheFilePath(): string { + return path.join(getCcsDir(), CACHE_FILE_NAME); +} + +/** Read cached catalog data, null if expired or missing */ +export function getCachedCatalog(): CatalogCacheData | null { + try { + const filePath = getCacheFilePath(); + if (!fs.existsSync(filePath)) return null; + const data = JSON.parse(fs.readFileSync(filePath, 'utf8')) as CatalogCacheData; + if (Date.now() - data.fetchedAt > CACHE_TTL_MS) return null; + return data; + } catch { + return null; + } +} + +/** Save catalog data to cache */ +export function setCachedCatalog(providers: Record): void { + try { + const filePath = getCacheFilePath(); + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify({ providers, fetchedAt: Date.now() })); + } catch { + // Ignore cache write errors + } +} + +/** Delete cache file */ +export function clearCatalogCache(): boolean { + try { + const filePath = getCacheFilePath(); + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + return true; + } + return false; + } catch { + return false; + } +} + +/** Get cache age in human-readable format, or null if no cache */ +export function getCacheAge(): string | null { + try { + const filePath = getCacheFilePath(); + if (!fs.existsSync(filePath)) return null; + const data = JSON.parse(fs.readFileSync(filePath, 'utf8')) as CatalogCacheData; + const ageMs = Date.now() - data.fetchedAt; + const hours = Math.floor(ageMs / (60 * 60 * 1000)); + const minutes = Math.floor((ageMs % (60 * 60 * 1000)) / (60 * 1000)); + if (hours > 0) return `${hours}h ${minutes}m ago`; + return `${minutes}m ago`; + } catch { + return null; + } +} + +/** Map remote thinking support to CCS ThinkingSupport */ +function mapThinking(remote?: RemoteThinkingSupport): ThinkingSupport | undefined { + if (!remote) return undefined; + // If levels are provided, it's a levels-type thinking + if (remote.levels && remote.levels.length > 0) { + return { + type: 'levels', + levels: remote.levels, + dynamicAllowed: remote.dynamic_allowed, + }; + } + // If min/max budget are provided, it's budget-type + if (remote.min !== undefined || remote.max !== undefined) { + return { + type: 'budget', + min: remote.min, + max: remote.max, + zeroAllowed: remote.zero_allowed, + dynamicAllowed: remote.dynamic_allowed, + }; + } + return { type: 'none' }; +} + +/** Map RemoteModelInfo to ModelEntry */ +function mapRemoteToModelEntry(remote: RemoteModelInfo): ModelEntry { + const entry: ModelEntry = { + id: remote.id, + name: remote.display_name || remote.id, + }; + if (remote.description) entry.description = remote.description; + if (remote.context_length && remote.context_length >= 1_000_000) { + entry.extendedContext = true; + } + const thinking = mapThinking(remote.thinking); + if (thinking) entry.thinking = thinking; + return entry; +} + +/** + * Merge remote models with static catalog for a provider. + * Remote fields override static where present. + * Static-only fields preserved: broken, deprecated, deprecationReason, issueUrl, tier. + * Models in static but not in remote → kept. + * Models in remote but not in static → added. + */ +export function mergeCatalog( + provider: CLIProxyProvider, + remoteModels: RemoteModelInfo[] +): ProviderCatalog | undefined { + const staticCatalog = MODEL_CATALOG[provider]; + if (!staticCatalog && remoteModels.length === 0) return undefined; + + const displayName = staticCatalog?.displayName || provider; + const defaultModel = staticCatalog?.defaultModel || (remoteModels[0]?.id ?? ''); + + // Build map of static models by lowercase ID for fast lookup + const staticMap = new Map(); + if (staticCatalog) { + for (const model of staticCatalog.models) { + staticMap.set(model.id.toLowerCase(), model); + } + } + + // Process remote models: merge with static entries + const mergedIds = new Set(); + const mergedModels: ModelEntry[] = []; + + for (const remote of remoteModels) { + const remoteEntry = mapRemoteToModelEntry(remote); + const staticEntry = staticMap.get(remote.id.toLowerCase()); + mergedIds.add(remote.id.toLowerCase()); + + if (staticEntry) { + // Merge: remote overrides, static fills gaps + mergedModels.push({ + ...remoteEntry, + // Preserve static-only fields + tier: staticEntry.tier, + broken: staticEntry.broken, + issueUrl: staticEntry.issueUrl, + deprecated: staticEntry.deprecated, + deprecationReason: staticEntry.deprecationReason, + }); + } else { + mergedModels.push(remoteEntry); + } + } + + // Add static-only models not in remote + if (staticCatalog) { + for (const model of staticCatalog.models) { + if (!mergedIds.has(model.id.toLowerCase())) { + mergedModels.push(model); + } + } + } + + return { + provider, + displayName, + defaultModel, + models: mergedModels, + }; +} + +/** + * Get resolved catalog for a provider. + * Uses cached remote data if available, falls back to static. + */ +export function getResolvedCatalog(provider: CLIProxyProvider): ProviderCatalog | undefined { + const cached = getCachedCatalog(); + if (cached && cached.providers[provider]) { + return mergeCatalog(provider, cached.providers[provider]); + } + return MODEL_CATALOG[provider]; +} + +/** + * Get all resolved catalogs (for Dashboard). + */ +export function getAllResolvedCatalogs(): Partial> { + const result: Partial> = {}; + const cached = getCachedCatalog(); + + // Get all known providers from both static and cache + const providers = new Set(); + for (const p of Object.keys(MODEL_CATALOG) as CLIProxyProvider[]) providers.add(p); + if (cached) { + for (const p of Object.keys(cached.providers) as CLIProxyProvider[]) providers.add(p); + } + + for (const provider of providers) { + const catalog = getResolvedCatalog(provider); + if (catalog) result[provider] = catalog; + } + + return result; +} diff --git a/src/cliproxy/index.ts b/src/cliproxy/index.ts index a504a0f8..0975e3cb 100644 --- a/src/cliproxy/index.ts +++ b/src/cliproxy/index.ts @@ -176,9 +176,22 @@ export type { ManagementApiErrorCode, ClaudeKeyPatch, SyncStatus, + RemoteModelInfo, + RemoteThinkingSupport, } from './management-api-types'; export { ManagementApiClient, createManagementClient } from './management-api-client'; +// Catalog cache (model catalog sync with CLIProxyAPI) +export { + getResolvedCatalog, + getAllResolvedCatalogs, + getCacheAge, + clearCatalogCache, + setCachedCatalog, + SYNCABLE_PROVIDERS, + PROVIDER_TO_CHANNEL, +} from './catalog-cache'; + // Sync module (profile sync to remote CLIProxy) export type { SyncableProfile, SyncPreviewItem } from './sync'; export { diff --git a/src/cliproxy/management-api-client.ts b/src/cliproxy/management-api-client.ts index 52e89a1b..26f9a6ae 100644 --- a/src/cliproxy/management-api-client.ts +++ b/src/cliproxy/management-api-client.ts @@ -13,6 +13,8 @@ import type { ClaudeKey, GetClaudeKeysResponse, ClaudeKeyPatch, + RemoteModelInfo, + GetModelDefinitionsResponse, } from './management-api-types'; /** Default timeout for management operations (longer than health check) */ @@ -200,6 +202,19 @@ export class ManagementApiClient { await this.request('DELETE', `/v0/management/claude-api-key?api-key=${encodedKey}`); } + /** + * Get model definitions for a channel from CLIProxyAPI. + * GET /v0/management/model-definitions/:channel + */ + async getModelDefinitions(channel: string): Promise { + const encodedChannel = encodeURIComponent(channel); + const response = await this.request( + 'GET', + `/v0/management/model-definitions/${encodedChannel}` + ); + return response.data?.models ?? []; + } + /** * Make an HTTP request to the Management API. */ diff --git a/src/cliproxy/management-api-types.ts b/src/cliproxy/management-api-types.ts index f0a9092a..aa8423e5 100644 --- a/src/cliproxy/management-api-types.ts +++ b/src/cliproxy/management-api-types.ts @@ -121,3 +121,36 @@ export interface SyncStatus { /** Remote CLIProxy URL */ remoteUrl?: string; } + +/** + * Remote model thinking support from CLIProxyAPI model-definitions endpoint + */ +export interface RemoteThinkingSupport { + min?: number; + max?: number; + zero_allowed?: boolean; + dynamic_allowed?: boolean; + levels?: string[]; +} + +/** + * Remote model info from CLIProxyAPI model-definitions endpoint + */ +export interface RemoteModelInfo { + id: string; + display_name?: string; + description?: string; + context_length?: number; + max_completion_tokens?: number; + thinking?: RemoteThinkingSupport; + owned_by?: string; + type?: string; +} + +/** + * Response from GET /v0/management/model-definitions/:channel + */ +export interface GetModelDefinitionsResponse { + channel: string; + models: RemoteModelInfo[]; +} diff --git a/src/commands/cliproxy/catalog-subcommand.ts b/src/commands/cliproxy/catalog-subcommand.ts new file mode 100644 index 00000000..8ff99159 --- /dev/null +++ b/src/commands/cliproxy/catalog-subcommand.ts @@ -0,0 +1,142 @@ +import { initUI, header, subheader, color, dim } from '../../utils/ui'; +import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; +import { createManagementClient } from '../../cliproxy/management-api-client'; +import { + getCacheAge, + setCachedCatalog, + clearCatalogCache, + SYNCABLE_PROVIDERS, + PROVIDER_TO_CHANNEL, + getResolvedCatalog, +} from '../../cliproxy/catalog-cache'; +import type { CLIProxyProvider } from '../../cliproxy/types'; +import type { RemoteModelInfo } from '../../cliproxy/management-api-types'; + +/** Fetch model definitions from CLIProxyAPI for all syncable providers */ +async function fetchRemoteCatalogs( + verbose: boolean +): Promise | null> { + const config = loadOrCreateUnifiedConfig(); + const remote = config.cliproxy_server?.remote; + + if (!remote?.host) { + if (verbose) console.log(dim(' No remote CLIProxy configured')); + return null; + } + + const client = createManagementClient(remote); + + // Check health first + const health = await client.health(); + if (!health.healthy) { + console.log(color(` [!] CLIProxy unreachable: ${health.error || 'unknown error'}`, 'warning')); + return null; + } + + if (verbose) { + console.log(dim(` Connected to ${client.getBaseUrl()}`)); + if (health.version) console.log(dim(` CLIProxy version: ${health.version}`)); + } + + const result: Record = {}; + + for (const provider of SYNCABLE_PROVIDERS) { + const channel = PROVIDER_TO_CHANNEL[provider]; + if (!channel) continue; + + try { + const response = await client.getModelDefinitions(channel); + if (response && response.length > 0) { + result[provider] = response; + if (verbose) console.log(dim(` ${provider}: ${response.length} models`)); + } + } catch { + if (verbose) console.log(dim(` ${provider}: fetch failed (skipped)`)); + } + } + + return Object.keys(result).length > 0 ? result : null; +} + +/** Show catalog status */ +export async function handleCatalogStatus(verbose: boolean): Promise { + await initUI(); + console.log(''); + console.log(header('Model Catalog')); + console.log(''); + + const cacheAge = getCacheAge(); + if (cacheAge) { + console.log(` Cache: ${color('synced', 'success')} (${cacheAge})`); + } else { + console.log(` Cache: ${color('static only', 'warning')} (no sync)`); + } + + console.log(''); + console.log(subheader('Providers:')); + + for (const provider of SYNCABLE_PROVIDERS) { + const catalog = getResolvedCatalog(provider); + if (catalog) { + const count = catalog.models.length; + console.log(` ${color(catalog.displayName.padEnd(20), 'command')} ${count} models`); + if (verbose) { + for (const model of catalog.models) { + console.log(dim(` - ${model.id} (${model.name})`)); + } + } + } + } + + console.log(''); + if (!cacheAge) { + console.log(dim(' Run "ccs cliproxy catalog refresh" to sync from CLIProxy')); + } + console.log(''); +} + +/** Refresh catalog from CLIProxyAPI */ +export async function handleCatalogRefresh(verbose: boolean): Promise { + await initUI(); + console.log(''); + console.log(header('Catalog Refresh')); + console.log(''); + + const result = await fetchRemoteCatalogs(verbose); + if (!result) { + console.log(' Failed to fetch remote catalogs. Static catalog unchanged.'); + console.log(''); + return; + } + + setCachedCatalog(result); + + // Show summary + let totalModels = 0; + for (const [provider, models] of Object.entries(result)) { + const merged = getResolvedCatalog(provider as CLIProxyProvider); + const mergedCount = merged?.models.length ?? 0; + console.log( + ` ${color(provider.padEnd(12), 'command')} ${models.length} remote -> ${mergedCount} merged` + ); + totalModels += mergedCount; + } + + console.log(''); + console.log(` ${color('[OK]', 'success')} Catalog synced (${totalModels} total models)`); + console.log(''); +} + +/** Reset catalog cache */ +export async function handleCatalogReset(): Promise { + await initUI(); + console.log(''); + + const cleared = clearCatalogCache(); + if (cleared) { + console.log(` ${color('[OK]', 'success')} Catalog cache cleared. Using static catalog.`); + } else { + console.log(' No cache to clear.'); + } + console.log(''); +} diff --git a/src/commands/cliproxy/help-subcommand.ts b/src/commands/cliproxy/help-subcommand.ts index 74e0a597..1c970232 100644 --- a/src/commands/cliproxy/help-subcommand.ts +++ b/src/commands/cliproxy/help-subcommand.ts @@ -30,6 +30,14 @@ export async function showHelp(): Promise { ['remove ', 'Remove a CLIProxy variant profile'], ], ], + [ + 'Catalog Commands:', + [ + ['catalog', 'Show catalog status (cached vs static)'], + ['catalog refresh', 'Sync models from remote CLIProxy'], + ['catalog reset', 'Clear cache, revert to static catalog'], + ], + ], [ 'Local Sync:', [ diff --git a/src/commands/cliproxy/index.ts b/src/commands/cliproxy/index.ts index 176901c3..dc87d32d 100644 --- a/src/commands/cliproxy/index.ts +++ b/src/commands/cliproxy/index.ts @@ -23,6 +23,11 @@ import { handleCreate, handleRemove } from './variant-subcommand'; import { handleProxyStatus, handleStop } from './proxy-lifecycle-subcommand'; import { showStatus, handleInstallVersion, handleInstallLatest } from './install-subcommand'; import { showHelp } from './help-subcommand'; +import { + handleCatalogStatus, + handleCatalogRefresh, + handleCatalogReset, +} from './catalog-subcommand'; /** * Parse --backend flag from args @@ -166,6 +171,21 @@ export async function handleCliproxyCommand(args: string[]): Promise { return; } + // Catalog commands + if (command === 'catalog') { + const subcommand = remainingArgs[1]; + if (subcommand === 'refresh') { + await handleCatalogRefresh(verbose); + return; + } + if (subcommand === 'reset') { + await handleCatalogReset(); + return; + } + await handleCatalogStatus(verbose); + return; + } + // Sync command if (command === 'sync') { await handleSync(remainingArgs.slice(1)); diff --git a/src/copilot/copilot-executor.ts b/src/copilot/copilot-executor.ts index d2364aba..98908496 100644 --- a/src/copilot/copilot-executor.ts +++ b/src/copilot/copilot-executor.ts @@ -13,6 +13,8 @@ import { isDaemonRunning, startDaemon } from './copilot-daemon'; import { ensureCopilotApi } from './copilot-package-manager'; import { CopilotStatus } from './types'; import { fail, info, ok } from '../utils/ui'; +import { getWebSearchHookEnv } from '../utils/websearch-manager'; +import { getImageAnalysisHookEnv } from '../utils/hooks'; /** * Get full copilot status (auth + daemon). @@ -133,11 +135,16 @@ export async function executeCopilotProfile( const globalEnvConfig = getGlobalEnvConfig(); const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {}; - // Merge with current environment (global env first, copilot overrides) + // Merge with current environment (global env first, copilot overrides, then hook env vars) + const webSearchEnv = getWebSearchHookEnv(); + const imageAnalysisEnv = getImageAnalysisHookEnv('copilot'); const env = { ...process.env, ...globalEnv, ...copilotEnv, + ...webSearchEnv, + ...imageAnalysisEnv, + CCS_PROFILE_TYPE: 'copilot', }; console.log(info(`Using GitHub Copilot proxy (model: ${config.model})`)); diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index e8bb02d9..6afa2620 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -4,7 +4,7 @@ * Cross-platform shell execution utilities for CCS. */ -import { spawn, ChildProcess } from 'child_process'; +import { spawn, spawnSync, ChildProcess } from 'child_process'; import { ErrorManager } from './error-manager'; import { getWebSearchHookEnv } from './websearch-manager'; @@ -82,6 +82,21 @@ export function execClaude( ? { ...baseEnv, ...envVars, ...webSearchEnv } : { ...baseEnv, ...webSearchEnv }; + // propagate key env vars to tmux session so agent team teammates + // (spawned via tmux split-window) inherit the correct config dir + if (process.env.TMUX && envVars) { + const tmuxPropagateVars = ['CLAUDE_CONFIG_DIR', 'CCS_PROFILE_TYPE', 'CCS_WEBSEARCH_SKIP']; + for (const key of tmuxPropagateVars) { + if (envVars[key]) { + try { + spawnSync('tmux', ['setenv', key, envVars[key] ?? ''], { stdio: 'ignore' }); + } catch { + // tmux setenv can fail if not in a tmux session; safe to ignore + } + } + } + } + let child: ChildProcess; if (needsShell) { // When shell needed: concatenate into string to avoid DEP0190 warning diff --git a/src/web-server/routes/catalog-routes.ts b/src/web-server/routes/catalog-routes.ts new file mode 100644 index 00000000..4ff2d9af --- /dev/null +++ b/src/web-server/routes/catalog-routes.ts @@ -0,0 +1,26 @@ +import { Router, Request, Response } from 'express'; +import { getAllResolvedCatalogs, getCacheAge } from '../../cliproxy/catalog-cache'; + +const router = Router(); + +/** + * GET /api/cliproxy/catalog - Get merged model catalogs + * Returns resolved catalogs (cached + static merged) + */ +router.get('/', (_req: Request, res: Response): void => { + try { + const catalogs = getAllResolvedCatalogs(); + const cacheAge = getCacheAge(); + res.json({ + catalogs, + cache: { + synced: cacheAge !== null, + age: cacheAge, + }, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +export default router; diff --git a/src/web-server/routes/index.ts b/src/web-server/routes/index.ts index 6152aafb..c488a9c9 100644 --- a/src/web-server/routes/index.ts +++ b/src/web-server/routes/index.ts @@ -24,6 +24,7 @@ import miscRoutes from './misc-routes'; import cliproxyServerRoutes from './proxy-routes'; import authRoutes from './auth-routes'; import persistRoutes from './persist-routes'; +import catalogRoutes from './catalog-routes'; // Create the main API router export const apiRoutes = Router(); @@ -53,6 +54,7 @@ apiRoutes.use('/cliproxy', variantRoutes); apiRoutes.use('/cliproxy/auth', cliproxyAuthRoutes); apiRoutes.use('/cliproxy', cliproxyStatsRoutes); apiRoutes.use('/cliproxy/sync', cliproxySyncRoutes); +apiRoutes.use('/cliproxy/catalog', catalogRoutes); apiRoutes.use('/cliproxy/openai-compat', providerRoutes); // ==================== WebSearch ====================