claude-switch-profile 1.0.1

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.
@@ -0,0 +1,102 @@
1
+ import { getActive, setActive, profileExists, getProfileDir } from '../profile-store.js';
2
+ import { saveSymlinks, removeSymlinks, restoreSymlinks, createSymlinks } from '../symlink-manager.js';
3
+ import { saveFiles, removeFiles, restoreFiles } from '../file-operations.js';
4
+ import { validateProfile, validateSourceTargets } from '../profile-validator.js';
5
+ import { withLock, warnIfClaudeRunning, createBackup } from '../safety.js';
6
+ import { success, error, info, warn } from '../output-helpers.js';
7
+ import { readFileSync, existsSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { SOURCE_FILE } from '../constants.js';
10
+
11
+ export const useCommand = async (name, options) => {
12
+ if (!profileExists(name)) {
13
+ error(`Profile "${name}" does not exist. Run "csp list" to see available profiles.`);
14
+ process.exit(1);
15
+ }
16
+
17
+ const active = getActive();
18
+ if (active === name) {
19
+ info(`Profile "${name}" is already active.`);
20
+ return;
21
+ }
22
+
23
+ const profileDir = getProfileDir(name);
24
+
25
+ // Validate target profile
26
+ const validation = validateProfile(profileDir);
27
+ if (!validation.valid) {
28
+ error(`Profile "${name}" is invalid:`);
29
+ validation.errors.forEach((e) => error(` ${e}`));
30
+ process.exit(1);
31
+ }
32
+
33
+ // Validate symlink targets exist
34
+ const sourcePath = join(profileDir, SOURCE_FILE);
35
+ try {
36
+ const sourceMap = JSON.parse(readFileSync(sourcePath, 'utf-8'));
37
+ const targetValidation = validateSourceTargets(sourceMap);
38
+ if (!targetValidation.valid) {
39
+ warn('Some symlink targets are missing:');
40
+ targetValidation.errors.forEach((e) => warn(` ${e}`));
41
+ if (!options.force) {
42
+ error('Use --force to switch anyway.');
43
+ process.exit(1);
44
+ }
45
+ }
46
+ } catch {
47
+ // source.json parse error — will be caught during restore
48
+ }
49
+
50
+ if (options.dryRun) {
51
+ info(`[Dry run] Would switch from "${active || 'none'}" to "${name}"`);
52
+ info(`[Dry run] Profile dir: ${profileDir}`);
53
+ return;
54
+ }
55
+
56
+ warnIfClaudeRunning();
57
+
58
+ await withLock(async () => {
59
+ // 1. Save current state to active profile (if any)
60
+ if (active && profileExists(active) && options.save !== false) {
61
+ const activeDir = getProfileDir(active);
62
+ saveSymlinks(activeDir);
63
+ saveFiles(activeDir);
64
+ info(`Saved current state to "${active}"`);
65
+ }
66
+
67
+ // 2. Auto-backup
68
+ const backupPath = createBackup();
69
+ info(`Backup created at ${backupPath}`);
70
+
71
+ // 3. Remove managed items + restore target — with rollback on failure
72
+ removeSymlinks();
73
+ removeFiles();
74
+
75
+ try {
76
+ restoreSymlinks(profileDir);
77
+ restoreFiles(profileDir);
78
+ } catch (err) {
79
+ // Rollback: restore from backup
80
+ warn('Switch failed — rolling back from backup...');
81
+ try {
82
+ const backupSource = join(backupPath, SOURCE_FILE);
83
+ if (existsSync(backupSource)) {
84
+ const backupMap = JSON.parse(readFileSync(backupSource, 'utf-8'));
85
+ createSymlinks(backupMap);
86
+ }
87
+ restoreFiles(backupPath);
88
+ warn('Rollback complete. Previous config restored.');
89
+ } catch (rollbackErr) {
90
+ error(`Rollback also failed: ${rollbackErr.message}`);
91
+ error(`Manual recovery: restore from ${backupPath}`);
92
+ }
93
+ throw err;
94
+ }
95
+
96
+ // 4. Update active marker
97
+ setActive(name);
98
+
99
+ success(`Switched to profile "${name}"`);
100
+ info('Restart your Claude Code session to apply changes.');
101
+ });
102
+ };
@@ -0,0 +1,63 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+
4
+ // Allow override via env for testing
5
+ const home = process.env.CSP_HOME || homedir();
6
+
7
+ export const CLAUDE_DIR = process.env.CSP_CLAUDE_DIR || join(home, '.claude');
8
+ export const PROFILES_DIR = process.env.CSP_PROFILES_DIR || join(home, '.claude-profiles');
9
+
10
+ export const ACTIVE_FILE = '.active';
11
+ export const PROFILES_META = 'profiles.json';
12
+ export const SOURCE_FILE = 'source.json';
13
+ export const LOCK_FILE = '.lock';
14
+ export const BACKUP_DIR = '.backup';
15
+
16
+ // Items managed via symlinks — these point to external dirs/files
17
+ export const SYMLINK_ITEMS = [
18
+ 'CLAUDE.md',
19
+ 'rules',
20
+ 'agents',
21
+ 'skills',
22
+ 'hooks',
23
+ 'statusline.cjs',
24
+ '.luna.json',
25
+ ];
26
+
27
+ // Mutable files managed via copy
28
+ export const COPY_ITEMS = [
29
+ 'settings.json',
30
+ '.env',
31
+ '.ck.json',
32
+ '.ckignore',
33
+ ];
34
+
35
+ // Directories managed via copy
36
+ export const COPY_DIRS = [
37
+ 'commands',
38
+ 'plugins',
39
+ ];
40
+
41
+ // Never touch these — runtime/session data
42
+ export const NEVER_TOUCH = [
43
+ '.credentials.json',
44
+ 'projects',
45
+ 'backups',
46
+ 'cache',
47
+ 'debug',
48
+ 'telemetry',
49
+ 'shell-snapshots',
50
+ 'paste-cache',
51
+ 'file-history',
52
+ 'ide',
53
+ 'session-env',
54
+ 'todos',
55
+ 'tasks',
56
+ 'teams',
57
+ 'agent-memory',
58
+ 'history.jsonl',
59
+ 'plans',
60
+ ];
61
+
62
+ // All managed items (symlinks + copies + copy dirs)
63
+ export const ALL_MANAGED = [...SYMLINK_ITEMS, ...COPY_ITEMS, ...COPY_DIRS];
@@ -0,0 +1,64 @@
1
+ import { existsSync, copyFileSync, cpSync, unlinkSync, rmSync, mkdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { CLAUDE_DIR, COPY_ITEMS, COPY_DIRS } from './constants.js';
4
+
5
+ // Copy COPY_ITEMS files + COPY_DIRS dirs from ~/.claude to profileDir
6
+ export const saveFiles = (profileDir) => {
7
+ mkdirSync(profileDir, { recursive: true });
8
+
9
+ for (const item of COPY_ITEMS) {
10
+ const src = join(CLAUDE_DIR, item);
11
+ if (existsSync(src)) {
12
+ copyFileSync(src, join(profileDir, item));
13
+ }
14
+ }
15
+
16
+ for (const dir of COPY_DIRS) {
17
+ const src = join(CLAUDE_DIR, dir);
18
+ if (existsSync(src)) {
19
+ const dest = join(profileDir, dir);
20
+ rmSync(dest, { recursive: true, force: true });
21
+ cpSync(src, dest, { recursive: true });
22
+ }
23
+ }
24
+ };
25
+
26
+ // Copy files + dirs from profileDir back to ~/.claude
27
+ export const restoreFiles = (profileDir) => {
28
+ for (const item of COPY_ITEMS) {
29
+ const src = join(profileDir, item);
30
+ if (existsSync(src)) {
31
+ copyFileSync(src, join(CLAUDE_DIR, item));
32
+ }
33
+ }
34
+
35
+ for (const dir of COPY_DIRS) {
36
+ const src = join(profileDir, dir);
37
+ if (existsSync(src)) {
38
+ const dest = join(CLAUDE_DIR, dir);
39
+ rmSync(dest, { recursive: true, force: true });
40
+ cpSync(src, dest, { recursive: true });
41
+ }
42
+ }
43
+ };
44
+
45
+ // Remove managed COPY_ITEMS files and COPY_DIRS from ~/.claude
46
+ export const removeFiles = () => {
47
+ for (const item of COPY_ITEMS) {
48
+ const itemPath = join(CLAUDE_DIR, item);
49
+ try {
50
+ if (existsSync(itemPath)) unlinkSync(itemPath);
51
+ } catch {
52
+ // Skip
53
+ }
54
+ }
55
+
56
+ for (const dir of COPY_DIRS) {
57
+ const dirPath = join(CLAUDE_DIR, dir);
58
+ try {
59
+ if (existsSync(dirPath)) rmSync(dirPath, { recursive: true, force: true });
60
+ } catch {
61
+ // Skip
62
+ }
63
+ }
64
+ };
@@ -0,0 +1,27 @@
1
+ import chalk from 'chalk';
2
+
3
+ export const success = (msg) => console.log(chalk.green('✓'), msg);
4
+ export const warn = (msg) => console.log(chalk.yellow('⚠'), msg);
5
+ export const error = (msg) => console.error(chalk.red('✗'), msg);
6
+ export const info = (msg) => console.log(chalk.blue('ℹ'), msg);
7
+
8
+ export const table = (rows, headers) => {
9
+ if (!rows.length) return;
10
+
11
+ const cols = headers || Object.keys(rows[0]);
12
+ const widths = cols.map((col) => {
13
+ const maxData = Math.max(...rows.map((r) => String(r[col] || '').length));
14
+ return Math.max(col.length, maxData);
15
+ });
16
+
17
+ // Header
18
+ const headerLine = cols.map((c, i) => c.padEnd(widths[i])).join(' ');
19
+ console.log(chalk.bold(headerLine));
20
+ console.log(widths.map((w) => '─'.repeat(w)).join('──'));
21
+
22
+ // Rows
23
+ for (const row of rows) {
24
+ const line = cols.map((c, i) => String(row[c] || '').padEnd(widths[i])).join(' ');
25
+ console.log(line);
26
+ }
27
+ };
@@ -0,0 +1,68 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { PROFILES_DIR, ACTIVE_FILE, PROFILES_META } from './constants.js';
4
+
5
+ export const ensureProfilesDir = () => {
6
+ if (!existsSync(PROFILES_DIR)) {
7
+ mkdirSync(PROFILES_DIR, { recursive: true });
8
+ }
9
+ };
10
+
11
+ export const readProfiles = () => {
12
+ const metaPath = join(PROFILES_DIR, PROFILES_META);
13
+ if (!existsSync(metaPath)) return {};
14
+ return JSON.parse(readFileSync(metaPath, 'utf-8'));
15
+ };
16
+
17
+ export const writeProfiles = (data) => {
18
+ ensureProfilesDir();
19
+ writeFileSync(join(PROFILES_DIR, PROFILES_META), JSON.stringify(data, null, 2) + '\n');
20
+ };
21
+
22
+ export const getActive = () => {
23
+ const activePath = join(PROFILES_DIR, ACTIVE_FILE);
24
+ if (!existsSync(activePath)) return null;
25
+ return readFileSync(activePath, 'utf-8').trim() || null;
26
+ };
27
+
28
+ export const setActive = (name) => {
29
+ ensureProfilesDir();
30
+ writeFileSync(join(PROFILES_DIR, ACTIVE_FILE), name + '\n');
31
+ };
32
+
33
+ export const addProfile = (name, metadata = {}) => {
34
+ const profiles = readProfiles();
35
+ profiles[name] = {
36
+ created: new Date().toISOString(),
37
+ description: '',
38
+ ...metadata,
39
+ };
40
+ writeProfiles(profiles);
41
+ };
42
+
43
+ export const removeProfile = (name) => {
44
+ const profiles = readProfiles();
45
+ delete profiles[name];
46
+ writeProfiles(profiles);
47
+ };
48
+
49
+ export const profileExists = (name) => {
50
+ return existsSync(getProfileDir(name));
51
+ };
52
+
53
+ // Validate profile name — prevent path traversal and injection
54
+ const SAFE_NAME = /^[a-zA-Z0-9_-]+$/;
55
+ export const validateName = (name) => {
56
+ if (!name || !SAFE_NAME.test(name)) {
57
+ throw new Error(`Invalid profile name: "${name}". Use only letters, numbers, hyphens, underscores.`);
58
+ }
59
+ };
60
+
61
+ export const getProfileDir = (name) => {
62
+ validateName(name);
63
+ return join(PROFILES_DIR, name);
64
+ };
65
+
66
+ export const listProfileNames = () => {
67
+ return Object.keys(readProfiles());
68
+ };
@@ -0,0 +1,50 @@
1
+ import { existsSync, readdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { SOURCE_FILE, SYMLINK_ITEMS } from './constants.js';
4
+
5
+ // Validate a profile directory
6
+ export const validateProfile = (profileDir) => {
7
+ const errors = [];
8
+
9
+ if (!existsSync(profileDir)) {
10
+ return { valid: false, errors: ['Profile directory does not exist'] };
11
+ }
12
+
13
+ const sourcePath = join(profileDir, SOURCE_FILE);
14
+ if (!existsSync(sourcePath)) {
15
+ errors.push('Missing source.json — no symlink targets defined');
16
+ }
17
+
18
+ return { valid: errors.length === 0, errors };
19
+ };
20
+
21
+ // Check all symlink targets in sourceMap actually exist on disk
22
+ export const validateSourceTargets = (sourceMap) => {
23
+ const errors = [];
24
+ for (const [item, target] of Object.entries(sourceMap)) {
25
+ if (!existsSync(target)) {
26
+ errors.push(`${item}: target does not exist → ${target}`);
27
+ }
28
+ }
29
+ return { valid: errors.length === 0, errors };
30
+ };
31
+
32
+ // List what files/symlinks a profile contains
33
+ export const listManagedItems = (profileDir) => {
34
+ if (!existsSync(profileDir)) return { symlinks: [], files: [], dirs: [] };
35
+
36
+ const entries = readdirSync(profileDir, { withFileTypes: true });
37
+ const items = { symlinks: [], files: [], dirs: [] };
38
+
39
+ for (const entry of entries) {
40
+ if (entry.name === SOURCE_FILE) {
41
+ items.files.push(entry.name);
42
+ } else if (entry.isDirectory()) {
43
+ items.dirs.push(entry.name);
44
+ } else {
45
+ items.files.push(entry.name);
46
+ }
47
+ }
48
+
49
+ return items;
50
+ };
package/src/safety.js ADDED
@@ -0,0 +1,116 @@
1
+ import { existsSync, writeFileSync, readFileSync, unlinkSync, mkdirSync, cpSync, readdirSync, rmSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { PROFILES_DIR, LOCK_FILE, BACKUP_DIR, CLAUDE_DIR, COPY_ITEMS, COPY_DIRS } from './constants.js';
5
+ import { readCurrentSymlinks } from './symlink-manager.js';
6
+ import { warn } from './output-helpers.js';
7
+
8
+ const MAX_BACKUPS = 5;
9
+
10
+ // Acquire a lock file — atomic O_EXCL prevents TOCTOU race
11
+ export const acquireLock = () => {
12
+ const lockPath = join(PROFILES_DIR, LOCK_FILE);
13
+
14
+ try {
15
+ writeFileSync(lockPath, String(process.pid) + '\n', { flag: 'wx' });
16
+ } catch (err) {
17
+ if (err.code === 'EEXIST') {
18
+ // Lock exists — check if stale
19
+ const content = readFileSync(lockPath, 'utf-8').trim();
20
+ const pid = parseInt(content, 10);
21
+
22
+ if (pid && !isProcessRunning(pid)) {
23
+ unlinkSync(lockPath);
24
+ writeFileSync(lockPath, String(process.pid) + '\n', { flag: 'wx' });
25
+ } else {
26
+ throw new Error(`Another csp operation is running (PID: ${content}). Remove ${lockPath} if stale.`);
27
+ }
28
+ } else {
29
+ throw err;
30
+ }
31
+ }
32
+ };
33
+
34
+ // Release the lock file
35
+ export const releaseLock = () => {
36
+ const lockPath = join(PROFILES_DIR, LOCK_FILE);
37
+ try {
38
+ if (existsSync(lockPath)) unlinkSync(lockPath);
39
+ } catch {
40
+ // Best effort
41
+ }
42
+ };
43
+
44
+ // Wrapper that acquires/releases lock around async function
45
+ export const withLock = async (fn) => {
46
+ acquireLock();
47
+ try {
48
+ return await fn();
49
+ } finally {
50
+ releaseLock();
51
+ }
52
+ };
53
+
54
+ // Check if a PID is still running
55
+ const isProcessRunning = (pid) => {
56
+ try {
57
+ process.kill(pid, 0);
58
+ return true;
59
+ } catch {
60
+ return false;
61
+ }
62
+ };
63
+
64
+ // Check if claude process is running
65
+ export const isClaudeRunning = () => {
66
+ try {
67
+ const result = execFileSync('pgrep', ['-x', 'claude'], { encoding: 'utf-8' });
68
+ return result.trim().length > 0;
69
+ } catch {
70
+ return false;
71
+ }
72
+ };
73
+
74
+ // Print warning if Claude is detected running
75
+ export const warnIfClaudeRunning = () => {
76
+ if (isClaudeRunning()) {
77
+ warn('Claude Code appears to be running. Restart your Claude session after switching profiles.');
78
+ }
79
+ };
80
+
81
+ // Create backup of current managed items, prune old backups
82
+ export const createBackup = () => {
83
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
84
+ const backupBase = join(PROFILES_DIR, BACKUP_DIR);
85
+ const backupPath = join(backupBase, timestamp);
86
+ mkdirSync(backupPath, { recursive: true });
87
+
88
+ // Save symlink targets
89
+ const sourceMap = readCurrentSymlinks();
90
+ writeFileSync(join(backupPath, 'source.json'), JSON.stringify(sourceMap, null, 2) + '\n');
91
+
92
+ // Copy mutable files
93
+ for (const item of COPY_ITEMS) {
94
+ const src = join(CLAUDE_DIR, item);
95
+ if (existsSync(src)) cpSync(src, join(backupPath, item));
96
+ }
97
+
98
+ // Copy dirs
99
+ for (const dir of COPY_DIRS) {
100
+ const src = join(CLAUDE_DIR, dir);
101
+ if (existsSync(src)) cpSync(src, join(backupPath, dir), { recursive: true });
102
+ }
103
+
104
+ // Prune old backups — keep only MAX_BACKUPS most recent
105
+ try {
106
+ const backups = readdirSync(backupBase).sort();
107
+ while (backups.length > MAX_BACKUPS) {
108
+ const oldest = backups.shift();
109
+ rmSync(join(backupBase, oldest), { recursive: true, force: true });
110
+ }
111
+ } catch {
112
+ // Non-critical — skip pruning
113
+ }
114
+
115
+ return backupPath;
116
+ };
@@ -0,0 +1,69 @@
1
+ import { existsSync, readlinkSync, symlinkSync, unlinkSync, lstatSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
3
+ import { CLAUDE_DIR, SYMLINK_ITEMS, SOURCE_FILE } from './constants.js';
4
+
5
+ // Read current symlink targets from ~/.claude for all SYMLINK_ITEMS
6
+ export const readCurrentSymlinks = () => {
7
+ const sourceMap = {};
8
+ for (const item of SYMLINK_ITEMS) {
9
+ const itemPath = join(CLAUDE_DIR, item);
10
+ try {
11
+ if (existsSync(itemPath) && lstatSync(itemPath).isSymbolicLink()) {
12
+ sourceMap[item] = resolve(CLAUDE_DIR, readlinkSync(itemPath));
13
+ }
14
+ } catch {
15
+ // Item doesn't exist or isn't readable — skip
16
+ }
17
+ }
18
+ return sourceMap;
19
+ };
20
+
21
+ // Remove all managed symlinks from ~/.claude
22
+ export const removeSymlinks = () => {
23
+ for (const item of SYMLINK_ITEMS) {
24
+ const itemPath = join(CLAUDE_DIR, item);
25
+ try {
26
+ if (existsSync(itemPath) && lstatSync(itemPath).isSymbolicLink()) {
27
+ unlinkSync(itemPath);
28
+ }
29
+ } catch {
30
+ // Already gone or not a symlink — skip
31
+ }
32
+ }
33
+ };
34
+
35
+ // Create symlinks in ~/.claude from a sourceMap object
36
+ export const createSymlinks = (sourceMap) => {
37
+ for (const [item, target] of Object.entries(sourceMap)) {
38
+ if (!SYMLINK_ITEMS.includes(item)) continue;
39
+ const itemPath = join(CLAUDE_DIR, item);
40
+
41
+ // Remove existing if present
42
+ try {
43
+ if (lstatSync(itemPath)) unlinkSync(itemPath);
44
+ } catch {
45
+ // Doesn't exist — fine
46
+ }
47
+
48
+ // Only create if target exists
49
+ if (existsSync(target)) {
50
+ symlinkSync(target, itemPath);
51
+ }
52
+ }
53
+ };
54
+
55
+ // Save current symlink targets to profileDir/source.json
56
+ export const saveSymlinks = (profileDir) => {
57
+ const sourceMap = readCurrentSymlinks();
58
+ writeFileSync(join(profileDir, SOURCE_FILE), JSON.stringify(sourceMap, null, 2) + '\n');
59
+ return sourceMap;
60
+ };
61
+
62
+ // Read source.json from profileDir and create symlinks
63
+ export const restoreSymlinks = (profileDir) => {
64
+ const sourcePath = join(profileDir, SOURCE_FILE);
65
+ if (!existsSync(sourcePath)) return {};
66
+ const sourceMap = JSON.parse(readFileSync(sourcePath, 'utf-8'));
67
+ createSymlinks(sourceMap);
68
+ return sourceMap;
69
+ };