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.
- package/README.md +744 -0
- package/bin/csp.js +91 -0
- package/package.json +33 -0
- package/scripts/release.js +110 -0
- package/src/commands/create.js +72 -0
- package/src/commands/current.js +13 -0
- package/src/commands/delete.js +40 -0
- package/src/commands/diff.js +119 -0
- package/src/commands/export.js +24 -0
- package/src/commands/import.js +43 -0
- package/src/commands/init.js +21 -0
- package/src/commands/list.js +26 -0
- package/src/commands/save.js +22 -0
- package/src/commands/use.js +102 -0
- package/src/constants.js +63 -0
- package/src/file-operations.js +64 -0
- package/src/output-helpers.js +27 -0
- package/src/profile-store.js +68 -0
- package/src/profile-validator.js +50 -0
- package/src/safety.js +116 -0
- package/src/symlink-manager.js +69 -0
|
@@ -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
|
+
};
|
package/src/constants.js
ADDED
|
@@ -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
|
+
};
|