claude-switch-profile 1.4.0 → 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,47 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- import { existsSync, mkdirSync } from 'node:fs';
3
- import { resolve, basename } from 'node:path';
2
+ import { existsSync, mkdirSync, lstatSync, readlinkSync, readdirSync, rmSync } from 'node:fs';
3
+ import { resolve, basename, dirname, join } from 'node:path';
4
4
  import { addProfile, profileExists, getProfileDir, ensureProfilesDir } from '../profile-store.js';
5
5
  import { isWindows } from '../platform.js';
6
6
  import { validateProfile } from '../profile-validator.js';
7
+ import { MANAGED_ITEMS, COPY_ITEMS, COPY_DIRS } from '../constants.js';
7
8
  import { success, error, warn } from '../output-helpers.js';
8
9
 
10
+ const IMPORT_SAFE_ITEMS = [...new Set([...MANAGED_ITEMS, ...COPY_ITEMS, ...COPY_DIRS])];
11
+
12
+ const isWithinDir = (baseDir, targetPath) => {
13
+ const normalizedBase = resolve(baseDir);
14
+ const normalizedTarget = resolve(targetPath);
15
+ return normalizedTarget === normalizedBase || normalizedTarget.startsWith(normalizedBase + '/');
16
+ };
17
+
18
+ const findUnsafeManagedSymlink = (profileDir) => {
19
+ const pending = IMPORT_SAFE_ITEMS.map((item) => join(profileDir, item)).filter((itemPath) => existsSync(itemPath));
20
+
21
+ while (pending.length > 0) {
22
+ const currentPath = pending.pop();
23
+ const stat = lstatSync(currentPath);
24
+
25
+ if (stat.isSymbolicLink()) {
26
+ const linkTarget = readlinkSync(currentPath);
27
+ const resolvedTarget = resolve(dirname(currentPath), linkTarget);
28
+ if (!isWithinDir(profileDir, resolvedTarget)) {
29
+ return { path: currentPath, target: linkTarget };
30
+ }
31
+ continue;
32
+ }
33
+
34
+ if (!stat.isDirectory()) continue;
35
+
36
+ const entries = readdirSync(currentPath, { withFileTypes: true });
37
+ for (const entry of entries) {
38
+ pending.push(join(currentPath, entry.name));
39
+ }
40
+ }
41
+
42
+ return null;
43
+ };
44
+
9
45
  export const importCommand = (file, options) => {
10
46
  const filePath = resolve(file);
11
47
  if (!existsSync(filePath)) {
@@ -47,6 +83,15 @@ export const importCommand = (file, options) => {
47
83
  validation.errors.forEach((e) => warn(` ${e}`));
48
84
  }
49
85
 
86
+ const unsafeSymlink = findUnsafeManagedSymlink(profileDir);
87
+ if (unsafeSymlink) {
88
+ rmSync(profileDir, { recursive: true, force: true });
89
+ error(
90
+ `Imported profile contains an unsafe symlink outside the profile tree: ${unsafeSymlink.path} -> ${unsafeSymlink.target}`,
91
+ );
92
+ process.exit(1);
93
+ }
94
+
50
95
  addProfile(name, { description: options.description || `Imported from ${basename(file)}` });
51
96
  success(`Profile "${name}" imported from ${filePath}`);
52
97
  };
@@ -1,23 +1,43 @@
1
1
  import { existsSync } from 'node:fs';
2
- import { ensureProfilesDir, getActive, addProfile, setActive } from '../profile-store.js';
3
- import { success, info } from '../output-helpers.js';
4
- import { PROFILES_DIR } from '../constants.js';
2
+ import {
3
+ ensureProfilesDir,
4
+ ensureDefaultProfileSnapshot,
5
+ getActive,
6
+ readProfiles,
7
+ addProfile,
8
+ setActive,
9
+ getProfileDir,
10
+ } from '../profile-store.js';
11
+ import { success, info, error } from '../output-helpers.js';
12
+ import { PROFILES_DIR, DEFAULT_PROFILE } from '../constants.js';
5
13
 
6
14
  export const initCommand = () => {
7
15
  const active = getActive();
16
+
17
+ ensureProfilesDir();
18
+ info('Initializing Claude Switch Profile...');
19
+
20
+ const profiles = readProfiles();
21
+ if (!profiles[DEFAULT_PROFILE]) {
22
+ addProfile(DEFAULT_PROFILE, { description: 'Vanilla Claude defaults', mode: 'legacy' });
23
+ }
24
+
25
+ try {
26
+ ensureDefaultProfileSnapshot();
27
+ } catch (err) {
28
+ error(err.message);
29
+ process.exit(1);
30
+ }
31
+
8
32
  if (existsSync(PROFILES_DIR) && active) {
9
33
  info(`Already initialized. Active profile: "${active}"`);
10
34
  info(`Profiles directory: ${PROFILES_DIR}`);
11
35
  return;
12
36
  }
13
37
 
14
- ensureProfilesDir();
15
- info('Initializing Claude Switch Profile...');
16
-
17
- addProfile('default', { description: 'Vanilla Claude defaults', mode: 'legacy' });
18
- setActive('default');
38
+ setActive(DEFAULT_PROFILE);
19
39
 
20
40
  success('Initialization complete.');
21
- info('"default" profile uses ~/.claude directly — no copy/restore needed.');
41
+ info(`Created physical default profile at ${getProfileDir(DEFAULT_PROFILE)}`);
22
42
  info('Run "csp create <name>" to capture your current setup into a new profile.');
23
43
  };
@@ -1,7 +1,7 @@
1
1
  import { execFileSync, spawn } from 'node:child_process';
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
- import { profileExists } from '../profile-store.js';
4
+ import { profileExists, ensureDefaultProfileSnapshot } from '../profile-store.js';
5
5
  import { useCommand } from './use.js';
6
6
  import { ensureRuntimeInstance } from '../runtime-instance-manager.js';
7
7
  import { withRuntimeLock } from '../safety.js';
@@ -128,6 +128,15 @@ export const stripInheritedLaunchEnv = (env = process.env) => {
128
128
  };
129
129
 
130
130
  export const launchCommand = async (name, claudeArgs, options = {}) => {
131
+ if (name === DEFAULT_PROFILE) {
132
+ try {
133
+ ensureDefaultProfileSnapshot();
134
+ } catch (err) {
135
+ error(err.message);
136
+ process.exit(1);
137
+ }
138
+ }
139
+
131
140
  if (!profileExists(name)) {
132
141
  error(`Profile "${name}" does not exist. Run "csp list" to see available profiles.`);
133
142
  process.exit(1);
@@ -142,12 +151,8 @@ export const launchCommand = async (name, claudeArgs, options = {}) => {
142
151
  let launchEnv = { ...process.env };
143
152
 
144
153
  if (options.legacyGlobal || legacyFromArgs) {
145
- // Legacy path: keep historical behavior for compatibility.
146
154
  await useCommand(name, { save: true, skipClaudeCheck: true });
147
155
  info(`Launching legacy/global mode: claude ${args.join(' ')}`.trim());
148
- } else if (name === DEFAULT_PROFILE) {
149
- // Default profile: launch Claude using ~/.claude natively — no runtime override
150
- info(`Launching with default profile (using ~/.claude directly): claude ${args.join(' ')}`.trim());
151
156
  } else {
152
157
  const runtimeDir = await withRuntimeLock(name, async () => ensureRuntimeInstance(name));
153
158
  const { profileSettingsEnv, profileDotEnvEnv } = readProfileLaunchEnvSources(runtimeDir);
@@ -1,7 +1,7 @@
1
- import { getActive, profileExists, getProfileDir } from '../profile-store.js';
1
+ import { getActive, profileExists, getProfileDir, ensureDefaultProfileSnapshot } from '../profile-store.js';
2
2
  import { saveItems } from '../item-manager.js';
3
3
  import { saveFiles, updateSettingsPaths } from '../file-operations.js';
4
- import { success, error, info } from '../output-helpers.js';
4
+ import { success, error } from '../output-helpers.js';
5
5
  import { DEFAULT_PROFILE } from '../constants.js';
6
6
 
7
7
  export const saveCommand = () => {
@@ -12,8 +12,12 @@ export const saveCommand = () => {
12
12
  }
13
13
 
14
14
  if (active === DEFAULT_PROFILE) {
15
- info('Default profile uses ~/.claude directly. No save needed.');
16
- return;
15
+ try {
16
+ ensureDefaultProfileSnapshot();
17
+ } catch (err) {
18
+ error(err.message);
19
+ process.exit(1);
20
+ }
17
21
  }
18
22
 
19
23
  if (!profileExists(active)) {
@@ -1,6 +1,6 @@
1
1
  import { existsSync, rmSync } from 'node:fs';
2
2
  import { createInterface } from 'node:readline';
3
- import { getActive, profileExists, getProfileDir } from '../profile-store.js';
3
+ import { getActive, profileExists, getProfileDir, ensureDefaultProfileSnapshot } from '../profile-store.js';
4
4
  import { removeItems, restoreItems } from '../item-manager.js';
5
5
  import { removeFiles, restoreFiles } from '../file-operations.js';
6
6
  import { withLock, createBackup, warnIfClaudeRunning } from '../safety.js';
@@ -26,15 +26,23 @@ export const uninstallCommand = async (options) => {
26
26
  return;
27
27
  }
28
28
 
29
+ const restoreProfile = options.profile || active;
30
+ if (restoreProfile === DEFAULT_PROFILE) {
31
+ try {
32
+ ensureDefaultProfileSnapshot();
33
+ } catch (err) {
34
+ error(err.message);
35
+ process.exit(1);
36
+ }
37
+ }
38
+
29
39
  // Show what will happen
30
40
  console.log('');
31
41
  info('This will:');
32
42
  if (options.profile && profileExists(options.profile)) {
33
43
  info(` 1. Restore profile "${options.profile}" to ~/.claude`);
34
- } else if (active && active !== DEFAULT_PROFILE && profileExists(active)) {
44
+ } else if (active && profileExists(active)) {
35
45
  info(` 1. Restore active profile "${active}" to ~/.claude (use --profile <name> to choose)`);
36
- } else if (active === DEFAULT_PROFILE) {
37
- info(' 1. Default profile — ~/.claude already in correct state');
38
46
  } else {
39
47
  warn(' 1. No profile to restore (managed items will be removed)');
40
48
  }
@@ -61,26 +69,15 @@ export const uninstallCommand = async (options) => {
61
69
  // Non-critical — profiles dir may be empty
62
70
  }
63
71
 
64
- // 2. Determine which profile to restore
65
- const restoreProfile = options.profile || active;
66
-
67
- // 3. Remove current managed items from ~/.claude (skip for default — items live there)
68
- if (restoreProfile !== DEFAULT_PROFILE) {
69
- removeItems();
70
- removeFiles();
71
- }
72
+ // 2. Remove current managed items from ~/.claude
73
+ removeItems();
74
+ removeFiles();
72
75
 
73
- // 4. Restore the chosen profile's config
74
- if (restoreProfile === DEFAULT_PROFILE) {
75
- info('Default profile — ~/.claude already in correct state.');
76
- } else if (restoreProfile && profileExists(restoreProfile)) {
76
+ // 3. Restore the chosen profile's config
77
+ if (restoreProfile && profileExists(restoreProfile)) {
77
78
  const profileDir = getProfileDir(restoreProfile);
78
- // If restoring the active profile: items already in ~/.claude (moved there on switch)
79
- // If restoring a different profile: need to restore from profileDir
80
- if (restoreProfile !== active) {
81
- restoreItems(profileDir);
82
- restoreFiles(profileDir);
83
- }
79
+ restoreItems(profileDir);
80
+ restoreFiles(profileDir);
84
81
  success(`Restored "${restoreProfile}" profile to ~/.claude`);
85
82
  } else {
86
83
  warn('No profile restored. ~/.claude managed items have been cleared.');
@@ -1,24 +1,53 @@
1
- import { getActive, setActive, profileExists, getProfileDir, getProfileMeta, setPrevious } from '../profile-store.js';
2
- import { moveItemsToProfile, moveItemsToClaude, saveItems, removeItems } from '../item-manager.js';
3
- import { saveFiles, removeFiles, restoreFiles, updateSettingsPaths, moveDirsToProfile, moveDirsToClaude } from '../file-operations.js';
1
+ import {
2
+ getActive,
3
+ setActive,
4
+ profileExists,
5
+ getProfileDir,
6
+ getProfileMeta,
7
+ setPrevious,
8
+ ensureDefaultProfileSnapshot,
9
+ } from '../profile-store.js';
10
+ import { moveItemsToProfile, moveItemsToClaude, removeItems } from '../item-manager.js';
11
+ import {
12
+ saveFiles,
13
+ removeFiles,
14
+ restoreFiles,
15
+ updateSettingsPaths,
16
+ moveDirsToProfile,
17
+ moveDirsToClaude,
18
+ } from '../file-operations.js';
4
19
  import { validateProfile } from '../profile-validator.js';
5
20
  import { withLock, assertClaudeNotRunning } from '../safety.js';
6
21
  import { success, error, info, warn } from '../output-helpers.js';
7
22
  import { CLAUDE_DIR, DEFAULT_PROFILE } from '../constants.js';
8
23
 
9
- export const useCommand = async (name, options) => {
10
- // Warn about legacy behavior (skip if called internally from launch --legacy-global)
24
+ const ensureDefaultProfileIfNeeded = (name) => {
25
+ if (name !== DEFAULT_PROFILE) return;
26
+
27
+ try {
28
+ ensureDefaultProfileSnapshot();
29
+ } catch (err) {
30
+ error(err.message);
31
+ process.exit(1);
32
+ }
33
+ };
34
+
35
+ export const useCommand = async (name, options = {}) => {
11
36
  if (!options.skipClaudeCheck) {
12
37
  warn('Note: "csp use" modifies ~/.claude directly (legacy mode).');
13
38
  info('Prefer "csp launch <name>" for isolated sessions that never touch ~/.claude.');
14
39
  }
15
40
 
41
+ ensureDefaultProfileIfNeeded(name);
42
+
16
43
  if (!profileExists(name)) {
17
44
  error(`Profile "${name}" does not exist. Run "csp list" to see available profiles.`);
18
45
  process.exit(1);
19
46
  }
20
47
 
21
48
  const active = getActive();
49
+ ensureDefaultProfileIfNeeded(active);
50
+
22
51
  if (active === name) {
23
52
  info(`Profile "${name}" is already active.`);
24
53
  return;
@@ -32,14 +61,11 @@ export const useCommand = async (name, options) => {
32
61
  info('Legacy "csp use" mutates global ~/.claude state.');
33
62
  }
34
63
 
35
- // Validate target profile (skip for default — it's a pass-through)
36
- if (name !== DEFAULT_PROFILE) {
37
- const validation = validateProfile(profileDir);
38
- if (!validation.valid) {
39
- error(`Profile "${name}" is invalid:`);
40
- validation.errors.forEach((e) => error(` ${e}`));
41
- process.exit(1);
42
- }
64
+ const validation = validateProfile(profileDir);
65
+ if (!validation.valid) {
66
+ error(`Profile "${name}" is invalid:`);
67
+ validation.errors.forEach((validationError) => error(` ${validationError}`));
68
+ process.exit(1);
43
69
  }
44
70
 
45
71
  if (options.dryRun) {
@@ -58,54 +84,33 @@ export const useCommand = async (name, options) => {
58
84
  }
59
85
 
60
86
  await withLock(async () => {
61
- // 1. Save current state (skip if from=default ~/.claude IS default)
62
- if (active && active !== DEFAULT_PROFILE && profileExists(active) && options.save !== false) {
87
+ if (active && profileExists(active) && options.save !== false) {
63
88
  const activeDir = getProfileDir(active);
64
- if (name === DEFAULT_PROFILE) {
65
- saveItems(activeDir);
66
- saveFiles(activeDir);
67
- } else {
68
- moveItemsToProfile(activeDir);
69
- saveFiles(activeDir);
70
- moveDirsToProfile(activeDir);
71
- }
89
+ moveItemsToProfile(activeDir);
90
+ saveFiles(activeDir);
91
+ moveDirsToProfile(activeDir);
72
92
  updateSettingsPaths(activeDir, 'save');
73
93
  info(`Saved current state to "${active}"`);
74
94
  }
75
95
 
76
- // 2. Remove leftovers only when switching to non-default
77
- if (name !== DEFAULT_PROFILE) {
78
- if (active !== DEFAULT_PROFILE) {
79
- removeItems();
80
- }
81
- removeFiles();
82
- }
96
+ removeItems();
97
+ removeFiles();
83
98
 
84
- // 3. Restore target (skip if to=default — ~/.claude already correct)
85
- if (name !== DEFAULT_PROFILE) {
86
- try {
87
- moveItemsToClaude(profileDir);
88
- restoreFiles(profileDir);
89
- moveDirsToClaude(profileDir);
90
- updateSettingsPaths(CLAUDE_DIR, 'restore', profileDir);
91
- } catch (err) {
92
- warn(`Switch failed: ${err.message}`);
93
- error('Manual recovery: re-run "csp use <profile>" or restore from profile directory.');
94
- throw err;
95
- }
99
+ try {
100
+ moveItemsToClaude(profileDir);
101
+ restoreFiles(profileDir);
102
+ moveDirsToClaude(profileDir);
103
+ updateSettingsPaths(CLAUDE_DIR, 'restore', profileDir);
104
+ } catch (err) {
105
+ warn(`Switch failed: ${err.message}`);
106
+ error('Manual recovery: re-run "csp use <profile>" or restore from profile directory.');
107
+ throw err;
96
108
  }
97
109
 
98
- // Track previous profile for toggle
99
110
  if (active) setPrevious(active);
100
-
101
- // 4. Update active marker
102
111
  setActive(name);
103
112
 
104
113
  success(`Switched to profile "${name}"`);
105
- if (name === DEFAULT_PROFILE) {
106
- info('Using ~/.claude directly (default profile).');
107
- } else {
108
- info('Restart your Claude Code session to apply changes.');
109
- }
114
+ info('Restart your Claude Code session to apply changes.');
110
115
  });
111
116
  };
package/src/constants.js CHANGED
@@ -19,7 +19,7 @@ export const RUNTIMES_DIR = join(PROFILES_DIR, RUNTIME_DIR_NAME);
19
19
  export const LAUNCH_CONFIG_ENV = 'CLAUDE_CONFIG_DIR';
20
20
  export const LAUNCH_ANTHROPIC_ENV_KEYS = ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_MODEL'];
21
21
 
22
- // Items managed via copy (formerly symlinks now always copied)
22
+ // Items managed by snapshot/move flows; file/dir/symlink shape is preserved
23
23
  export const MANAGED_ITEMS = [
24
24
  'CLAUDE.md',
25
25
  'rules',
@@ -1,5 +1,5 @@
1
- import { existsSync, copyFileSync, cpSync, unlinkSync, rmSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
2
- import { join } from 'node:path';
1
+ import { existsSync, cpSync, unlinkSync, rmSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
2
+ import { join, dirname } from 'node:path';
3
3
  import { CLAUDE_DIR, COPY_ITEMS, COPY_DIRS } from './constants.js';
4
4
 
5
5
  // Rename-based dir move with EXDEV fallback
@@ -9,12 +9,18 @@ const moveDir = (src, dest) => {
9
9
  renameSync(src, dest);
10
10
  } catch (err) {
11
11
  if (err.code === 'EXDEV') {
12
- cpSync(src, dest, { recursive: true });
12
+ cpSync(src, dest, { recursive: true, verbatimSymlinks: true });
13
13
  rmSync(src, { recursive: true, force: true });
14
14
  } else throw err;
15
15
  }
16
16
  };
17
17
 
18
+ const copyPathPreservingSymlink = (src, dest) => {
19
+ mkdirSync(dirname(dest), { recursive: true });
20
+ rmSync(dest, { recursive: true, force: true });
21
+ cpSync(src, dest, { recursive: true, verbatimSymlinks: true });
22
+ };
23
+
18
24
  // Copy COPY_ITEMS files + COPY_DIRS dirs from ~/.claude to profileDir
19
25
  export const saveFiles = (profileDir) => {
20
26
  mkdirSync(profileDir, { recursive: true });
@@ -22,16 +28,14 @@ export const saveFiles = (profileDir) => {
22
28
  for (const item of COPY_ITEMS) {
23
29
  const src = join(CLAUDE_DIR, item);
24
30
  if (existsSync(src)) {
25
- copyFileSync(src, join(profileDir, item));
31
+ copyPathPreservingSymlink(src, join(profileDir, item));
26
32
  }
27
33
  }
28
34
 
29
35
  for (const dir of COPY_DIRS) {
30
36
  const src = join(CLAUDE_DIR, dir);
31
37
  if (existsSync(src)) {
32
- const dest = join(profileDir, dir);
33
- rmSync(dest, { recursive: true, force: true });
34
- cpSync(src, dest, { recursive: true });
38
+ copyPathPreservingSymlink(src, join(profileDir, dir));
35
39
  }
36
40
  }
37
41
  };
@@ -41,16 +45,14 @@ export const restoreFiles = (profileDir) => {
41
45
  for (const item of COPY_ITEMS) {
42
46
  const src = join(profileDir, item);
43
47
  if (existsSync(src)) {
44
- copyFileSync(src, join(CLAUDE_DIR, item));
48
+ copyPathPreservingSymlink(src, join(CLAUDE_DIR, item));
45
49
  }
46
50
  }
47
51
 
48
52
  for (const dir of COPY_DIRS) {
49
53
  const src = join(profileDir, dir);
50
54
  if (existsSync(src)) {
51
- const dest = join(CLAUDE_DIR, dir);
52
- rmSync(dest, { recursive: true, force: true });
53
- cpSync(src, dest, { recursive: true });
55
+ copyPathPreservingSymlink(src, join(CLAUDE_DIR, dir));
54
56
  }
55
57
  }
56
58
  };
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, writeFileSync, cpSync, rmSync, mkdirSync, lstatSync, copyFileSync, renameSync } from 'node:fs';
1
+ import { existsSync, readFileSync, writeFileSync, cpSync, rmSync, mkdirSync, lstatSync, renameSync } from 'node:fs';
2
2
  import { join, dirname, basename } from 'node:path';
3
3
  import { CLAUDE_DIR, MANAGED_ITEMS, SOURCE_FILE } from './constants.js';
4
4
 
@@ -13,7 +13,7 @@ const moveItem = (src, dest) => {
13
13
  renameSync(src, dest);
14
14
  } catch (err) {
15
15
  if (err.code === 'EXDEV') {
16
- cpSync(src, dest, { recursive: true, filter: skipHeavyDirs });
16
+ cpSync(src, dest, { recursive: true, filter: skipHeavyDirs, verbatimSymlinks: true });
17
17
  rmSync(src, { recursive: true, force: true });
18
18
  } else throw err;
19
19
  }
@@ -49,6 +49,12 @@ export const removeItems = () => {
49
49
  }
50
50
  };
51
51
 
52
+ const copyItemPreservingSymlink = (src, dest) => {
53
+ mkdirSync(dirname(dest), { recursive: true });
54
+ rmSync(dest, { recursive: true, force: true });
55
+ cpSync(src, dest, { recursive: true, verbatimSymlinks: true });
56
+ };
57
+
52
58
  // Copy managed items from ~/.claude into profileDir, write source.json manifest (non-destructive)
53
59
  export const copyItems = (profileDir) => {
54
60
  const sourceMap = {};
@@ -58,14 +64,7 @@ export const copyItems = (profileDir) => {
58
64
  if (!existsSync(itemPath)) continue;
59
65
 
60
66
  const dest = join(profileDir, item);
61
- const stat = lstatSync(itemPath);
62
-
63
- if (stat.isDirectory()) {
64
- rmSync(dest, { recursive: true, force: true });
65
- cpSync(itemPath, dest, { recursive: true });
66
- } else if (stat.isFile()) {
67
- copyFileSync(itemPath, dest);
68
- }
67
+ copyItemPreservingSymlink(itemPath, dest);
69
68
  sourceMap[item] = dest;
70
69
  } catch {
71
70
  // Not readable — skip
@@ -99,12 +98,7 @@ export const restoreItems = (profileDir) => {
99
98
  // Copy from profile (or external legacy path) into ~/.claude
100
99
  if (existsSync(srcPath)) {
101
100
  try {
102
- const stat = lstatSync(srcPath);
103
- if (stat.isDirectory()) {
104
- cpSync(srcPath, dest, { recursive: true });
105
- } else if (stat.isFile()) {
106
- copyFileSync(srcPath, dest);
107
- }
101
+ copyItemPreservingSymlink(srcPath, dest);
108
102
  } catch {
109
103
  // skip unreadable
110
104
  }
@@ -1,5 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
+ import { saveFiles, updateSettingsPaths } from './file-operations.js';
4
+ import { copyItems } from './item-manager.js';
3
5
  import {
4
6
  PROFILES_DIR,
5
7
  ACTIVE_FILE,
@@ -53,6 +55,15 @@ const normalizeProfiles = (raw) => {
53
55
  return normalized;
54
56
  };
55
57
 
58
+ const backfillDefaultProfileSnapshot = () => {
59
+ const profileDir = getProfileDir(DEFAULT_PROFILE);
60
+ mkdirSync(profileDir, { recursive: true });
61
+ copyItems(profileDir);
62
+ saveFiles(profileDir);
63
+ updateSettingsPaths(profileDir, 'save');
64
+ return profileDir;
65
+ };
66
+
56
67
  export const readProfiles = () => {
57
68
  const metaPath = join(PROFILES_DIR, PROFILES_META);
58
69
  if (!existsSync(metaPath)) return {};
@@ -130,12 +141,27 @@ export const removeProfile = (name) => {
130
141
  writeProfiles(profiles);
131
142
  };
132
143
 
144
+ export const ensureDefaultProfileSnapshot = () => {
145
+ const profileDir = getProfileDir(DEFAULT_PROFILE);
146
+ if (existsSync(profileDir)) return profileDir;
147
+
148
+ const profiles = readProfiles();
149
+ if (!profiles[DEFAULT_PROFILE]) return profileDir;
150
+
151
+ const active = getActive();
152
+ if (active && active !== DEFAULT_PROFILE) {
153
+ throw new Error(
154
+ `Default profile snapshot is missing for this legacy install while "${active}" is active. CSP cannot safely recreate "default" from the current ~/.claude state. Switch back to your intended default baseline, then retry.`,
155
+ );
156
+ }
157
+
158
+ return backfillDefaultProfileSnapshot();
159
+ };
160
+
133
161
  export const profileExists = (name) => {
134
- if (name === DEFAULT_PROFILE) return true;
135
162
  return existsSync(getProfileDir(name));
136
163
  };
137
164
 
138
- // Validate profile name — prevent path traversal and injection
139
165
  const SAFE_NAME = /^[a-zA-Z0-9_-]+$/;
140
166
  export const validateName = (name) => {
141
167
  if (!name || !SAFE_NAME.test(name)) {
@@ -170,31 +196,28 @@ export const updateProfileMeta = (name, patch) => {
170
196
 
171
197
  export const markRuntimeInitialized = (name, runtimeDir) => {
172
198
  const now = new Date().toISOString();
173
- return updateProfileMeta(name, {
199
+ return updateProfileMeta(name, (meta) => ({
200
+ ...meta,
174
201
  runtimeDir,
175
202
  runtimeInitializedAt: now,
176
203
  lastLaunchAt: now,
177
- mode: 'account-session',
178
- });
204
+ mode: name === DEFAULT_PROFILE ? meta.mode : 'account-session',
205
+ }));
179
206
  };
180
207
 
181
208
  export const markProfileLaunched = (name) => {
182
- return updateProfileMeta(name, {
209
+ return updateProfileMeta(name, (meta) => ({
210
+ ...meta,
183
211
  lastLaunchAt: new Date().toISOString(),
184
- mode: 'account-session',
185
- });
212
+ mode: name === DEFAULT_PROFILE ? meta.mode : 'account-session',
213
+ }));
186
214
  };
187
215
 
188
216
  export const listProfileNames = () => {
189
217
  return Object.keys(readProfiles());
190
218
  };
191
219
 
192
- // Returns the directory containing a profile's actual files.
193
- // If profile is active, items were moved to CLAUDE_DIR during switch.
194
- // If profile is not active, items are in profileDir.
195
220
  export const getEffectiveDir = (name) => {
196
- if (name === DEFAULT_PROFILE) return CLAUDE_DIR;
197
221
  const active = getActive();
198
- if (active === name) return CLAUDE_DIR;
199
- return getProfileDir(name);
222
+ return active === name ? CLAUDE_DIR : getProfileDir(name);
200
223
  };