diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1fcbb8a6..04485546 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -74,13 +74,13 @@ jobs: fi node scripts/send-discord-release.cjs production "$DISCORD_WEBHOOK_URL" - - name: Cleanup stale labels on released issues + - name: Cleanup labels and close released issues if: success() && steps.release.outputs.released == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | # semantic-release already adds "released" label via .releaserc.cjs - # This step removes transitional labels from issues now in stable + # This step removes transitional labels and closes issues now in stable. # Find issues with both "released" and "released-dev" labels ISSUES=$(gh issue list --label "released" --label "released-dev" --state all --json number --jq '.[].number' 2>/dev/null || echo "") @@ -97,3 +97,13 @@ jobs: echo "Removing pending-release from issue #$NUM" gh issue edit "$NUM" --remove-label "pending-release" --repo "${{ github.repository }}" 2>/dev/null || true done + + # Close open issues that are marked as released + OPEN_RELEASED=$(gh issue list --label "released" --state open --json number --jq '.[].number' 2>/dev/null || echo "") + + for NUM in $OPEN_RELEASED; do + echo "Closing released issue #$NUM" + gh issue close "$NUM" \ + --comment "[bot] Closing issue because this fix/feature is now in stable release (@latest)." \ + --repo "${{ github.repository }}" || true + done diff --git a/package.json b/package.json index 53b518f8..03197061 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.48.1", + "version": "7.48.1-dev.1", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", diff --git a/src/auth/commands/create-command.ts b/src/auth/commands/create-command.ts index 3dd55c84..03668b47 100644 --- a/src/auth/commands/create-command.ts +++ b/src/auth/commands/create-command.ts @@ -42,7 +42,7 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise try { // Create instance directory console.log(info(`Creating profile: ${profileName}`)); - const instancePath = ctx.instanceMgr.ensureInstance(profileName); + const instancePath = await ctx.instanceMgr.ensureInstance(profileName); // Create/update profile entry based on config mode if (isUnifiedMode()) { diff --git a/src/ccs.ts b/src/ccs.ts index 5676fd96..dc673635 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -884,7 +884,7 @@ async function main(): Promise { const instanceMgr = new InstanceManager(); // Ensure instance exists (lazy init if needed) - const instancePath = instanceMgr.ensureInstance(profileInfo.name); + const instancePath = await instanceMgr.ensureInstance(profileInfo.name); // Update last_used timestamp (check unified config first, fallback to legacy) if (registry.hasAccountUnified(profileInfo.name)) { diff --git a/src/cliproxy/config/env-builder.ts b/src/cliproxy/config/env-builder.ts index 277f4d41..92933208 100644 --- a/src/cliproxy/config/env-builder.ts +++ b/src/cliproxy/config/env-builder.ts @@ -28,12 +28,19 @@ import { /** Settings file structure for user overrides */ interface ProviderSettings { env: NodeJS.ProcessEnv; + presets?: Array>; } /** Model name prefix that was deprecated in CLIProxyAPI registry */ const DEPRECATED_MODEL_PREFIX = 'gemini-claude-'; /** Replacement prefix matching actual upstream model names */ const UPSTREAM_MODEL_PREFIX = 'claude-'; +const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i; +const PRESET_MODEL_KEYS = ['default', 'opus', 'sonnet', 'haiku'] as const; + +function stripCodexEffortSuffix(modelId: string): string { + return modelId.replace(CODEX_EFFORT_SUFFIX_REGEX, ''); +} /** * Migrate deprecated gemini-claude-* model names to upstream claude-* names in a settings file. @@ -68,6 +75,58 @@ function migrateDeprecatedModelNames(settingsPath: string, settings: ProviderSet return migrated; } +/** + * Migrate codex effort-suffixed model IDs in settings to canonical IDs. + * Example: gpt-5.3-codex-xhigh -> gpt-5.3-codex + */ +function migrateCodexEffortSuffixes( + settingsPath: string, + provider: CLIProxyProvider, + settings: ProviderSettings +): boolean { + if (provider !== 'codex') return false; + if (!settings.env || typeof settings.env !== 'object') return false; + + let migrated = false; + + for (const key of MODEL_ENV_VAR_KEYS) { + const value = settings.env[key]; + if (typeof value !== 'string') continue; + const canonical = stripCodexEffortSuffix(value); + if (canonical !== value) { + settings.env[key] = canonical; + migrated = true; + } + } + + if (Array.isArray(settings.presets)) { + for (const preset of settings.presets) { + if (!preset || typeof preset !== 'object') continue; + const presetRecord = preset as Record; + + for (const key of PRESET_MODEL_KEYS) { + const value = presetRecord[key]; + if (typeof value !== 'string') continue; + const canonical = stripCodexEffortSuffix(value); + if (canonical !== value) { + presetRecord[key] = canonical; + migrated = true; + } + } + } + } + + if (migrated) { + try { + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', { mode: 0o600 }); + } catch { + // Best-effort migration — don't block startup if write fails + } + } + + return migrated; +} + /** Remote proxy configuration for URL rewriting */ export interface RemoteProxyRewriteConfig { host: string; @@ -285,6 +344,8 @@ export function getEffectiveEnvVars( if (settings.env && typeof settings.env === 'object') { // Migrate deprecated gemini-claude-* model names if present migrateDeprecatedModelNames(expandedPath, settings); + // Migrate codex effort suffixes to canonical IDs if present + migrateCodexEffortSuffixes(expandedPath, provider, settings); // Custom variant settings found - merge with global env envVars = { ...globalEnv, ...settings.env }; // Ensure required vars are present (fall back to defaults if missing) @@ -316,6 +377,8 @@ export function getEffectiveEnvVars( if (settings.env && typeof settings.env === 'object') { // Migrate deprecated gemini-claude-* model names if present migrateDeprecatedModelNames(settingsPath, settings); + // Migrate codex effort suffixes to canonical IDs if present + migrateCodexEffortSuffixes(settingsPath, provider, settings); // User override found - merge with global env envVars = { ...globalEnv, ...settings.env }; // Ensure required vars are present (fall back to defaults if missing) @@ -403,6 +466,7 @@ export function getRemoteEnvVars( const settings: ProviderSettings = JSON.parse(content); if (settings.env && typeof settings.env === 'object') { migrateDeprecatedModelNames(expandedPath, settings); + migrateCodexEffortSuffixes(expandedPath, provider, settings); userEnvVars = settings.env as Record; } } catch { @@ -421,6 +485,7 @@ export function getRemoteEnvVars( const settings: ProviderSettings = JSON.parse(content); if (settings.env && typeof settings.env === 'object') { migrateDeprecatedModelNames(settingsPath, settings); + migrateCodexEffortSuffixes(settingsPath, provider, settings); userEnvVars = settings.env as Record; } } catch { diff --git a/src/cliproxy/config/thinking-config.ts b/src/cliproxy/config/thinking-config.ts index 76e67752..5afe0b3b 100644 --- a/src/cliproxy/config/thinking-config.ts +++ b/src/cliproxy/config/thinking-config.ts @@ -124,6 +124,15 @@ export function getThinkingValueForTier( if (providerOverride) { return providerOverride; } + + // Codex effort aliases historically defaulted to xhigh/high/medium per tier. + // Preserve this behavior when settings are canonicalized (suffix-stripped). + if (provider === 'codex') { + if (tier === 'opus') return 'xhigh'; + if (tier === 'sonnet') return 'high'; + return 'medium'; + } + // Fall back to global tier default (with null guard, uses centralized defaults) return thinkingConfig.tier_defaults?.[tier] ?? DEFAULT_THINKING_TIER_DEFAULTS[tier]; } diff --git a/src/management/instance-manager.ts b/src/management/instance-manager.ts index 2063a39c..a9b273bd 100644 --- a/src/management/instance-manager.ts +++ b/src/management/instance-manager.ts @@ -26,7 +26,7 @@ class InstanceManager { /** * Ensure instance exists for profile (lazy init only) */ - ensureInstance(profileName: string): string { + async ensureInstance(profileName: string): Promise { const instancePath = this.getInstancePath(profileName); // Lazy initialization @@ -37,6 +37,9 @@ class InstanceManager { // Validate structure (auto-fix missing dirs) this.validateInstance(instancePath); + // Keep project memory shared across instances. + await this.sharedManager.syncProjectMemories(instancePath); + return instancePath; } diff --git a/src/management/shared-manager.ts b/src/management/shared-manager.ts index 754e42e7..7871e95d 100644 --- a/src/management/shared-manager.ts +++ b/src/management/shared-manager.ts @@ -201,6 +201,98 @@ class SharedManager { this.normalizePluginRegistryPaths(); } + /** + * Ensure all project memory directories for an instance are shared. + * + * Source layout (isolated): + * ~/.ccs/instances//projects//memory/ + * + * Shared layout (canonical): + * ~/.ccs/shared/memory// + */ + async syncProjectMemories(instancePath: string): Promise { + const projectsDir = path.join(instancePath, 'projects'); + if (!(await this.pathExists(projectsDir))) { + return; + } + + await this.ensureDirectory(this.sharedDir); + + const sharedMemoryRoot = path.join(this.sharedDir, 'memory'); + await this.ensureDirectory(sharedMemoryRoot); + + let projectEntries: fs.Dirent[] = []; + try { + projectEntries = await fs.promises.readdir(projectsDir, { withFileTypes: true }); + } catch (_err) { + return; + } + + const projects = projectEntries.filter((entry) => entry.isDirectory()); + if (projects.length === 0) { + return; + } + + let migrated = 0; + let merged = 0; + let linked = 0; + const instanceName = path.basename(instancePath); + + for (const project of projects) { + const projectDir = path.join(projectsDir, project.name); + const projectMemoryPath = path.join(projectDir, 'memory'); + const sharedProjectMemoryPath = path.join(sharedMemoryRoot, project.name); + + const projectMemoryStats = await this.getLstat(projectMemoryPath); + if (!projectMemoryStats) { + if (await this.ensureProjectMemoryLink(projectMemoryPath, sharedProjectMemoryPath)) { + linked++; + } + continue; + } + + if (projectMemoryStats.isSymbolicLink()) { + if (await this.isSymlinkTarget(projectMemoryPath, sharedProjectMemoryPath)) { + continue; + } + + await fs.promises.unlink(projectMemoryPath); + if (await this.ensureProjectMemoryLink(projectMemoryPath, sharedProjectMemoryPath)) { + linked++; + } + continue; + } + + if (!projectMemoryStats.isDirectory()) { + continue; + } + + if (!(await this.pathExists(sharedProjectMemoryPath))) { + await this.moveDirectory(projectMemoryPath, sharedProjectMemoryPath); + migrated++; + } else { + merged += await this.mergeDirectoryWithConflictCopies( + projectMemoryPath, + sharedProjectMemoryPath, + instanceName + ); + await fs.promises.rm(projectMemoryPath, { recursive: true, force: true }); + } + + if (await this.ensureProjectMemoryLink(projectMemoryPath, sharedProjectMemoryPath)) { + linked++; + } + } + + if (migrated > 0 || merged > 0 || linked > 0) { + console.log( + ok( + `Synced shared project memory: ${migrated} migrated, ${merged} merged conflict(s), ${linked} linked` + ) + ); + } + } + /** * Normalize plugin registry paths to use canonical ~/.claude/ paths * instead of instance-specific ~/.ccs/instances// paths. @@ -420,6 +512,190 @@ class SharedManager { console.log(ok(`Migrated ${migrated} instance(s), skipped ${skipped}`)); } + /** + * Ensure memory path is linked to shared memory root. + * Returns true when a link/copy was created or updated. + */ + private async ensureProjectMemoryLink(linkPath: string, targetPath: string): Promise { + await this.ensureDirectory(targetPath); + + const linkStats = await this.getLstat(linkPath); + if (linkStats) { + if (linkStats.isSymbolicLink() && (await this.isSymlinkTarget(linkPath, targetPath))) { + return false; + } + + if (linkStats.isDirectory()) { + await fs.promises.rm(linkPath, { recursive: true, force: true }); + } else { + await fs.promises.unlink(linkPath); + } + } + + const symlinkType: 'dir' | 'junction' = process.platform === 'win32' ? 'junction' : 'dir'; + const linkTarget = process.platform === 'win32' ? path.resolve(targetPath) : targetPath; + + try { + await fs.promises.symlink(linkTarget, linkPath, symlinkType); + return true; + } catch (_err) { + if (process.platform === 'win32') { + this.copyDirectoryFallback(targetPath, linkPath); + console.log( + warn(`Symlink failed for project memory, copied instead (enable Developer Mode)`) + ); + return true; + } + throw _err; + } + } + + /** + * Check whether symlink points to expected target. + */ + private async isSymlinkTarget(linkPath: string, expectedTarget: string): Promise { + try { + const stats = await fs.promises.lstat(linkPath); + if (!stats.isSymbolicLink()) { + return false; + } + + const currentTarget = await fs.promises.readlink(linkPath); + const resolvedCurrentTarget = path.resolve(path.dirname(linkPath), currentTarget); + const resolvedExpectedTarget = path.resolve(expectedTarget); + return resolvedCurrentTarget === resolvedExpectedTarget; + } catch (_err) { + return false; + } + } + + /** + * Move directory, with cross-device fallback. + */ + private async moveDirectory(src: string, dest: string): Promise { + try { + await fs.promises.rename(src, dest); + } catch (err) { + const error = err as NodeJS.ErrnoException; + if (error.code !== 'EXDEV') { + throw err; + } + + await fs.promises.cp(src, dest, { recursive: true }); + await fs.promises.rm(src, { recursive: true, force: true }); + } + } + + /** + * Merge source into target. On file conflicts, keep target and copy source + * as ".migrated-from-[-N]" to avoid data loss. + */ + private async mergeDirectoryWithConflictCopies( + sourceDir: string, + targetDir: string, + instanceName: string + ): Promise { + await this.ensureDirectory(targetDir); + + let conflicts = 0; + const entries = await fs.promises.readdir(sourceDir, { withFileTypes: true }); + + for (const entry of entries) { + const sourcePath = path.join(sourceDir, entry.name); + const targetPath = path.join(targetDir, entry.name); + + if (entry.isDirectory()) { + conflicts += await this.mergeDirectoryWithConflictCopies( + sourcePath, + targetPath, + instanceName + ); + continue; + } + + if (entry.isFile()) { + if (!(await this.pathExists(targetPath))) { + await fs.promises.copyFile(sourcePath, targetPath); + continue; + } + + if (await this.fileContentsEqual(sourcePath, targetPath)) { + continue; + } + + const conflictPath = await this.getConflictCopyPath(targetPath, instanceName); + await fs.promises.copyFile(sourcePath, conflictPath); + conflicts++; + } + } + + return conflicts; + } + + /** + * Compare two files byte-for-byte. + */ + private async fileContentsEqual(fileA: string, fileB: string): Promise { + try { + const [statA, statB] = await Promise.all([fs.promises.stat(fileA), fs.promises.stat(fileB)]); + if (statA.size !== statB.size) { + return false; + } + + const [contentA, contentB] = await Promise.all([ + fs.promises.readFile(fileA), + fs.promises.readFile(fileB), + ]); + return contentA.equals(contentB); + } catch (_err) { + return false; + } + } + + /** + * Build a non-destructive conflict copy path. + */ + private async getConflictCopyPath( + existingTargetPath: string, + instanceName: string + ): Promise { + const safeInstanceName = instanceName.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase(); + const baseSuffix = `.migrated-from-${safeInstanceName}`; + + let candidate = `${existingTargetPath}${baseSuffix}`; + let sequence = 1; + while (await this.pathExists(candidate)) { + candidate = `${existingTargetPath}${baseSuffix}-${sequence}`; + sequence++; + } + + return candidate; + } + + private async pathExists(targetPath: string): Promise { + try { + await fs.promises.access(targetPath); + return true; + } catch (_err) { + return false; + } + } + + private async ensureDirectory(targetPath: string): Promise { + await fs.promises.mkdir(targetPath, { recursive: true, mode: 0o700 }); + } + + private async getLstat(targetPath: string): Promise { + try { + return await fs.promises.lstat(targetPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + throw err; + } + } + /** * Copy directory as fallback (Windows without Developer Mode) */ diff --git a/src/utils/claude-config-path.ts b/src/utils/claude-config-path.ts index 0ea40972..e084cceb 100644 --- a/src/utils/claude-config-path.ts +++ b/src/utils/claude-config-path.ts @@ -5,7 +5,7 @@ import * as path from 'path'; * Resolve Claude config directory with test/dev overrides. * Precedence: * 1. CLAUDE_CONFIG_DIR (explicit override) - * 2. CCS_HOME compatibility path (/.claude) + * 2. CCS_HOME compatibility path (/.claude) * 3. ~/.claude (default) */ export function getClaudeConfigDir(): string { @@ -14,7 +14,7 @@ export function getClaudeConfigDir(): string { } if (process.env.CCS_HOME) { - return path.join(path.dirname(path.resolve(process.env.CCS_HOME)), '.claude'); + return path.join(path.resolve(process.env.CCS_HOME), '.claude'); } return path.join(os.homedir(), '.claude'); diff --git a/src/web-server/routes/settings-routes.ts b/src/web-server/routes/settings-routes.ts index dc4c5063..f5cfb39b 100644 --- a/src/web-server/routes/settings-routes.ts +++ b/src/web-server/routes/settings-routes.ts @@ -22,6 +22,14 @@ import { deduplicateCcsHooks } from '../../utils/websearch/hook-utils'; import type { Settings } from '../../types/config'; const router = Router(); +const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i; +const MODEL_ENV_KEYS = [ + 'ANTHROPIC_MODEL', + 'ANTHROPIC_DEFAULT_OPUS_MODEL', + 'ANTHROPIC_DEFAULT_SONNET_MODEL', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL', +] as const; +const PRESET_MODEL_KEYS = ['default', 'opus', 'sonnet', 'haiku'] as const; /** * Helper: Resolve settings path for profile or variant @@ -42,6 +50,59 @@ function resolveSettingsPath(profileOrVariant: string): string { return path.join(ccsDir, `${profileOrVariant}.settings.json`); } +function isCodexProfile(profileOrVariant: string): boolean { + if (profileOrVariant.toLowerCase() === 'codex') return true; + const variants = listVariants(); + return variants[profileOrVariant]?.provider === 'codex'; +} + +function stripCodexEffortSuffix(modelId: string): string { + return modelId.replace(CODEX_EFFORT_SUFFIX_REGEX, ''); +} + +function canonicalizeCodexSettings(profileOrVariant: string, settings: Settings): Settings { + if (!isCodexProfile(profileOrVariant)) return settings; + + let changed = false; + const next: Settings = { ...settings }; + + if (settings.env && typeof settings.env === 'object') { + const env = { ...settings.env }; + for (const key of MODEL_ENV_KEYS) { + const value = env[key]; + if (typeof value !== 'string') continue; + const canonical = stripCodexEffortSuffix(value); + if (canonical !== value) { + env[key] = canonical; + changed = true; + } + } + next.env = env; + } + + if (Array.isArray(settings.presets)) { + const normalizedPresets = settings.presets.map((preset) => { + const normalizedPreset = { ...preset }; + let presetChanged = false; + + for (const key of PRESET_MODEL_KEYS) { + const value = normalizedPreset[key]; + const canonical = stripCodexEffortSuffix(value); + if (canonical !== value) { + normalizedPreset[key] = canonical; + presetChanged = true; + } + } + + if (presetChanged) changed = true; + return normalizedPreset; + }); + next.presets = normalizedPresets; + } + + return changed ? next : settings; +} + /** * Helper: Mask API keys in settings */ @@ -73,7 +134,7 @@ router.get('/:profile', (req: Request, res: Response): void => { } const stat = fs.statSync(settingsPath); - const settings = loadSettings(settingsPath); + const settings = canonicalizeCodexSettings(profile, loadSettings(settingsPath)); const masked = maskApiKeys(settings); res.json({ @@ -101,7 +162,7 @@ router.get('/:profile/raw', (req: Request, res: Response): void => { } const stat = fs.statSync(settingsPath); - const settings = loadSettings(settingsPath); + const settings = canonicalizeCodexSettings(profile, loadSettings(settingsPath)); res.json({ profile, @@ -137,14 +198,16 @@ router.put('/:profile', (req: Request, res: Response): void => { return; } + const normalizedSettings = canonicalizeCodexSettings(profile, settings as Settings); + // Deduplicate CCS hooks to prevent accumulation (fixes #450) // This handles cases where duplicate hooks were added by previous versions - deduplicateCcsHooks(settings as Record); + deduplicateCcsHooks(normalizedSettings as Record); const ccsDir = getCcsDir(); // Check for missing required fields (warning, not blocking - runtime fills defaults) - const missingFields = checkRequiredEnvVars(settings); + const missingFields = checkRequiredEnvVars(normalizedSettings); const settingsPath = resolveSettingsPath(profile); const fileExists = fs.existsSync(settingsPath); @@ -163,7 +226,7 @@ router.put('/:profile', (req: Request, res: Response): void => { // Create backup only if file exists AND content actually changed let backupPath: string | undefined; - const newContent = JSON.stringify(settings, null, 2) + '\n'; + const newContent = JSON.stringify(normalizedSettings, null, 2) + '\n'; if (fileExists) { const existingContent = fs.readFileSync(settingsPath, 'utf8'); // Only create backup if content differs @@ -220,7 +283,7 @@ router.get('/:profile/presets', (req: Request, res: Response): void => { return; } - const settings = loadSettings(settingsPath); + const settings = canonicalizeCodexSettings(profile, loadSettings(settingsPath)); res.json({ presets: settings.presets || [] }); } catch (error) { res.status(500).json({ error: (error as Error).message }); @@ -257,12 +320,20 @@ router.post('/:profile/presets', (req: Request, res: Response): void => { return; } + const normalizePresetModel = (modelId: string): string => + isCodexProfile(profile) ? stripCodexEffortSuffix(modelId) : modelId; + + const normalizedDefaultModel = normalizePresetModel(defaultModel); + const normalizedOpusModel = normalizePresetModel(opus || defaultModel); + const normalizedSonnetModel = normalizePresetModel(sonnet || defaultModel); + const normalizedHaikuModel = normalizePresetModel(haiku || defaultModel); + const preset = { name, - default: defaultModel, - opus: opus || defaultModel, - sonnet: sonnet || defaultModel, - haiku: haiku || defaultModel, + default: normalizedDefaultModel, + opus: normalizedOpusModel, + sonnet: normalizedSonnetModel, + haiku: normalizedHaikuModel, }; settings.presets.push(preset); diff --git a/src/web-server/shared-routes.ts b/src/web-server/shared-routes.ts index b6b91ac1..1682cab0 100644 --- a/src/web-server/shared-routes.ts +++ b/src/web-server/shared-routes.ts @@ -69,36 +69,76 @@ function getSharedItems(type: 'commands' | 'skills' | 'agents'): SharedItem[] { } const items: SharedItem[] = []; + const sharedDirRoot = safeRealPath(sharedDir) ?? path.resolve(sharedDir); + const allowedSkillAgentRoots = new Set([ + sharedDirRoot, + ...[ + path.join(getClaudeConfigDir(), type), + path.join(ccsDir, '.claude', type), + path.join(ccsDir, 'shared', type), + ] + .map((dirPath) => safeRealPath(dirPath)) + .filter((dirPath): dirPath is string => typeof dirPath === 'string'), + ]); try { const entries = fs.readdirSync(sharedDir, { withFileTypes: true }); for (const entry of entries) { - if (entry.isDirectory()) { - // Skill/Agent: look for SKILL.md for skills, prompt.md for agents - const markdownFile = type === 'skills' ? 'SKILL.md' : 'prompt.md'; - const promptPath = path.join(sharedDir, entry.name, markdownFile); - if (fs.existsSync(promptPath)) { - const content = fs.readFileSync(promptPath, 'utf8'); - const description = extractDescription(content); + try { + const entryPath = path.join(sharedDir, entry.name); + + if (type === 'commands') { + if (!entry.name.endsWith('.md')) { + continue; + } + if (!entry.isFile() && !entry.isSymbolicLink()) { + continue; + } + + const commandPath = safeRealPath(entryPath); + if (!commandPath || !isPathWithin(commandPath, sharedDirRoot)) { + continue; + } + + const description = readMarkdownDescription(commandPath, sharedDirRoot); + if (!description) { + continue; + } + items.push({ - name: entry.name, + name: entry.name.replace('.md', ''), description, - path: path.join(sharedDir, entry.name), - type: type === 'commands' ? 'command' : (type.slice(0, -1) as 'skill' | 'agent'), + path: entryPath, + type: 'command', }); + continue; } - } else if (entry.name.endsWith('.md')) { - // Command: .md file - const filePath = path.join(sharedDir, entry.name); - const content = fs.readFileSync(filePath, 'utf8'); - const description = extractDescription(content); + + // Skills/agents are directory-based and may be symlinked directories. + if (!entry.isDirectory() && !entry.isSymbolicLink()) { + continue; + } + + const entryRoot = safeRealPath(entryPath); + if (!entryRoot || !isPathWithinAny(entryRoot, allowedSkillAgentRoots)) { + continue; + } + + const markdownFile = type === 'skills' ? 'SKILL.md' : 'prompt.md'; + const description = readMarkdownDescription(path.join(entryRoot, markdownFile), entryRoot); + if (!description) { + continue; + } + items.push({ - name: entry.name.replace('.md', ''), + name: entry.name, description, - path: filePath, - type: 'command', + path: entryPath, + type: type === 'skills' ? 'skill' : 'agent', }); + } catch { + // Fail soft per entry so one bad item does not hide valid results. } } } catch { @@ -120,6 +160,53 @@ function extractDescription(content: string): string { return 'No description'; } +function readMarkdownDescription(markdownPath: string, allowedRoot: string): string | null { + try { + const resolvedMarkdownPath = safeRealPath(markdownPath); + if (!resolvedMarkdownPath || !isPathWithin(resolvedMarkdownPath, allowedRoot)) { + return null; + } + + const stats = fs.statSync(resolvedMarkdownPath); + if (!stats.isFile()) { + return null; + } + const content = fs.readFileSync(resolvedMarkdownPath, 'utf8'); + return extractDescription(content); + } catch { + return null; + } +} + +function safeRealPath(targetPath: string): string | null { + try { + return fs.realpathSync(targetPath); + } catch { + return null; + } +} + +function isPathWithin(candidatePath: string, basePath: string): boolean { + const normalizedCandidate = normalizeForPathComparison(candidatePath); + const normalizedBase = normalizeForPathComparison(basePath); + const relative = path.relative(normalizedBase, normalizedCandidate); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); +} + +function isPathWithinAny(candidatePath: string, basePaths: Set): boolean { + for (const basePath of basePaths) { + if (isPathWithin(candidatePath, basePath)) { + return true; + } + } + return false; +} + +function normalizeForPathComparison(targetPath: string): string { + const normalized = path.resolve(targetPath); + return process.platform === 'win32' ? normalized.toLowerCase() : normalized; +} + function checkSymlinkStatus(): { valid: boolean; message: string } { const ccsDir = getCcsDir(); const sharedDir = path.join(ccsDir, 'shared'); @@ -140,7 +227,7 @@ function checkSymlinkStatus(): { valid: boolean; message: string } { const stats = fs.lstatSync(linkPath); if (stats.isSymbolicLink()) { const target = fs.readlinkSync(linkPath); - // Check if it points to ~/.claude/{linkType} + // Check if it points to Claude config dir. const expectedTarget = path.join(getClaudeConfigDir(), linkType); if (path.resolve(path.dirname(linkPath), target) === path.resolve(expectedTarget)) { validLinks++; diff --git a/tests/unit/cliproxy/env-builder-provider-url.test.ts b/tests/unit/cliproxy/env-builder-provider-url.test.ts index bae0639d..38370495 100644 --- a/tests/unit/cliproxy/env-builder-provider-url.test.ts +++ b/tests/unit/cliproxy/env-builder-provider-url.test.ts @@ -13,8 +13,12 @@ interface EnvSettings { ANTHROPIC_DEFAULT_HAIKU_MODEL: string; } -function writeSettings(settingsPath: string, env: EnvSettings): void { - fs.writeFileSync(settingsPath, JSON.stringify({ env }, null, 2)); +function writeSettings( + settingsPath: string, + env: EnvSettings, + extras?: Record +): void { + fs.writeFileSync(settingsPath, JSON.stringify({ env, ...(extras || {}) }, null, 2)); } describe('getEffectiveEnvVars local provider URL normalization', () => { @@ -42,6 +46,18 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { const env = getEffectiveEnvVars('codex', 8317, settingsPath); expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/codex'); + expect(env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex'); + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex'); + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini'); + + const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { + env: Record; + }; + expect(persisted.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); + expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex'); + expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex'); + expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini'); }); it('rewrites wrong local provider path to the requested provider', () => { @@ -88,4 +104,39 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6-thinking'); expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5'); }); + + it('migrates codex preset model mappings to canonical IDs', () => { + writeSettings( + settingsPath, + { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'gpt-5.3-codex-xhigh', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex-xhigh', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5-mini-medium', + }, + { + presets: [ + { + name: 'legacy-codex', + default: 'gpt-5.3-codex-xhigh', + opus: 'gpt-5.3-codex-xhigh', + sonnet: 'gpt-5.3-codex-high', + haiku: 'gpt-5-mini-medium', + }, + ], + } + ); + + getEffectiveEnvVars('codex', 8317, settingsPath); + + const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { + presets: Array>; + }; + expect(persisted.presets[0]?.default).toBe('gpt-5.3-codex'); + expect(persisted.presets[0]?.opus).toBe('gpt-5.3-codex'); + expect(persisted.presets[0]?.sonnet).toBe('gpt-5.3-codex'); + expect(persisted.presets[0]?.haiku).toBe('gpt-5-mini'); + }); }); diff --git a/tests/unit/shared-memory-sync.test.ts b/tests/unit/shared-memory-sync.test.ts new file mode 100644 index 00000000..a041209b --- /dev/null +++ b/tests/unit/shared-memory-sync.test.ts @@ -0,0 +1,113 @@ +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 SharedManager from '../../src/management/shared-manager'; + +function getTestCcsDir(): string { + if (!process.env.CCS_HOME) { + throw new Error('CCS_HOME must be set in tests'); + } + return path.join(path.resolve(process.env.CCS_HOME), '.ccs'); +} + +describe('SharedManager project memory sync', () => { + let tempRoot = ''; + let originalHome: string | undefined; + let originalCcsHome: string | undefined; + let originalCcsDir: string | undefined; + + beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-shared-memory-test-')); + originalHome = process.env.HOME; + originalCcsHome = process.env.CCS_HOME; + originalCcsDir = process.env.CCS_DIR; + + const isolatedHome = path.join(tempRoot, 'home'); + fs.mkdirSync(isolatedHome, { recursive: true }); + process.env.HOME = isolatedHome; + process.env.CCS_HOME = tempRoot; + delete process.env.CCS_DIR; + }); + + afterEach(() => { + if (originalHome !== undefined) process.env.HOME = originalHome; + else delete process.env.HOME; + + if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; + else delete process.env.CCS_HOME; + + if (originalCcsDir !== undefined) process.env.CCS_DIR = originalCcsDir; + else delete process.env.CCS_DIR; + + if (tempRoot && fs.existsSync(tempRoot)) { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('migrates existing project memory and replaces it with a shared symlink', async () => { + const ccsDir = getTestCcsDir(); + const instancePath = path.join(ccsDir, 'instances', 'work'); + const projectName = '-tmp-my-project'; + const projectMemoryPath = path.join(instancePath, 'projects', projectName, 'memory'); + fs.mkdirSync(projectMemoryPath, { recursive: true }); + fs.writeFileSync(path.join(projectMemoryPath, 'MEMORY.md'), 'instance knowledge', 'utf8'); + + const manager = new SharedManager(); + await manager.syncProjectMemories(instancePath); + + const sharedMemoryFile = path.join(ccsDir, 'shared', 'memory', projectName, 'MEMORY.md'); + expect(fs.existsSync(sharedMemoryFile)).toBe(true); + expect(fs.readFileSync(sharedMemoryFile, 'utf8')).toBe('instance knowledge'); + + const linkStats = fs.lstatSync(projectMemoryPath); + expect(linkStats.isSymbolicLink()).toBe(true); + + const resolvedTarget = path.resolve(path.dirname(projectMemoryPath), fs.readlinkSync(projectMemoryPath)); + expect(resolvedTarget).toBe(path.join(ccsDir, 'shared', 'memory', projectName)); + }); + + it('preserves canonical memory and writes conflict copy when contents differ', async () => { + const ccsDir = getTestCcsDir(); + const instancePath = path.join(ccsDir, 'instances', 'work'); + const projectName = '-tmp-shared-project'; + const projectMemoryPath = path.join(instancePath, 'projects', projectName, 'memory'); + const sharedProjectMemoryPath = path.join(ccsDir, 'shared', 'memory', projectName); + fs.mkdirSync(projectMemoryPath, { recursive: true }); + fs.mkdirSync(sharedProjectMemoryPath, { recursive: true }); + + fs.writeFileSync(path.join(projectMemoryPath, 'MEMORY.md'), 'instance memory', 'utf8'); + fs.writeFileSync(path.join(sharedProjectMemoryPath, 'MEMORY.md'), 'shared memory', 'utf8'); + + const manager = new SharedManager(); + await manager.syncProjectMemories(instancePath); + + const canonicalFile = path.join(sharedProjectMemoryPath, 'MEMORY.md'); + expect(fs.readFileSync(canonicalFile, 'utf8')).toBe('shared memory'); + + const conflictFile = path.join(sharedProjectMemoryPath, 'MEMORY.md.migrated-from-work'); + expect(fs.existsSync(conflictFile)).toBe(true); + expect(fs.readFileSync(conflictFile, 'utf8')).toBe('instance memory'); + + const linkStats = fs.lstatSync(projectMemoryPath); + expect(linkStats.isSymbolicLink()).toBe(true); + }); + + it('creates shared memory link for projects that do not have memory directory yet', async () => { + const ccsDir = getTestCcsDir(); + const instancePath = path.join(ccsDir, 'instances', 'work'); + const projectName = '-tmp-new-project'; + const projectPath = path.join(instancePath, 'projects', projectName); + const projectMemoryPath = path.join(projectPath, 'memory'); + fs.mkdirSync(projectPath, { recursive: true }); + + const manager = new SharedManager(); + await manager.syncProjectMemories(instancePath); + + const linkStats = fs.lstatSync(projectMemoryPath); + expect(linkStats.isSymbolicLink()).toBe(true); + + const sharedProjectMemoryPath = path.join(ccsDir, 'shared', 'memory', projectName); + expect(fs.existsSync(sharedProjectMemoryPath)).toBe(true); + }); +}); diff --git a/tests/unit/web-server/shared-routes.test.ts b/tests/unit/web-server/shared-routes.test.ts new file mode 100644 index 00000000..b1824b16 --- /dev/null +++ b/tests/unit/web-server/shared-routes.test.ts @@ -0,0 +1,330 @@ +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 { sharedRoutes } from '../../../src/web-server/shared-routes'; + +function createDirectorySymlink(targetDir: string, linkPath: string): void { + const symlinkType = process.platform === 'win32' ? 'junction' : 'dir'; + + try { + fs.symlinkSync(targetDir, linkPath, symlinkType as fs.symlink.Type); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'EPERM' || code === 'EACCES') { + throw new Error( + `Symlink creation is not permitted in this environment (${code}) for ${linkPath}` + ); + } + throw error; + } +} + +function createFileSymlink(targetFile: string, linkPath: string): void { + const symlinkType = process.platform === 'win32' ? 'file' : 'file'; + + try { + fs.symlinkSync(targetFile, linkPath, symlinkType as fs.symlink.Type); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'EPERM' || code === 'EACCES') { + throw new Error( + `File symlink creation is not permitted in this environment (${code}) for ${linkPath}` + ); + } + throw error; + } +} + +async function getJson(baseUrl: string, routePath: string): Promise { + const response = await fetch(`${baseUrl}${routePath}`); + expect(response.status).toBe(200); + return (await response.json()) as T; +} + +describe('web-server shared-routes', () => { + let server: Server; + let baseUrl = ''; + let tempHome: string; + let ccsDir: string; + let originalCcsHome: string | undefined; + let originalClaudeConfigDir: string | undefined; + + beforeAll(async () => { + const app = express(); + app.use('/api/shared', sharedRoutes); + + await new Promise((resolve, reject) => { + server = app.listen(0, '127.0.0.1'); + const handleError = (error: Error) => { + reject(error); + }; + + server.once('error', handleError); + server.once('listening', () => { + server.off('error', handleError); + 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-shared-routes-test-')); + originalCcsHome = process.env.CCS_HOME; + originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + process.env.CCS_HOME = tempHome; + delete process.env.CLAUDE_CONFIG_DIR; + + ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(path.join(ccsDir, 'shared'), { recursive: true }); + }); + + afterEach(() => { + 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; + } + + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('lists symlinked skill directories', async () => { + const sharedSkillsDir = path.join(ccsDir, 'shared', 'skills'); + fs.mkdirSync(sharedSkillsDir, { recursive: true }); + const targetDir = path.join(ccsDir, '.claude', 'skills', 'my-skill'); + fs.mkdirSync(targetDir, { recursive: true }); + fs.writeFileSync(path.join(targetDir, 'SKILL.md'), 'name: my-skill\n\nMy test skill'); + + const linkPath = path.join(sharedSkillsDir, 'my-skill'); + createDirectorySymlink(targetDir, linkPath); + + const payload = await getJson<{ + items: Array<{ name: string; type: string; description: string; path: string }>; + }>(baseUrl, '/api/shared/skills'); + + expect(payload.items).toHaveLength(1); + expect(payload.items[0]).toMatchObject({ + name: 'my-skill', + type: 'skill', + }); + expect(payload.items[0].description.length).toBeGreaterThan(0); + expect(payload.items[0].path).toBe(linkPath); + }); + + it('lists symlinked agent directories', async () => { + const sharedAgentsDir = path.join(ccsDir, 'shared', 'agents'); + fs.mkdirSync(sharedAgentsDir, { recursive: true }); + const targetDir = path.join(ccsDir, '.claude', 'agents', 'my-agent'); + fs.mkdirSync(targetDir, { recursive: true }); + fs.writeFileSync(path.join(targetDir, 'prompt.md'), 'My test agent prompt'); + + const linkPath = path.join(sharedAgentsDir, 'my-agent'); + createDirectorySymlink(targetDir, linkPath); + + const payload = await getJson<{ + items: Array<{ name: string; type: string; description: string; path: string }>; + }>(baseUrl, '/api/shared/agents'); + + expect(payload.items).toHaveLength(1); + expect(payload.items[0]).toMatchObject({ + name: 'my-agent', + type: 'agent', + }); + expect(payload.items[0].description.length).toBeGreaterThan(0); + expect(payload.items[0].path).toBe(linkPath); + }); + + it('ignores markdown files in shared skills root', async () => { + const sharedSkillsDir = path.join(ccsDir, 'shared', 'skills'); + fs.mkdirSync(sharedSkillsDir, { recursive: true }); + fs.writeFileSync(path.join(sharedSkillsDir, 'CLAUDE.md'), 'not a skill directory'); + + const payload = await getJson<{ items: Array<{ name: string }> }>( + baseUrl, + '/api/shared/skills' + ); + expect(payload.items).toEqual([]); + }); + + it('ignores invalid command markdown entries and keeps valid files', async () => { + const commandsDir = path.join(ccsDir, 'shared', 'commands'); + fs.mkdirSync(commandsDir, { recursive: true }); + fs.writeFileSync(path.join(commandsDir, 'build.md'), 'Run build command'); + fs.mkdirSync(path.join(commandsDir, 'directory.md'), { recursive: true }); + + const linkedDirTarget = path.join(tempHome, 'linked-command-dir.md'); + fs.mkdirSync(linkedDirTarget, { recursive: true }); + const linkedDirPath = path.join(commandsDir, 'linked-dir.md'); + createDirectorySymlink(linkedDirTarget, linkedDirPath); + + const payload = await getJson<{ + items: Array<{ name: string; type: string; description: string; path: string }>; + }>(baseUrl, '/api/shared/commands'); + + expect(payload.items).toEqual([ + { + name: 'build', + type: 'command', + description: 'Run build command', + path: path.join(commandsDir, 'build.md'), + }, + ]); + }); + + it('ignores skill symlink targets outside allowed roots', async () => { + const skillsDir = path.join(ccsDir, 'shared', 'skills'); + fs.mkdirSync(skillsDir, { recursive: true }); + + const outsideSkillDir = path.join(tempHome, 'external-skills', 'outside-skill'); + fs.mkdirSync(outsideSkillDir, { recursive: true }); + fs.writeFileSync(path.join(outsideSkillDir, 'SKILL.md'), 'Outside skill should be ignored'); + + const linkPath = path.join(skillsDir, 'outside-skill'); + createDirectorySymlink(outsideSkillDir, linkPath); + + const payload = await getJson<{ items: Array<{ name: string }> }>( + baseUrl, + '/api/shared/skills' + ); + expect(payload.items).toEqual([]); + }); + + it('ignores agent symlink targets outside allowed roots', async () => { + const agentsDir = path.join(ccsDir, 'shared', 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + + const outsideAgentDir = path.join(tempHome, 'external-agents', 'outside-agent'); + fs.mkdirSync(outsideAgentDir, { recursive: true }); + fs.writeFileSync(path.join(outsideAgentDir, 'prompt.md'), 'Outside agent should be ignored'); + + const linkPath = path.join(agentsDir, 'outside-agent'); + createDirectorySymlink(outsideAgentDir, linkPath); + + const payload = await getJson<{ items: Array<{ name: string }> }>( + baseUrl, + '/api/shared/agents' + ); + expect(payload.items).toEqual([]); + }); + + it('ignores symlinked SKILL.md that escapes an allowed skill directory', async () => { + const skillsDir = path.join(ccsDir, 'shared', 'skills'); + fs.mkdirSync(skillsDir, { recursive: true }); + + const entryTargetDir = path.join(ccsDir, '.claude', 'skills', 'safe-skill'); + fs.mkdirSync(entryTargetDir, { recursive: true }); + + const outsideMarkdown = path.join(tempHome, 'outside-skill.md'); + fs.writeFileSync(outsideMarkdown, 'Leaked content should never be read'); + createFileSymlink(outsideMarkdown, path.join(entryTargetDir, 'SKILL.md')); + + createDirectorySymlink(entryTargetDir, path.join(skillsDir, 'safe-skill')); + + const payload = await getJson<{ items: Array<{ name: string }> }>( + baseUrl, + '/api/shared/skills' + ); + expect(payload.items).toEqual([]); + }); + + it('ignores symlinked command markdown targets outside commands root', async () => { + const commandsDir = path.join(ccsDir, 'shared', 'commands'); + fs.mkdirSync(commandsDir, { recursive: true }); + + const outsideMarkdown = path.join(tempHome, 'outside-command.md'); + fs.writeFileSync(outsideMarkdown, 'Outside command should not be read'); + createFileSymlink(outsideMarkdown, path.join(commandsDir, 'outside.md')); + + const payload = await getJson<{ items: Array<{ name: string }> }>( + baseUrl, + '/api/shared/commands' + ); + expect(payload.items).toEqual([]); + }); + + it('summary uses CLAUDE_CONFIG_DIR for symlink status and counts', async () => { + const sharedDir = path.join(ccsDir, 'shared'); + const claudeConfigDir = path.join(tempHome, 'custom-claude'); + process.env.CLAUDE_CONFIG_DIR = claudeConfigDir; + + for (const linkType of ['commands', 'skills', 'agents']) { + const targetDir = path.join(claudeConfigDir, linkType); + fs.mkdirSync(targetDir, { recursive: true }); + + const linkPath = path.join(sharedDir, linkType); + createDirectorySymlink(targetDir, linkPath); + } + + fs.writeFileSync(path.join(claudeConfigDir, 'commands', 'lint.md'), 'Run lint command'); + + const skillDir = path.join(claudeConfigDir, 'skills', 'custom-skill'); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), 'Custom skill description'); + + const agentDir = path.join(claudeConfigDir, 'agents', 'custom-agent'); + fs.mkdirSync(agentDir, { recursive: true }); + fs.writeFileSync(path.join(agentDir, 'prompt.md'), 'Custom agent prompt'); + + const payload = await getJson<{ + commands: number; + skills: number; + agents: number; + total: number; + symlinkStatus: { valid: boolean; message: string }; + }>(baseUrl, '/api/shared/summary'); + + expect(payload.commands).toBe(1); + expect(payload.skills).toBe(1); + expect(payload.agents).toBe(1); + expect(payload.total).toBe(3); + expect(payload.symlinkStatus).toEqual({ + valid: true, + message: 'Symlinks active', + }); + }); + + it('summary uses CCS_HOME fallback Claude path when CLAUDE_CONFIG_DIR is unset', async () => { + const sharedDir = path.join(ccsDir, 'shared'); + const claudeConfigDir = path.join(tempHome, '.claude'); + + for (const linkType of ['commands', 'skills', 'agents']) { + const targetDir = path.join(claudeConfigDir, linkType); + fs.mkdirSync(targetDir, { recursive: true }); + createDirectorySymlink(targetDir, path.join(sharedDir, linkType)); + } + + fs.writeFileSync(path.join(claudeConfigDir, 'commands', 'test.md'), 'Command description'); + + const payload = await getJson<{ + symlinkStatus: { valid: boolean; message: string }; + commands: number; + }>(baseUrl, '/api/shared/summary'); + + expect(payload.commands).toBe(1); + expect(payload.symlinkStatus).toEqual({ + valid: true, + message: 'Symlinks active', + }); + }); +}); diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index 1736b19a..244f570a 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -140,10 +140,10 @@ export const MODEL_CATALOGS: Record = { name: 'GPT-5.3 Codex', description: 'Supports up to xhigh effort', presetMapping: { - default: 'gpt-5.3-codex-xhigh', - opus: 'gpt-5.3-codex-xhigh', - sonnet: 'gpt-5.3-codex-high', - haiku: 'gpt-5-mini-medium', + default: 'gpt-5.3-codex', + opus: 'gpt-5.3-codex', + sonnet: 'gpt-5.3-codex', + haiku: 'gpt-5-mini', }, }, { @@ -151,10 +151,10 @@ export const MODEL_CATALOGS: Record = { name: 'GPT-5.2 Codex', description: 'Previous stable Codex model', presetMapping: { - default: 'gpt-5.2-codex-xhigh', - opus: 'gpt-5.2-codex-xhigh', - sonnet: 'gpt-5.2-codex-high', - haiku: 'gpt-5-mini-medium', + default: 'gpt-5.2-codex', + opus: 'gpt-5.2-codex', + sonnet: 'gpt-5.2-codex', + haiku: 'gpt-5-mini', }, }, { @@ -162,10 +162,10 @@ export const MODEL_CATALOGS: Record = { name: 'GPT-5 Mini', description: 'Fast, capped at high effort (no xhigh)', presetMapping: { - default: 'gpt-5-mini-medium', - opus: 'gpt-5.3-codex-xhigh', - sonnet: 'gpt-5-mini-high', - haiku: 'gpt-5-mini-medium', + default: 'gpt-5-mini', + opus: 'gpt-5.3-codex', + sonnet: 'gpt-5-mini', + haiku: 'gpt-5-mini', }, }, { @@ -174,9 +174,9 @@ export const MODEL_CATALOGS: Record = { description: 'Legacy most capable Codex model', presetMapping: { default: 'gpt-5.1-codex-max', - opus: 'gpt-5.1-codex-max-high', + opus: 'gpt-5.1-codex-max', sonnet: 'gpt-5.1-codex-max', - haiku: 'gpt-5.1-codex-mini-high', + haiku: 'gpt-5.1-codex-mini', }, }, {