mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 18:16:08 +00:00
Merge pull request #615 from kaitranntt/dev
feat(memory): share project memory across account instances
This commit is contained in:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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)) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user