claude-switch-profile 1.0.2 → 1.1.0

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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,45 @@
1
+ # Changelog
2
+
3
+ All notable changes to `claude-switch-profile` are documented here.
4
+
5
+ ## [1.1.0] - 2026-03-13
6
+
7
+ ### Added
8
+ - ✨ **`csp uninstall` command** — Cleanly remove CSP and restore a chosen profile to `~/.claude` before wiping all profile data. Supports `--force` and `--profile <name>` flags.
9
+
10
+ ### Changed
11
+ - 🔄 **`saveSymlinks` now handles real dirs/files** — When a managed item in `~/.claude` is a real directory or file (not yet a symlink), `saveSymlinks` moves it into the profile directory and replaces it with a symlink in-place. Fixes the case where switching from a fresh `~/.claude` setup left the default profile empty.
12
+ - 🔄 **`removeSymlinks` now removes real dirs/files** — Previously only removed symlinks; now also removes real directories and files for managed items so `createSymlinks` is never blocked.
13
+ - 🔄 **`createSymlinks` uses `rmSync` instead of `unlinkSync`** — Correctly handles pre-existing directories (not just files/symlinks) when restoring a profile.
14
+
15
+ ### Fixed
16
+ - 🐛 **`statusline.cjs` broken after switch to default profile** — `saveSymlinks` was copying the file into the profile directory, causing `require('./lib/...')` to fail because `lib/` does not exist there. Now the file stays as a symlink pointing to the source project.
17
+
18
+ ---
19
+
20
+ ## [1.0.2] - 2026-03-12
21
+
22
+ ### Fixed
23
+ - 🐛 **`csp create` produces clean profiles** — New profiles no longer inherit the current session state; they start empty and isolated.
24
+
25
+ ### Changed
26
+ - 🔄 **Max backups reduced to 2** — Keeps `.backup/` lean.
27
+
28
+ ---
29
+
30
+ ## [1.0.1] - 2026-03-11
31
+
32
+ ### Fixed
33
+ - 🐛 Initial bug fixes after first release.
34
+
35
+ ---
36
+
37
+ ## [1.0.0] - 2026-03-11
38
+
39
+ ### Added
40
+ - ✨ Initial release of `claude-switch-profile`
41
+ - `csp init`, `csp create`, `csp use`, `csp save`, `csp list`, `csp current`
42
+ - `csp delete`, `csp export`, `csp import`, `csp diff`
43
+ - Symlink + copy strategy for isolating Claude Code profiles
44
+ - Lock file, automatic backups, Claude process detection
45
+ - Environment variable overrides (`CSP_HOME`, `CSP_CLAUDE_DIR`, `CSP_PROFILES_DIR`)
package/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Claude Switch Profile (CSP)
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/claude-switch-profile)](https://www.npmjs.com/package/claude-switch-profile)
4
+ [![Node.js >= 18](https://img.shields.io/badge/node-%3E%3D18.0.0-brightgreen)](https://nodejs.org)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
6
+
3
7
  A CLI tool for managing multiple Claude Code configurations and profiles. Effortlessly switch between different development setups, each with their own rules, agents, skills, and settings.
4
8
 
5
9
  ## Overview
@@ -104,13 +108,22 @@ csp list
104
108
  Output example:
105
109
 
106
110
  ```
107
- * default — Default profile (initial capture) (2026-03-11)
111
+ * default — Vanilla Claude defaults (2026-03-11)
108
112
  work — Work setup (2026-03-11)
109
113
  experimental (2026-03-11)
110
114
  ```
111
115
 
112
116
  The `*` marks the active profile.
113
117
 
118
+ ### 6. Uninstall CSP
119
+
120
+ Remove CSP and restore your Claude Code to its original state:
121
+
122
+ ```bash
123
+ csp uninstall
124
+ # Uninstall CSP and remove all profiles? This cannot be undone. (y/N)
125
+ ```
126
+
114
127
  ## Commands Reference
115
128
 
116
129
  ### init
@@ -403,6 +416,44 @@ File differences:
403
416
  - Lists file presence and content differences
404
417
  - Shows which files differ and in which profile they exist
405
418
 
419
+ ### uninstall
420
+
421
+ Remove all profiles and restore Claude Code to its pre-CSP state.
422
+
423
+ ```bash
424
+ csp uninstall
425
+ ```
426
+
427
+ **Options:**
428
+ - `-f, --force` — Skip confirmation prompt
429
+ - `--profile <name>` — Restore a specific profile instead of the active one
430
+
431
+ **Examples:**
432
+
433
+ Uninstall with confirmation:
434
+ ```bash
435
+ csp uninstall
436
+ # Uninstall CSP and remove all profiles? This cannot be undone. (y/N)
437
+ ```
438
+
439
+ Restore a specific profile during uninstall:
440
+ ```bash
441
+ csp uninstall --profile production
442
+ ```
443
+
444
+ Force uninstall without prompt:
445
+ ```bash
446
+ csp uninstall --force
447
+ ```
448
+
449
+ **Behavior:**
450
+ 1. Creates a final backup at `~/.claude-profiles/.backup/`
451
+ 2. Restores the active profile (or `--profile` choice) to `~/.claude`
452
+ 3. Removes `~/.claude-profiles/` entirely
453
+ 4. Prints reminder to run `npm uninstall -g claude-switch-profile`
454
+
455
+ ---
456
+
406
457
  ## How Profiles Work
407
458
 
408
459
  ### Profile Storage
@@ -470,6 +521,16 @@ Profiles are stored in `~/.claude-profiles/`:
470
521
  - Each profile needs its own independent configuration
471
522
  - Prevents accidental modifications from affecting other profiles
472
523
 
524
+ ### Real Directory Handling
525
+
526
+ When `csp save` (or `csp use`) encounters a **real directory/file** in `~/.claude` for a managed item (instead of a symlink), it automatically:
527
+
528
+ 1. **Moves** the real item into the profile directory (`~/.claude-profiles/<name>/<item>`)
529
+ 2. **Replaces** it with a symlink at the original location
530
+ 3. **Records** the new location in `source.json`
531
+
532
+ This ensures that profiles created from a fresh `~/.claude` setup (before any symlinks exist) work correctly on first use.
533
+
473
534
  ## Safety Features
474
535
 
475
536
  ### Lock File
@@ -699,6 +760,10 @@ npm run test:cli # CLI integration tests
699
760
  npm run test:safety # Safety feature tests
700
761
  ```
701
762
 
763
+ ### Changelog
764
+
765
+ See [CHANGELOG.md](CHANGELOG.md) for version history and migration guidance.
766
+
702
767
  ### Project Structure
703
768
 
704
769
  ```
package/bin/csp.js CHANGED
@@ -15,6 +15,7 @@ import { exportCommand } from '../src/commands/export.js';
15
15
  import { importCommand } from '../src/commands/import.js';
16
16
  import { diffCommand } from '../src/commands/diff.js';
17
17
  import { initCommand } from '../src/commands/init.js';
18
+ import { uninstallCommand } from '../src/commands/uninstall.js';
18
19
 
19
20
  const __dirname = dirname(fileURLToPath(import.meta.url));
20
21
  const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
@@ -88,4 +89,11 @@ program
88
89
  .description('Compare two profiles (use "current" for active profile)')
89
90
  .action(diffCommand);
90
91
 
92
+ program
93
+ .command('uninstall')
94
+ .description('Remove all profiles and restore Claude Code to pre-CSP state')
95
+ .option('-f, --force', 'Skip confirmation prompt')
96
+ .option('--profile <name>', 'Restore specific profile instead of active one')
97
+ .action(uninstallCommand);
98
+
91
99
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-switch-profile",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "description": "CLI tool for managing multiple Claude Code profiles",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,8 +1,8 @@
1
- import { mkdirSync, cpSync, existsSync, writeFileSync, readdirSync } from 'node:fs';
1
+ import { mkdirSync, cpSync, existsSync, statSync, writeFileSync, readFileSync, copyFileSync } from 'node:fs';
2
2
  import { join, resolve } from 'node:path';
3
3
  import { addProfile, getActive, setActive, profileExists, getProfileDir } from '../profile-store.js';
4
4
  import { saveFiles } from '../file-operations.js';
5
- import { SYMLINK_ITEMS, SYMLINK_DIRS, SOURCE_FILE } from '../constants.js';
5
+ import { CLAUDE_DIR, SYMLINK_ITEMS, SYMLINK_DIRS, SOURCE_FILE } from '../constants.js';
6
6
  import { success, error, info, warn } from '../output-helpers.js';
7
7
 
8
8
  export const createCommand = (name, options) => {
@@ -45,6 +45,22 @@ export const createCommand = (name, options) => {
45
45
  warn(`No recognized items found in "${sourcePath}". Expected: ${SYMLINK_ITEMS.join(', ')}`);
46
46
  }
47
47
 
48
+ // Inherit missing items from current state (hooks, statusline, etc.)
49
+ for (const item of SYMLINK_ITEMS) {
50
+ if (sourceMap[item]) continue;
51
+ const src = join(CLAUDE_DIR, item);
52
+ if (!existsSync(src)) continue;
53
+ try {
54
+ const dest = join(profileDir, item);
55
+ if (statSync(src).isDirectory()) {
56
+ cpSync(src, dest, { recursive: true, dereference: true });
57
+ } else {
58
+ copyFileSync(src, dest);
59
+ }
60
+ sourceMap[item] = dest;
61
+ } catch { /* skip */ }
62
+ }
63
+
48
64
  writeFileSync(join(profileDir, SOURCE_FILE), JSON.stringify(sourceMap, null, 2) + '\n');
49
65
  info(`Linked to kit at ${sourcePath}`);
50
66
  info(`Items found: ${Object.keys(sourceMap).join(', ') || 'none'}`);
@@ -52,21 +68,48 @@ export const createCommand = (name, options) => {
52
68
  // Also copy current mutable files
53
69
  saveFiles(profileDir);
54
70
  } else {
55
- // Create brand new independent profile — everything fresh, nothing copied
71
+ // Create new independent profile — inherit infrastructure from current state
56
72
  mkdirSync(profileDir, { recursive: true });
57
73
 
58
- // Create self-contained directories for each symlink directory item
74
+ // Copy all current symlink items (dirs + files) into profile — dereference symlinks
59
75
  const sourceMap = {};
76
+ for (const item of SYMLINK_ITEMS) {
77
+ const src = join(CLAUDE_DIR, item);
78
+ const dest = join(profileDir, item);
79
+ try {
80
+ if (!existsSync(src)) continue;
81
+ if (statSync(src).isDirectory()) {
82
+ cpSync(src, dest, { recursive: true, dereference: true });
83
+ } else {
84
+ copyFileSync(src, dest);
85
+ }
86
+ sourceMap[item] = dest;
87
+ } catch { /* skip unreadable items */ }
88
+ }
89
+
90
+ // Fallback: ensure empty dirs for any missing SYMLINK_DIRS
60
91
  for (const item of SYMLINK_DIRS) {
61
- const itemDir = join(profileDir, item);
62
- mkdirSync(itemDir, { recursive: true });
63
- sourceMap[item] = itemDir;
92
+ if (!sourceMap[item]) {
93
+ const itemDir = join(profileDir, item);
94
+ mkdirSync(itemDir, { recursive: true });
95
+ sourceMap[item] = itemDir;
96
+ }
64
97
  }
65
98
 
66
99
  writeFileSync(join(profileDir, SOURCE_FILE), JSON.stringify(sourceMap, null, 2) + '\n');
67
- // Do NOT call saveFiles() — new profile should start clean
68
- // without inheriting settings.json, .env, .ck*, etc. from current state
69
- info('Created new independent profile (clean state)');
100
+
101
+ // Copy hooks config from settings.json (hooks won't run without it)
102
+ const settingsPath = join(CLAUDE_DIR, 'settings.json');
103
+ if (existsSync(settingsPath)) {
104
+ try {
105
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
106
+ if (settings.hooks) {
107
+ writeFileSync(join(profileDir, 'settings.json'), JSON.stringify({ hooks: settings.hooks }, null, 2) + '\n');
108
+ }
109
+ } catch { /* parse error — skip */ }
110
+ }
111
+
112
+ info('Created new independent profile (infrastructure inherited)');
70
113
  }
71
114
 
72
115
  addProfile(name, { description: options.description || '' });
@@ -1,8 +1,8 @@
1
- import { existsSync } from 'node:fs';
2
- import { ensureProfilesDir, getActive, profileExists } from '../profile-store.js';
3
- import { createCommand } from './create.js';
1
+ import { existsSync, mkdirSync, writeFileSync, lstatSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { ensureProfilesDir, getActive, addProfile, setActive, getProfileDir } from '../profile-store.js';
4
4
  import { success, info, warn } from '../output-helpers.js';
5
- import { PROFILES_DIR } from '../constants.js';
5
+ import { PROFILES_DIR, SOURCE_FILE, CLAUDE_DIR, SYMLINK_ITEMS } from '../constants.js';
6
6
 
7
7
  export const initCommand = () => {
8
8
  if (existsSync(PROFILES_DIR) && getActive()) {
@@ -15,7 +15,30 @@ export const initCommand = () => {
15
15
  ensureProfilesDir();
16
16
  info('Initializing Claude Switch Profile...');
17
17
 
18
- // Create default profile from current state
19
- createCommand('default', { description: 'Default profile (initial capture)' });
20
- success('Initialization complete. Your current setup is saved as "default".');
18
+ // Create "default" as vanilla Claude — empty profile with no symlinks or copy files.
19
+ // Switching to this profile removes all managed items, restoring Claude's built-in defaults.
20
+ const profileDir = getProfileDir('default');
21
+ mkdirSync(profileDir, { recursive: true });
22
+ writeFileSync(join(profileDir, SOURCE_FILE), '{}\n');
23
+
24
+ addProfile('default', { description: 'Vanilla Claude defaults' });
25
+ setActive('default');
26
+
27
+ success('Initialization complete. "default" profile uses vanilla Claude defaults.');
28
+
29
+ // Warn if real directories exist that should be captured into a profile
30
+ const realItems = SYMLINK_ITEMS.filter((item) => {
31
+ const itemPath = join(CLAUDE_DIR, item);
32
+ try {
33
+ const stat = lstatSync(itemPath);
34
+ return !stat.isSymbolicLink() && (stat.isDirectory() || stat.isFile());
35
+ } catch {
36
+ return false;
37
+ }
38
+ });
39
+
40
+ if (realItems.length) {
41
+ warn(`Real directories/files detected: ${realItems.join(', ')}`);
42
+ info('Run "csp create <name>" to capture them into a profile.');
43
+ }
21
44
  };
@@ -0,0 +1,89 @@
1
+ import { existsSync, rmSync } from 'node:fs';
2
+ import { createInterface } from 'node:readline';
3
+ import { getActive, profileExists, getProfileDir } from '../profile-store.js';
4
+ import { removeSymlinks, restoreSymlinks } from '../symlink-manager.js';
5
+ import { removeFiles, restoreFiles } from '../file-operations.js';
6
+ import { withLock, createBackup, warnIfClaudeRunning } from '../safety.js';
7
+ import { PROFILES_DIR } from '../constants.js';
8
+ import { success, error, info, warn } from '../output-helpers.js';
9
+
10
+ const confirm = (question) => {
11
+ return new Promise((resolve) => {
12
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
13
+ rl.question(question, (answer) => {
14
+ rl.close();
15
+ resolve(answer.toLowerCase().startsWith('y'));
16
+ });
17
+ });
18
+ };
19
+
20
+ export const uninstallCommand = async (options) => {
21
+ const active = getActive();
22
+
23
+ if (!existsSync(PROFILES_DIR)) {
24
+ info('No profiles directory found. CSP is not initialized.');
25
+ info('To remove the CLI: npm uninstall -g claude-switch-profile');
26
+ return;
27
+ }
28
+
29
+ // Show what will happen
30
+ console.log('');
31
+ info('This will:');
32
+ if (options.profile && profileExists(options.profile)) {
33
+ info(` 1. Restore profile "${options.profile}" to ~/.claude`);
34
+ } else if (active && profileExists(active)) {
35
+ info(` 1. Restore active profile "${active}" to ~/.claude (use --profile <name> to choose)`);
36
+ } else {
37
+ warn(' 1. No profile to restore (managed items will be removed)');
38
+ }
39
+ info(` 2. Remove all profiles: ${PROFILES_DIR}`);
40
+ info(' 3. You can then run: npm uninstall -g claude-switch-profile');
41
+ console.log('');
42
+
43
+ if (!options.force) {
44
+ const confirmed = await confirm('Uninstall CSP and remove all profiles? This cannot be undone. (y/N) ');
45
+ if (!confirmed) {
46
+ warn('Cancelled.');
47
+ return;
48
+ }
49
+ }
50
+
51
+ warnIfClaudeRunning();
52
+
53
+ await withLock(async () => {
54
+ // 1. Create final backup before uninstall
55
+ try {
56
+ const backupPath = createBackup();
57
+ info(`Final backup created at ${backupPath}`);
58
+ } catch {
59
+ // Non-critical — profiles dir may be empty
60
+ }
61
+
62
+ // 2. Determine which profile to restore
63
+ const restoreProfile = options.profile || active;
64
+
65
+ // 3. Remove current managed items from ~/.claude
66
+ removeSymlinks();
67
+ removeFiles();
68
+
69
+ // 4. Restore the chosen profile's config
70
+ if (restoreProfile && profileExists(restoreProfile)) {
71
+ const profileDir = getProfileDir(restoreProfile);
72
+ restoreSymlinks(profileDir);
73
+ restoreFiles(profileDir);
74
+ success(`Restored "${restoreProfile}" profile to ~/.claude`);
75
+ } else {
76
+ warn('No profile restored. ~/.claude managed items have been cleared.');
77
+ }
78
+ });
79
+
80
+ // 5. Remove profiles directory (after lock is released)
81
+ rmSync(PROFILES_DIR, { recursive: true, force: true });
82
+ success('Removed all profiles and CSP data.');
83
+
84
+ console.log('');
85
+ info('To complete uninstall, run:');
86
+ info(' npm uninstall -g claude-switch-profile');
87
+ info('');
88
+ info('Restart your Claude Code session to apply changes.');
89
+ };
@@ -1,4 +1,4 @@
1
- import { existsSync, readlinkSync, symlinkSync, unlinkSync, lstatSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, readlinkSync, symlinkSync, unlinkSync, lstatSync, readFileSync, writeFileSync, cpSync, rmSync } from 'node:fs';
2
2
  import { join, resolve } from 'node:path';
3
3
  import { CLAUDE_DIR, SYMLINK_ITEMS, SOURCE_FILE } from './constants.js';
4
4
 
@@ -18,16 +18,24 @@ export const readCurrentSymlinks = () => {
18
18
  return sourceMap;
19
19
  };
20
20
 
21
- // Remove all managed symlinks from ~/.claude
21
+ // Remove all managed symlinks (and real dirs/files) from ~/.claude
22
22
  export const removeSymlinks = () => {
23
23
  for (const item of SYMLINK_ITEMS) {
24
24
  const itemPath = join(CLAUDE_DIR, item);
25
25
  try {
26
- if (existsSync(itemPath) && lstatSync(itemPath).isSymbolicLink()) {
26
+ if (!existsSync(itemPath) && !lstatSync(itemPath).isSymbolicLink()) continue;
27
+ } catch {
28
+ continue; // Doesn't exist at all
29
+ }
30
+ try {
31
+ const stat = lstatSync(itemPath);
32
+ if (stat.isSymbolicLink()) {
27
33
  unlinkSync(itemPath);
34
+ } else if (stat.isDirectory() || stat.isFile()) {
35
+ rmSync(itemPath, { recursive: true });
28
36
  }
29
37
  } catch {
30
- // Already gone or not a symlink — skip
38
+ // Already gone — skip
31
39
  }
32
40
  }
33
41
  };
@@ -38,9 +46,9 @@ export const createSymlinks = (sourceMap) => {
38
46
  if (!SYMLINK_ITEMS.includes(item)) continue;
39
47
  const itemPath = join(CLAUDE_DIR, item);
40
48
 
41
- // Remove existing if present
49
+ // Remove existing (symlink, file, or directory)
42
50
  try {
43
- if (lstatSync(itemPath)) unlinkSync(itemPath);
51
+ if (lstatSync(itemPath)) rmSync(itemPath, { recursive: true, force: true });
44
52
  } catch {
45
53
  // Doesn't exist — fine
46
54
  }
@@ -53,8 +61,33 @@ export const createSymlinks = (sourceMap) => {
53
61
  };
54
62
 
55
63
  // Save current symlink targets to profileDir/source.json
64
+ // For real dirs/files: move them into profileDir and create symlinks in their place
56
65
  export const saveSymlinks = (profileDir) => {
57
- const sourceMap = readCurrentSymlinks();
66
+ const sourceMap = {};
67
+ for (const item of SYMLINK_ITEMS) {
68
+ const itemPath = join(CLAUDE_DIR, item);
69
+ try {
70
+ if (!existsSync(itemPath) && !lstatSync(itemPath).isSymbolicLink()) continue;
71
+ } catch {
72
+ continue;
73
+ }
74
+ try {
75
+ const stat = lstatSync(itemPath);
76
+ if (stat.isSymbolicLink()) {
77
+ // Already a symlink — just record its target
78
+ sourceMap[item] = resolve(CLAUDE_DIR, readlinkSync(itemPath));
79
+ } else if (stat.isDirectory() || stat.isFile()) {
80
+ // Real dir/file — move into profileDir, replace with symlink
81
+ const dest = join(profileDir, item);
82
+ cpSync(itemPath, dest, { recursive: true });
83
+ rmSync(itemPath, { recursive: true });
84
+ symlinkSync(dest, itemPath);
85
+ sourceMap[item] = dest;
86
+ }
87
+ } catch {
88
+ // Not readable — skip
89
+ }
90
+ }
58
91
  writeFileSync(join(profileDir, SOURCE_FILE), JSON.stringify(sourceMap, null, 2) + '\n');
59
92
  return sourceMap;
60
93
  };