Merge pull request #615 from kaitranntt/dev

feat(memory): share project memory across account instances
This commit is contained in:
Kai (Tam Nhu) Tran
2026-02-23 12:22:19 -05:00
committed by GitHub
15 changed files with 1068 additions and 53 deletions
+12 -2
View File
@@ -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
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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()) {
+1 -1
View File
@@ -884,7 +884,7 @@ async function main(): Promise<void> {
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)) {
+65
View File
@@ -28,12 +28,19 @@ import {
/** Settings file structure for user overrides */
interface ProviderSettings {
env: NodeJS.ProcessEnv;
presets?: Array<Record<string, unknown>>;
}
/** 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<string, unknown>;
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<string, string>;
}
} 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<string, string>;
}
} catch {
+9
View File
@@ -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];
}
+4 -1
View File
@@ -26,7 +26,7 @@ class InstanceManager {
/**
* Ensure instance exists for profile (lazy init only)
*/
ensureInstance(profileName: string): string {
async ensureInstance(profileName: string): Promise<string> {
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;
}
+276
View File
@@ -201,6 +201,98 @@ class SharedManager {
this.normalizePluginRegistryPaths();
}
/**
* Ensure all project memory directories for an instance are shared.
*
* Source layout (isolated):
* ~/.ccs/instances/<profile>/projects/<project>/memory/
*
* Shared layout (canonical):
* ~/.ccs/shared/memory/<project>/
*/
async syncProjectMemories(instancePath: string): Promise<void> {
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/<name>/ 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<boolean> {
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<boolean> {
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<void> {
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 "<name>.migrated-from-<instance>[-N]" to avoid data loss.
*/
private async mergeDirectoryWithConflictCopies(
sourceDir: string,
targetDir: string,
instanceName: string
): Promise<number> {
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<boolean> {
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<string> {
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<boolean> {
try {
await fs.promises.access(targetPath);
return true;
} catch (_err) {
return false;
}
}
private async ensureDirectory(targetPath: string): Promise<void> {
await fs.promises.mkdir(targetPath, { recursive: true, mode: 0o700 });
}
private async getLstat(targetPath: string): Promise<fs.Stats | null> {
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)
*/
+2 -2
View File
@@ -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 (<dirname(CCS_HOME)>/.claude)
* 2. CCS_HOME compatibility path (<CCS_HOME>/.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');
+81 -10
View File
@@ -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<string, unknown>);
deduplicateCcsHooks(normalizedSettings as Record<string, unknown>);
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);
+106 -19
View File
@@ -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<string>([
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<string>): 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++;
@@ -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<string, unknown>
): 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<string, string>;
};
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<Record<string, string>>;
};
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');
});
});
+113
View File
@@ -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);
});
});
+330
View File
@@ -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<T>(baseUrl: string, routePath: string): Promise<T> {
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<void>((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<void>((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',
});
});
});
+14 -14
View File
@@ -140,10 +140,10 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
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<string, ProviderCatalog> = {
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<string, ProviderCatalog> = {
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<string, ProviderCatalog> = {
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',
},
},
{