From e3dbc6763436e97b6ae2ec6687174fb6c6904dc7 Mon Sep 17 00:00:00 2001 From: innovatias Date: Mon, 23 Feb 2026 22:08:53 +0700 Subject: [PATCH 01/12] fix(web-server): detect symlinked shared entries Handle shared skills and agents when entries are symlinked directories. Also avoid classifying root markdown files in skills/agents as commands. Co-Authored-By: Claude Opus 4.6 --- src/web-server/shared-routes.ts | 18 +-- tests/unit/web-server/shared-routes.test.ts | 133 ++++++++++++++++++++ 2 files changed, 143 insertions(+), 8 deletions(-) create mode 100644 tests/unit/web-server/shared-routes.test.ts diff --git a/src/web-server/shared-routes.ts b/src/web-server/shared-routes.ts index 6c56c993..6e1d965a 100644 --- a/src/web-server/shared-routes.ts +++ b/src/web-server/shared-routes.ts @@ -74,23 +74,25 @@ function getSharedItems(type: 'commands' | 'skills' | 'agents'): SharedItem[] { 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 entryPath = path.join(sharedDir, entry.name); + + // Skills/Agents are directory-based and may be symlinked directories + if (type !== 'commands' && (entry.isDirectory() || entry.isSymbolicLink())) { const markdownFile = type === 'skills' ? 'SKILL.md' : 'prompt.md'; - const promptPath = path.join(sharedDir, entry.name, markdownFile); + const promptPath = path.join(entryPath, markdownFile); if (fs.existsSync(promptPath)) { const content = fs.readFileSync(promptPath, 'utf8'); const description = extractDescription(content); items.push({ name: entry.name, description, - path: path.join(sharedDir, entry.name), - type: type === 'commands' ? 'command' : (type.slice(0, -1) as 'skill' | 'agent'), + path: entryPath, + type: type === 'skills' ? 'skill' : 'agent', }); } - } else if (entry.name.endsWith('.md')) { - // Command: .md file - const filePath = path.join(sharedDir, entry.name); + } else if (type === 'commands' && entry.name.endsWith('.md')) { + // Commands are markdown files + const filePath = entryPath; const content = fs.readFileSync(filePath, 'utf8'); const description = extractDescription(content); items.push({ 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..0fc0352d --- /dev/null +++ b/tests/unit/web-server/shared-routes.test.ts @@ -0,0 +1,133 @@ +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 { sharedRoutes } from '../../../src/web-server/shared-routes'; + +function getGetRouteHandler(routePath: string): (req: unknown, res: unknown) => void { + const layer = (sharedRoutes as { stack?: unknown[] }).stack?.find((entry) => { + const route = (entry as { route?: { path?: string; methods?: Record } }).route; + return route?.path === routePath && route.methods?.get; + }) as + | { + route?: { + stack?: Array<{ handle: (req: unknown, res: unknown) => void }>; + }; + } + | undefined; + + const handler = layer?.route?.stack?.[0]?.handle; + if (!handler) { + throw new Error(`GET handler not found for route: ${routePath}`); + } + + return handler; +} + +describe('web-server shared-routes', () => { + let tempHome: string; + let ccsDir: string; + let originalCcsHome: string | undefined; + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-shared-routes-test-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + + ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(path.join(ccsDir, 'shared', 'skills'), { recursive: true }); + fs.mkdirSync(path.join(ccsDir, 'shared', 'agents'), { recursive: true }); + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('lists symlinked skill directories', () => { + const sharedSkillsDir = path.join(ccsDir, 'shared', 'skills'); + const targetDir = path.join(tempHome, 'skill-targets', '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'); + const symlinkType = process.platform === 'win32' ? 'junction' : 'dir'; + fs.symlinkSync(targetDir, linkPath, symlinkType as fs.symlink.Type); + + const handler = getGetRouteHandler('/skills'); + let payload: { items: Array<{ name: string; type: string }> } | undefined; + + handler( + {}, + { + json: (data: { items: Array<{ name: string; type: string }> }) => { + payload = data; + }, + } + ); + + expect(payload).toBeDefined(); + expect(payload?.items).toHaveLength(1); + expect(payload?.items[0]).toMatchObject({ + name: 'my-skill', + type: 'skill', + }); + }); + + it('lists symlinked agent directories', () => { + const sharedAgentsDir = path.join(ccsDir, 'shared', 'agents'); + const targetDir = path.join(tempHome, 'agent-targets', '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'); + const symlinkType = process.platform === 'win32' ? 'junction' : 'dir'; + fs.symlinkSync(targetDir, linkPath, symlinkType as fs.symlink.Type); + + const handler = getGetRouteHandler('/agents'); + let payload: { items: Array<{ name: string; type: string }> } | undefined; + + handler( + {}, + { + json: (data: { items: Array<{ name: string; type: string }> }) => { + payload = data; + }, + } + ); + + expect(payload).toBeDefined(); + expect(payload?.items).toHaveLength(1); + expect(payload?.items[0]).toMatchObject({ + name: 'my-agent', + type: 'agent', + }); + }); + + it('ignores markdown files in shared skills root', () => { + const sharedSkillsDir = path.join(ccsDir, 'shared', 'skills'); + fs.writeFileSync(path.join(sharedSkillsDir, 'CLAUDE.md'), 'not a skill directory'); + + const handler = getGetRouteHandler('/skills'); + let payload: { items: Array<{ name: string }> } | undefined; + + handler( + {}, + { + json: (data: { items: Array<{ name: string }> }) => { + payload = data; + }, + } + ); + + expect(payload).toBeDefined(); + expect(payload?.items).toEqual([]); + }); +}); From b6f2d5d249dde80a3288a79e9dc783756296e79c Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 23 Feb 2026 22:20:19 +0700 Subject: [PATCH 02/12] fix(ci): auto-close released issues in stable release workflow --- .github/workflows/release.yml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0a31aa98..a919fee5 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 From 9464615e38aa430f9a344b0bb7fbb67eae771032 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 23 Feb 2026 15:36:23 +0000 Subject: [PATCH 03/12] chore(release): 7.48.0-dev.1 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9a11c880..cb1ada80 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.48.0", + "version": "7.48.0-dev.1", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From be96b84e613ac4be217ea05c8090318df1114469 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 23 Feb 2026 22:52:44 +0700 Subject: [PATCH 04/12] fix(codex): canonicalize dashboard presets and migrate suffixed settings --- src/cliproxy/config/env-builder.ts | 65 +++++++++++++ src/web-server/routes/settings-routes.ts | 91 +++++++++++++++++-- .../cliproxy/env-builder-provider-url.test.ts | 55 ++++++++++- ui/src/lib/model-catalogs.ts | 28 +++--- 4 files changed, 213 insertions(+), 26 deletions(-) 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/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/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/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', }, }, { From 57e14cf3a69e5106a0a29ffda77f8aa134aa435d Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 23 Feb 2026 22:59:02 +0700 Subject: [PATCH 05/12] fix(codex): validate tier thinking values before suffixing --- src/cliproxy/config/thinking-config.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/cliproxy/config/thinking-config.ts b/src/cliproxy/config/thinking-config.ts index 76e67752..bf0dfa99 100644 --- a/src/cliproxy/config/thinking-config.ts +++ b/src/cliproxy/config/thinking-config.ts @@ -294,6 +294,16 @@ export function applyThinkingConfig( continue; } + // Validate/clamp tier thinking against this specific tier model capabilities. + const tierValidation = validateThinking(tierProvider, normalizedTierModel, tierThinkingValue); + if (tierValidation.warning && shouldShowWarnings(thinkingConfig)) { + console.warn(warn(tierValidation.warning)); + } + tierThinkingValue = tierValidation.value; + if (isThinkingOffValue(tierThinkingValue)) { + continue; + } + result[tierVar] = applyThinkingSuffixForProvider(model, tierThinkingValue, tierProvider); } } From db80f85e38abe7ace0c25b774cdf65ed4913b8df Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 23 Feb 2026 23:10:12 +0700 Subject: [PATCH 06/12] fix(codex): preserve codex tier efforts in auto thinking --- src/cliproxy/config/thinking-config.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/cliproxy/config/thinking-config.ts b/src/cliproxy/config/thinking-config.ts index bf0dfa99..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]; } @@ -294,16 +303,6 @@ export function applyThinkingConfig( continue; } - // Validate/clamp tier thinking against this specific tier model capabilities. - const tierValidation = validateThinking(tierProvider, normalizedTierModel, tierThinkingValue); - if (tierValidation.warning && shouldShowWarnings(thinkingConfig)) { - console.warn(warn(tierValidation.warning)); - } - tierThinkingValue = tierValidation.value; - if (isThinkingOffValue(tierThinkingValue)) { - continue; - } - result[tierVar] = applyThinkingSuffixForProvider(model, tierThinkingValue, tierProvider); } } From e2623e632c057dc88f7ee1061864b44efbe2e03a Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 23 Feb 2026 23:18:34 +0700 Subject: [PATCH 07/12] feat(memory): share project memory across account instances --- src/management/instance-manager.ts | 3 + src/management/shared-manager.ts | 249 ++++++++++++++++++++++++++ tests/unit/shared-memory-sync.test.ts | 113 ++++++++++++ 3 files changed, 365 insertions(+) create mode 100644 tests/unit/shared-memory-sync.test.ts diff --git a/src/management/instance-manager.ts b/src/management/instance-manager.ts index 2063a39c..9e177d2a 100644 --- a/src/management/instance-manager.ts +++ b/src/management/instance-manager.ts @@ -37,6 +37,9 @@ class InstanceManager { // Validate structure (auto-fix missing dirs) this.validateInstance(instancePath); + // Keep project memory shared across instances. + this.sharedManager.syncProjectMemories(instancePath); + return instancePath; } diff --git a/src/management/shared-manager.ts b/src/management/shared-manager.ts index 754e42e7..03d1e633 100644 --- a/src/management/shared-manager.ts +++ b/src/management/shared-manager.ts @@ -201,6 +201,99 @@ 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// + */ + syncProjectMemories(instancePath: string): void { + const projectsDir = path.join(instancePath, 'projects'); + if (!fs.existsSync(projectsDir)) { + return; + } + + if (!fs.existsSync(this.sharedDir)) { + fs.mkdirSync(this.sharedDir, { recursive: true, mode: 0o700 }); + } + + const sharedMemoryRoot = path.join(this.sharedDir, 'memory'); + if (!fs.existsSync(sharedMemoryRoot)) { + fs.mkdirSync(sharedMemoryRoot, { recursive: true, mode: 0o700 }); + } + + const projects = fs.readdirSync(projectsDir, { withFileTypes: true }).filter((entry) => { + return 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); + + if (!fs.existsSync(projectMemoryPath)) { + if (this.ensureProjectMemoryLink(projectMemoryPath, sharedProjectMemoryPath)) { + linked++; + } + continue; + } + + const projectMemoryStats = fs.lstatSync(projectMemoryPath); + + if (projectMemoryStats.isSymbolicLink()) { + if (this.isSymlinkTarget(projectMemoryPath, sharedProjectMemoryPath)) { + continue; + } + + fs.unlinkSync(projectMemoryPath); + if (this.ensureProjectMemoryLink(projectMemoryPath, sharedProjectMemoryPath)) { + linked++; + } + continue; + } + + if (!projectMemoryStats.isDirectory()) { + continue; + } + + if (!fs.existsSync(sharedProjectMemoryPath)) { + this.moveDirectory(projectMemoryPath, sharedProjectMemoryPath); + migrated++; + } else { + merged += this.mergeDirectoryWithConflictCopies( + projectMemoryPath, + sharedProjectMemoryPath, + instanceName + ); + fs.rmSync(projectMemoryPath, { recursive: true, force: true }); + } + + if (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 +513,162 @@ 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 ensureProjectMemoryLink(linkPath: string, targetPath: string): boolean { + if (!fs.existsSync(targetPath)) { + fs.mkdirSync(targetPath, { recursive: true, mode: 0o700 }); + } + + if (fs.existsSync(linkPath)) { + const stats = fs.lstatSync(linkPath); + if (stats.isSymbolicLink() && this.isSymlinkTarget(linkPath, targetPath)) { + return false; + } + + if (stats.isDirectory()) { + fs.rmSync(linkPath, { recursive: true, force: true }); + } else { + fs.unlinkSync(linkPath); + } + } + + const symlinkType: 'dir' | 'junction' = process.platform === 'win32' ? 'junction' : 'dir'; + const linkTarget = process.platform === 'win32' ? path.resolve(targetPath) : targetPath; + + try { + fs.symlinkSync(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 isSymlinkTarget(linkPath: string, expectedTarget: string): boolean { + try { + const stats = fs.lstatSync(linkPath); + if (!stats.isSymbolicLink()) { + return false; + } + + const currentTarget = fs.readlinkSync(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 moveDirectory(src: string, dest: string): void { + try { + fs.renameSync(src, dest); + } catch (err) { + const error = err as NodeJS.ErrnoException; + if (error.code !== 'EXDEV') { + throw err; + } + + fs.cpSync(src, dest, { recursive: true }); + fs.rmSync(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 mergeDirectoryWithConflictCopies( + sourceDir: string, + targetDir: string, + instanceName: string + ): number { + if (!fs.existsSync(targetDir)) { + fs.mkdirSync(targetDir, { recursive: true, mode: 0o700 }); + } + + let conflicts = 0; + const entries = fs.readdirSync(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 += this.mergeDirectoryWithConflictCopies(sourcePath, targetPath, instanceName); + continue; + } + + if (entry.isFile()) { + if (!fs.existsSync(targetPath)) { + fs.copyFileSync(sourcePath, targetPath); + continue; + } + + if (this.fileContentsEqual(sourcePath, targetPath)) { + continue; + } + + const conflictPath = this.getConflictCopyPath(targetPath, instanceName); + fs.copyFileSync(sourcePath, conflictPath); + conflicts++; + } + } + + return conflicts; + } + + /** + * Compare two files byte-for-byte. + */ + private fileContentsEqual(fileA: string, fileB: string): boolean { + try { + const statA = fs.statSync(fileA); + const statB = fs.statSync(fileB); + if (statA.size !== statB.size) { + return false; + } + + const contentA = fs.readFileSync(fileA); + const contentB = fs.readFileSync(fileB); + return contentA.equals(contentB); + } catch (_err) { + return false; + } + } + + /** + * Build a non-destructive conflict copy path. + */ + private getConflictCopyPath(existingTargetPath: string, instanceName: string): string { + const safeInstanceName = instanceName.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase(); + const baseSuffix = `.migrated-from-${safeInstanceName}`; + + let candidate = `${existingTargetPath}${baseSuffix}`; + let sequence = 1; + while (fs.existsSync(candidate)) { + candidate = `${existingTargetPath}${baseSuffix}-${sequence}`; + sequence++; + } + + return candidate; + } + /** * Copy directory as fallback (Windows without Developer Mode) */ diff --git a/tests/unit/shared-memory-sync.test.ts b/tests/unit/shared-memory-sync.test.ts new file mode 100644 index 00000000..b276d06c --- /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', () => { + 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(); + 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', () => { + 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(); + 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', () => { + 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(); + 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); + }); +}); From 498b66d9cceaf34a36e81403fa15b73093eda2c9 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 23 Feb 2026 23:22:29 +0700 Subject: [PATCH 08/12] fix(web-server): harden shared route symlink scanning --- src/utils/claude-config-path.ts | 26 ++ src/web-server/shared-routes.ts | 127 ++++++-- tests/unit/web-server/shared-routes.test.ts | 331 ++++++++++++++++---- 3 files changed, 396 insertions(+), 88 deletions(-) create mode 100644 src/utils/claude-config-path.ts diff --git a/src/utils/claude-config-path.ts b/src/utils/claude-config-path.ts new file mode 100644 index 00000000..e084cceb --- /dev/null +++ b/src/utils/claude-config-path.ts @@ -0,0 +1,26 @@ +import * as os from 'os'; +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) + * 3. ~/.claude (default) + */ +export function getClaudeConfigDir(): string { + if (process.env.CLAUDE_CONFIG_DIR) { + return path.resolve(process.env.CLAUDE_CONFIG_DIR); + } + + if (process.env.CCS_HOME) { + return path.join(path.resolve(process.env.CCS_HOME), '.claude'); + } + + return path.join(os.homedir(), '.claude'); +} + +/** Resolve Claude settings.json path. */ +export function getClaudeSettingsPath(): string { + return path.join(getClaudeConfigDir(), 'settings.json'); +} diff --git a/src/web-server/shared-routes.ts b/src/web-server/shared-routes.ts index 6e1d965a..1682cab0 100644 --- a/src/web-server/shared-routes.ts +++ b/src/web-server/shared-routes.ts @@ -7,8 +7,8 @@ import { Router, Request, Response } from 'express'; import * as fs from 'fs'; import * as path from 'path'; -import * as os from 'os'; import { getCcsDir } from '../utils/config-manager'; +import { getClaudeConfigDir } from '../utils/claude-config-path'; export const sharedRoutes = Router(); @@ -69,38 +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) { - const entryPath = path.join(sharedDir, entry.name); + 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; + } - // Skills/Agents are directory-based and may be symlinked directories - if (type !== 'commands' && (entry.isDirectory() || entry.isSymbolicLink())) { - const markdownFile = type === 'skills' ? 'SKILL.md' : 'prompt.md'; - const promptPath = path.join(entryPath, markdownFile); - if (fs.existsSync(promptPath)) { - const content = fs.readFileSync(promptPath, 'utf8'); - const description = extractDescription(content); items.push({ - name: entry.name, + name: entry.name.replace('.md', ''), description, path: entryPath, - type: type === 'skills' ? 'skill' : 'agent', + type: 'command', }); + continue; } - } else if (type === 'commands' && entry.name.endsWith('.md')) { - // Commands are markdown files - const filePath = entryPath; - 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 { @@ -122,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'); @@ -142,8 +227,8 @@ 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} - const expectedTarget = path.join(os.homedir(), '.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/web-server/shared-routes.test.ts b/tests/unit/web-server/shared-routes.test.ts index 0fc0352d..b1824b16 100644 --- a/tests/unit/web-server/shared-routes.test.ts +++ b/tests/unit/web-server/shared-routes.test.ts @@ -1,42 +1,94 @@ -import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +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 getGetRouteHandler(routePath: string): (req: unknown, res: unknown) => void { - const layer = (sharedRoutes as { stack?: unknown[] }).stack?.find((entry) => { - const route = (entry as { route?: { path?: string; methods?: Record } }).route; - return route?.path === routePath && route.methods?.get; - }) as - | { - route?: { - stack?: Array<{ handle: (req: unknown, res: unknown) => void }>; - }; - } - | undefined; +function createDirectorySymlink(targetDir: string, linkPath: string): void { + const symlinkType = process.platform === 'win32' ? 'junction' : 'dir'; - const handler = layer?.route?.stack?.[0]?.handle; - if (!handler) { - throw new Error(`GET handler not found for route: ${routePath}`); + 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; } +} - return handler; +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', 'skills'), { recursive: true }); - fs.mkdirSync(path.join(ccsDir, 'shared', 'agents'), { recursive: true }); + fs.mkdirSync(path.join(ccsDir, 'shared'), { recursive: true }); }); afterEach(() => { @@ -46,88 +98,233 @@ describe('web-server shared-routes', () => { 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', () => { + it('lists symlinked skill directories', async () => { const sharedSkillsDir = path.join(ccsDir, 'shared', 'skills'); - const targetDir = path.join(tempHome, 'skill-targets', 'my-skill'); + 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'); - const symlinkType = process.platform === 'win32' ? 'junction' : 'dir'; - fs.symlinkSync(targetDir, linkPath, symlinkType as fs.symlink.Type); + createDirectorySymlink(targetDir, linkPath); - const handler = getGetRouteHandler('/skills'); - let payload: { items: Array<{ name: string; type: string }> } | undefined; + const payload = await getJson<{ + items: Array<{ name: string; type: string; description: string; path: string }>; + }>(baseUrl, '/api/shared/skills'); - handler( - {}, - { - json: (data: { items: Array<{ name: string; type: string }> }) => { - payload = data; - }, - } - ); - - expect(payload).toBeDefined(); - expect(payload?.items).toHaveLength(1); - expect(payload?.items[0]).toMatchObject({ + 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', () => { + it('lists symlinked agent directories', async () => { const sharedAgentsDir = path.join(ccsDir, 'shared', 'agents'); - const targetDir = path.join(tempHome, 'agent-targets', 'my-agent'); + 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'); - const symlinkType = process.platform === 'win32' ? 'junction' : 'dir'; - fs.symlinkSync(targetDir, linkPath, symlinkType as fs.symlink.Type); + createDirectorySymlink(targetDir, linkPath); - const handler = getGetRouteHandler('/agents'); - let payload: { items: Array<{ name: string; type: string }> } | undefined; + const payload = await getJson<{ + items: Array<{ name: string; type: string; description: string; path: string }>; + }>(baseUrl, '/api/shared/agents'); - handler( - {}, - { - json: (data: { items: Array<{ name: string; type: string }> }) => { - payload = data; - }, - } - ); - - expect(payload).toBeDefined(); - expect(payload?.items).toHaveLength(1); - expect(payload?.items[0]).toMatchObject({ + 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', () => { + 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 handler = getGetRouteHandler('/skills'); - let payload: { items: Array<{ name: string }> } | undefined; - - handler( - {}, - { - json: (data: { items: Array<{ name: string }> }) => { - payload = data; - }, - } + const payload = await getJson<{ items: Array<{ name: string }> }>( + baseUrl, + '/api/shared/skills' ); + expect(payload.items).toEqual([]); + }); - expect(payload).toBeDefined(); - 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', + }); }); }); From 6e94e41b471ef888d183e6169b0bc3bcca7aa421 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 23 Feb 2026 16:25:53 +0000 Subject: [PATCH 09/12] chore(release): 7.48.0-dev.2 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index cb1ada80..d857ccd6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.48.0-dev.1", + "version": "7.48.0-dev.2", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From c6c44d03411a85034ab452ac8fc82b35b50c3c27 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 23 Feb 2026 23:29:22 +0700 Subject: [PATCH 10/12] fix(memory): use async fs APIs to satisfy maintainability gate --- src/auth/commands/create-command.ts | 2 +- src/ccs.ts | 2 +- src/management/instance-manager.ts | 4 +- src/management/shared-manager.ts | 147 +++++++++++++++----------- tests/unit/shared-memory-sync.test.ts | 12 +-- 5 files changed, 97 insertions(+), 70 deletions(-) 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/management/instance-manager.ts b/src/management/instance-manager.ts index 9e177d2a..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 @@ -38,7 +38,7 @@ class InstanceManager { this.validateInstance(instancePath); // Keep project memory shared across instances. - this.sharedManager.syncProjectMemories(instancePath); + await this.sharedManager.syncProjectMemories(instancePath); return instancePath; } diff --git a/src/management/shared-manager.ts b/src/management/shared-manager.ts index 03d1e633..7871e95d 100644 --- a/src/management/shared-manager.ts +++ b/src/management/shared-manager.ts @@ -210,25 +210,25 @@ class SharedManager { * Shared layout (canonical): * ~/.ccs/shared/memory// */ - syncProjectMemories(instancePath: string): void { + async syncProjectMemories(instancePath: string): Promise { const projectsDir = path.join(instancePath, 'projects'); - if (!fs.existsSync(projectsDir)) { + if (!(await this.pathExists(projectsDir))) { return; } - if (!fs.existsSync(this.sharedDir)) { - fs.mkdirSync(this.sharedDir, { recursive: true, mode: 0o700 }); - } + await this.ensureDirectory(this.sharedDir); const sharedMemoryRoot = path.join(this.sharedDir, 'memory'); - if (!fs.existsSync(sharedMemoryRoot)) { - fs.mkdirSync(sharedMemoryRoot, { recursive: true, mode: 0o700 }); + await this.ensureDirectory(sharedMemoryRoot); + + let projectEntries: fs.Dirent[] = []; + try { + projectEntries = await fs.promises.readdir(projectsDir, { withFileTypes: true }); + } catch (_err) { + return; } - const projects = fs.readdirSync(projectsDir, { withFileTypes: true }).filter((entry) => { - return entry.isDirectory(); - }); - + const projects = projectEntries.filter((entry) => entry.isDirectory()); if (projects.length === 0) { return; } @@ -243,22 +243,21 @@ class SharedManager { const projectMemoryPath = path.join(projectDir, 'memory'); const sharedProjectMemoryPath = path.join(sharedMemoryRoot, project.name); - if (!fs.existsSync(projectMemoryPath)) { - if (this.ensureProjectMemoryLink(projectMemoryPath, sharedProjectMemoryPath)) { + const projectMemoryStats = await this.getLstat(projectMemoryPath); + if (!projectMemoryStats) { + if (await this.ensureProjectMemoryLink(projectMemoryPath, sharedProjectMemoryPath)) { linked++; } continue; } - const projectMemoryStats = fs.lstatSync(projectMemoryPath); - if (projectMemoryStats.isSymbolicLink()) { - if (this.isSymlinkTarget(projectMemoryPath, sharedProjectMemoryPath)) { + if (await this.isSymlinkTarget(projectMemoryPath, sharedProjectMemoryPath)) { continue; } - fs.unlinkSync(projectMemoryPath); - if (this.ensureProjectMemoryLink(projectMemoryPath, sharedProjectMemoryPath)) { + await fs.promises.unlink(projectMemoryPath); + if (await this.ensureProjectMemoryLink(projectMemoryPath, sharedProjectMemoryPath)) { linked++; } continue; @@ -268,19 +267,19 @@ class SharedManager { continue; } - if (!fs.existsSync(sharedProjectMemoryPath)) { - this.moveDirectory(projectMemoryPath, sharedProjectMemoryPath); + if (!(await this.pathExists(sharedProjectMemoryPath))) { + await this.moveDirectory(projectMemoryPath, sharedProjectMemoryPath); migrated++; } else { - merged += this.mergeDirectoryWithConflictCopies( + merged += await this.mergeDirectoryWithConflictCopies( projectMemoryPath, sharedProjectMemoryPath, instanceName ); - fs.rmSync(projectMemoryPath, { recursive: true, force: true }); + await fs.promises.rm(projectMemoryPath, { recursive: true, force: true }); } - if (this.ensureProjectMemoryLink(projectMemoryPath, sharedProjectMemoryPath)) { + if (await this.ensureProjectMemoryLink(projectMemoryPath, sharedProjectMemoryPath)) { linked++; } } @@ -517,21 +516,19 @@ class SharedManager { * Ensure memory path is linked to shared memory root. * Returns true when a link/copy was created or updated. */ - private ensureProjectMemoryLink(linkPath: string, targetPath: string): boolean { - if (!fs.existsSync(targetPath)) { - fs.mkdirSync(targetPath, { recursive: true, mode: 0o700 }); - } + private async ensureProjectMemoryLink(linkPath: string, targetPath: string): Promise { + await this.ensureDirectory(targetPath); - if (fs.existsSync(linkPath)) { - const stats = fs.lstatSync(linkPath); - if (stats.isSymbolicLink() && this.isSymlinkTarget(linkPath, targetPath)) { + const linkStats = await this.getLstat(linkPath); + if (linkStats) { + if (linkStats.isSymbolicLink() && (await this.isSymlinkTarget(linkPath, targetPath))) { return false; } - if (stats.isDirectory()) { - fs.rmSync(linkPath, { recursive: true, force: true }); + if (linkStats.isDirectory()) { + await fs.promises.rm(linkPath, { recursive: true, force: true }); } else { - fs.unlinkSync(linkPath); + await fs.promises.unlink(linkPath); } } @@ -539,7 +536,7 @@ class SharedManager { const linkTarget = process.platform === 'win32' ? path.resolve(targetPath) : targetPath; try { - fs.symlinkSync(linkTarget, linkPath, symlinkType); + await fs.promises.symlink(linkTarget, linkPath, symlinkType); return true; } catch (_err) { if (process.platform === 'win32') { @@ -556,14 +553,14 @@ class SharedManager { /** * Check whether symlink points to expected target. */ - private isSymlinkTarget(linkPath: string, expectedTarget: string): boolean { + private async isSymlinkTarget(linkPath: string, expectedTarget: string): Promise { try { - const stats = fs.lstatSync(linkPath); + const stats = await fs.promises.lstat(linkPath); if (!stats.isSymbolicLink()) { return false; } - const currentTarget = fs.readlinkSync(linkPath); + const currentTarget = await fs.promises.readlink(linkPath); const resolvedCurrentTarget = path.resolve(path.dirname(linkPath), currentTarget); const resolvedExpectedTarget = path.resolve(expectedTarget); return resolvedCurrentTarget === resolvedExpectedTarget; @@ -575,17 +572,17 @@ class SharedManager { /** * Move directory, with cross-device fallback. */ - private moveDirectory(src: string, dest: string): void { + private async moveDirectory(src: string, dest: string): Promise { try { - fs.renameSync(src, dest); + await fs.promises.rename(src, dest); } catch (err) { const error = err as NodeJS.ErrnoException; if (error.code !== 'EXDEV') { throw err; } - fs.cpSync(src, dest, { recursive: true }); - fs.rmSync(src, { recursive: true, force: true }); + await fs.promises.cp(src, dest, { recursive: true }); + await fs.promises.rm(src, { recursive: true, force: true }); } } @@ -593,39 +590,41 @@ class SharedManager { * Merge source into target. On file conflicts, keep target and copy source * as ".migrated-from-[-N]" to avoid data loss. */ - private mergeDirectoryWithConflictCopies( + private async mergeDirectoryWithConflictCopies( sourceDir: string, targetDir: string, instanceName: string - ): number { - if (!fs.existsSync(targetDir)) { - fs.mkdirSync(targetDir, { recursive: true, mode: 0o700 }); - } + ): Promise { + await this.ensureDirectory(targetDir); let conflicts = 0; - const entries = fs.readdirSync(sourceDir, { withFileTypes: true }); + 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 += this.mergeDirectoryWithConflictCopies(sourcePath, targetPath, instanceName); + conflicts += await this.mergeDirectoryWithConflictCopies( + sourcePath, + targetPath, + instanceName + ); continue; } if (entry.isFile()) { - if (!fs.existsSync(targetPath)) { - fs.copyFileSync(sourcePath, targetPath); + if (!(await this.pathExists(targetPath))) { + await fs.promises.copyFile(sourcePath, targetPath); continue; } - if (this.fileContentsEqual(sourcePath, targetPath)) { + if (await this.fileContentsEqual(sourcePath, targetPath)) { continue; } - const conflictPath = this.getConflictCopyPath(targetPath, instanceName); - fs.copyFileSync(sourcePath, conflictPath); + const conflictPath = await this.getConflictCopyPath(targetPath, instanceName); + await fs.promises.copyFile(sourcePath, conflictPath); conflicts++; } } @@ -636,16 +635,17 @@ class SharedManager { /** * Compare two files byte-for-byte. */ - private fileContentsEqual(fileA: string, fileB: string): boolean { + private async fileContentsEqual(fileA: string, fileB: string): Promise { try { - const statA = fs.statSync(fileA); - const statB = fs.statSync(fileB); + const [statA, statB] = await Promise.all([fs.promises.stat(fileA), fs.promises.stat(fileB)]); if (statA.size !== statB.size) { return false; } - const contentA = fs.readFileSync(fileA); - const contentB = fs.readFileSync(fileB); + const [contentA, contentB] = await Promise.all([ + fs.promises.readFile(fileA), + fs.promises.readFile(fileB), + ]); return contentA.equals(contentB); } catch (_err) { return false; @@ -655,13 +655,16 @@ class SharedManager { /** * Build a non-destructive conflict copy path. */ - private getConflictCopyPath(existingTargetPath: string, instanceName: string): string { + 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 (fs.existsSync(candidate)) { + while (await this.pathExists(candidate)) { candidate = `${existingTargetPath}${baseSuffix}-${sequence}`; sequence++; } @@ -669,6 +672,30 @@ class SharedManager { 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/tests/unit/shared-memory-sync.test.ts b/tests/unit/shared-memory-sync.test.ts index b276d06c..a041209b 100644 --- a/tests/unit/shared-memory-sync.test.ts +++ b/tests/unit/shared-memory-sync.test.ts @@ -45,7 +45,7 @@ describe('SharedManager project memory sync', () => { } }); - it('migrates existing project memory and replaces it with a shared symlink', () => { + 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'; @@ -54,7 +54,7 @@ describe('SharedManager project memory sync', () => { fs.writeFileSync(path.join(projectMemoryPath, 'MEMORY.md'), 'instance knowledge', 'utf8'); const manager = new SharedManager(); - manager.syncProjectMemories(instancePath); + await manager.syncProjectMemories(instancePath); const sharedMemoryFile = path.join(ccsDir, 'shared', 'memory', projectName, 'MEMORY.md'); expect(fs.existsSync(sharedMemoryFile)).toBe(true); @@ -67,7 +67,7 @@ describe('SharedManager project memory sync', () => { expect(resolvedTarget).toBe(path.join(ccsDir, 'shared', 'memory', projectName)); }); - it('preserves canonical memory and writes conflict copy when contents differ', () => { + 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'; @@ -80,7 +80,7 @@ describe('SharedManager project memory sync', () => { fs.writeFileSync(path.join(sharedProjectMemoryPath, 'MEMORY.md'), 'shared memory', 'utf8'); const manager = new SharedManager(); - manager.syncProjectMemories(instancePath); + await manager.syncProjectMemories(instancePath); const canonicalFile = path.join(sharedProjectMemoryPath, 'MEMORY.md'); expect(fs.readFileSync(canonicalFile, 'utf8')).toBe('shared memory'); @@ -93,7 +93,7 @@ describe('SharedManager project memory sync', () => { expect(linkStats.isSymbolicLink()).toBe(true); }); - it('creates shared memory link for projects that do not have memory directory yet', () => { + 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'; @@ -102,7 +102,7 @@ describe('SharedManager project memory sync', () => { fs.mkdirSync(projectPath, { recursive: true }); const manager = new SharedManager(); - manager.syncProjectMemories(instancePath); + await manager.syncProjectMemories(instancePath); const linkStats = fs.lstatSync(projectMemoryPath); expect(linkStats.isSymbolicLink()).toBe(true); From 2c9803a99eb443f6e25c231f7aeaf8ae68690395 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 23 Feb 2026 16:44:20 +0000 Subject: [PATCH 11/12] chore(release): 7.48.0-dev.3 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d857ccd6..6c6e38f7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.48.0-dev.2", + "version": "7.48.0-dev.3", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 9e48af255bd7b19ddd9a6045339a705c8120cb25 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 23 Feb 2026 17:15:27 +0000 Subject: [PATCH 12/12] chore(release): 7.48.1-dev.1 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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",