claude-switch-profile 1.0.2 → 1.4.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 +69 -0
- package/README.md +205 -55
- package/bin/csp.js +52 -3
- package/package.json +1 -1
- package/src/commands/create.js +76 -18
- package/src/commands/current.js +8 -3
- package/src/commands/deactivate.js +22 -0
- package/src/commands/delete.js +19 -7
- package/src/commands/diff.js +19 -9
- package/src/commands/export.js +30 -5
- package/src/commands/import.js +11 -2
- package/src/commands/init.js +10 -8
- package/src/commands/launch.js +216 -0
- package/src/commands/list.js +2 -1
- package/src/commands/save.js +11 -4
- package/src/commands/select.js +126 -0
- package/src/commands/status.js +48 -0
- package/src/commands/toggle.js +25 -0
- package/src/commands/uninstall.js +99 -0
- package/src/commands/use.js +67 -58
- package/src/constants.js +43 -16
- package/src/file-operations.js +80 -1
- package/src/item-manager.js +155 -0
- package/src/launch-effective-env-resolver.js +192 -0
- package/src/platform.js +24 -0
- package/src/profile-store.js +139 -7
- package/src/profile-validator.js +7 -9
- package/src/runtime-instance-manager.js +190 -0
- package/src/safety.js +72 -35
- package/src/symlink-manager.js +0 -69
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, cpSync, rmSync, lstatSync, statSync, copyFileSync, readFileSync, writeFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import { MANAGED_ITEMS, COPY_ITEMS, COPY_DIRS, CLAUDE_DIR, DEFAULT_PROFILE } from './constants.js';
|
|
4
|
+
import {
|
|
5
|
+
getActive,
|
|
6
|
+
getProfileDir,
|
|
7
|
+
getProfileMeta,
|
|
8
|
+
getRuntimeDir,
|
|
9
|
+
markRuntimeInitialized,
|
|
10
|
+
markProfileLaunched,
|
|
11
|
+
} from './profile-store.js';
|
|
12
|
+
|
|
13
|
+
const removePath = (targetPath) => {
|
|
14
|
+
if (!existsSync(targetPath)) return;
|
|
15
|
+
rmSync(targetPath, { recursive: true, force: true });
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const shouldSyncDir = (src, dest) => {
|
|
19
|
+
if (!existsSync(dest)) return true;
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const pending = [[src, dest]];
|
|
23
|
+
|
|
24
|
+
while (pending.length > 0) {
|
|
25
|
+
const [srcDir, destDir] = pending.pop();
|
|
26
|
+
const srcEntries = readdirSync(srcDir, { withFileTypes: true });
|
|
27
|
+
const destEntries = readdirSync(destDir, { withFileTypes: true });
|
|
28
|
+
|
|
29
|
+
if (srcEntries.length !== destEntries.length) return true;
|
|
30
|
+
|
|
31
|
+
const destMap = new Map(destEntries.map((entry) => [entry.name, entry]));
|
|
32
|
+
|
|
33
|
+
for (const srcEntry of srcEntries) {
|
|
34
|
+
const destEntry = destMap.get(srcEntry.name);
|
|
35
|
+
if (!destEntry) return true;
|
|
36
|
+
|
|
37
|
+
const srcPath = join(srcDir, srcEntry.name);
|
|
38
|
+
const destPath = join(destDir, srcEntry.name);
|
|
39
|
+
|
|
40
|
+
if (srcEntry.isDirectory()) {
|
|
41
|
+
if (!destEntry.isDirectory()) return true;
|
|
42
|
+
pending.push([srcPath, destPath]);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (srcEntry.isFile()) {
|
|
47
|
+
if (!destEntry.isFile()) return true;
|
|
48
|
+
const srcStat = statSync(srcPath);
|
|
49
|
+
const destStat = statSync(destPath);
|
|
50
|
+
if (srcStat.size !== destStat.size) return true;
|
|
51
|
+
if (srcStat.mtimeMs !== destStat.mtimeMs) return true;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return false;
|
|
57
|
+
} catch {
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const replacePathVariants = (content, fromPath, toPath) => {
|
|
63
|
+
const fromEscaped = fromPath.replaceAll('\\', '\\\\');
|
|
64
|
+
const toEscaped = toPath.replaceAll('\\', '\\\\');
|
|
65
|
+
const fromFwd = fromPath.replaceAll('\\', '/');
|
|
66
|
+
const toFwd = toPath.replaceAll('\\', '/');
|
|
67
|
+
|
|
68
|
+
let updated = content;
|
|
69
|
+
updated = updated.replaceAll(fromEscaped, toEscaped);
|
|
70
|
+
updated = updated.replaceAll(fromFwd, toFwd);
|
|
71
|
+
if (fromPath !== fromEscaped) {
|
|
72
|
+
updated = updated.replaceAll(fromPath, toPath);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return updated;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const rewriteSettingsForRuntime = (runtimeDir, sourceDir) => {
|
|
79
|
+
const settingsPath = join(runtimeDir, 'settings.json');
|
|
80
|
+
if (!existsSync(settingsPath)) return;
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const raw = readFileSync(settingsPath, 'utf-8');
|
|
84
|
+
let updated = raw;
|
|
85
|
+
|
|
86
|
+
updated = replacePathVariants(updated, sourceDir, runtimeDir);
|
|
87
|
+
updated = replacePathVariants(updated, CLAUDE_DIR, runtimeDir);
|
|
88
|
+
|
|
89
|
+
if (updated !== raw) {
|
|
90
|
+
writeFileSync(settingsPath, updated);
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
// Best effort
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const getStaticItems = () => {
|
|
98
|
+
return [...new Set([...MANAGED_ITEMS, ...COPY_ITEMS, ...COPY_DIRS])];
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const resolveSourceDir = (profileName) => {
|
|
102
|
+
if (profileName === DEFAULT_PROFILE) {
|
|
103
|
+
return CLAUDE_DIR;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const active = getActive();
|
|
107
|
+
if (active === profileName) {
|
|
108
|
+
return CLAUDE_DIR;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return getProfileDir(profileName);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const resolveItemSource = (profileName, item) => {
|
|
115
|
+
const profileDir = profileName === DEFAULT_PROFILE ? null : getProfileDir(profileName);
|
|
116
|
+
const active = getActive();
|
|
117
|
+
const profileIsActive = active === profileName;
|
|
118
|
+
|
|
119
|
+
// When profile is active or default, items live in CLAUDE_DIR
|
|
120
|
+
if (profileIsActive || profileName === DEFAULT_PROFILE) {
|
|
121
|
+
const claudePath = join(CLAUDE_DIR, item);
|
|
122
|
+
if (existsSync(claudePath)) return claudePath;
|
|
123
|
+
// Fallback: items may still be in profileDir (not yet moved by `use`)
|
|
124
|
+
if (profileDir) {
|
|
125
|
+
const profilePath = join(profileDir, item);
|
|
126
|
+
if (existsSync(profilePath)) return profilePath;
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Profile is NOT active — items should be in profileDir
|
|
132
|
+
const profilePath = join(profileDir, item);
|
|
133
|
+
if (existsSync(profilePath)) return profilePath;
|
|
134
|
+
|
|
135
|
+
return null;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export const syncStaticConfig = (profileName, runtimeDir) => {
|
|
139
|
+
const sourceDir = resolveSourceDir(profileName);
|
|
140
|
+
const staticItems = getStaticItems();
|
|
141
|
+
|
|
142
|
+
mkdirSync(runtimeDir, { recursive: true });
|
|
143
|
+
|
|
144
|
+
for (const item of staticItems) {
|
|
145
|
+
const src = resolveItemSource(profileName, item);
|
|
146
|
+
const dest = join(runtimeDir, item);
|
|
147
|
+
|
|
148
|
+
if (!src) {
|
|
149
|
+
// Source doesn't exist anywhere — skip (don't delete existing runtime items)
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const stat = lstatSync(src);
|
|
154
|
+
|
|
155
|
+
if (stat.isDirectory()) {
|
|
156
|
+
if (!shouldSyncDir(src, dest)) continue;
|
|
157
|
+
removePath(dest);
|
|
158
|
+
cpSync(src, dest, { recursive: true });
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (stat.isFile()) {
|
|
163
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
164
|
+
copyFileSync(src, dest);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
rewriteSettingsForRuntime(runtimeDir, sourceDir);
|
|
169
|
+
|
|
170
|
+
return { runtimeDir, sourceDir };
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
export const seedRuntimeIfNeeded = (profileName) => {
|
|
174
|
+
const meta = getProfileMeta(profileName);
|
|
175
|
+
const runtimeDir = meta?.runtimeDir || getRuntimeDir(profileName);
|
|
176
|
+
|
|
177
|
+
syncStaticConfig(profileName, runtimeDir);
|
|
178
|
+
|
|
179
|
+
if (!meta || !meta.runtimeInitializedAt || meta.runtimeDir !== runtimeDir) {
|
|
180
|
+
markRuntimeInitialized(profileName, runtimeDir);
|
|
181
|
+
} else {
|
|
182
|
+
markProfileLaunched(profileName);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return runtimeDir;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
export const ensureRuntimeInstance = (profileName) => {
|
|
189
|
+
return seedRuntimeIfNeeded(profileName);
|
|
190
|
+
};
|
package/src/safety.js
CHANGED
|
@@ -1,39 +1,45 @@
|
|
|
1
1
|
import { existsSync, writeFileSync, readFileSync, unlinkSync, mkdirSync, cpSync, readdirSync, rmSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import { execFileSync } from 'node:child_process';
|
|
4
3
|
import { PROFILES_DIR, LOCK_FILE, BACKUP_DIR, CLAUDE_DIR, COPY_ITEMS, COPY_DIRS } from './constants.js';
|
|
5
|
-
import {
|
|
4
|
+
import { readCurrentItems } from './item-manager.js';
|
|
5
|
+
import { findProcess } from './platform.js';
|
|
6
6
|
import { warn } from './output-helpers.js';
|
|
7
7
|
|
|
8
|
+
const CLAUDE_RUNNING_ERROR = 'Claude Code appears to be running. Close all Claude sessions before switching profiles.';
|
|
9
|
+
|
|
8
10
|
const MAX_BACKUPS = 2;
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
const isProcessRunning = (pid) => {
|
|
13
|
+
try {
|
|
14
|
+
process.kill(pid, 0);
|
|
15
|
+
return true;
|
|
16
|
+
} catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const acquireLockFile = (lockPath) => {
|
|
22
|
+
mkdirSync(PROFILES_DIR, { recursive: true });
|
|
13
23
|
|
|
14
24
|
try {
|
|
15
25
|
writeFileSync(lockPath, String(process.pid) + '\n', { flag: 'wx' });
|
|
16
26
|
} catch (err) {
|
|
17
|
-
if (err.code
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
throw new Error(`Another csp operation is running (PID: ${content}). Remove ${lockPath} if stale.`);
|
|
27
|
-
}
|
|
28
|
-
} else {
|
|
29
|
-
throw err;
|
|
27
|
+
if (err.code !== 'EEXIST') throw err;
|
|
28
|
+
|
|
29
|
+
const content = readFileSync(lockPath, 'utf-8').trim();
|
|
30
|
+
const pid = parseInt(content, 10);
|
|
31
|
+
|
|
32
|
+
if (pid && !isProcessRunning(pid)) {
|
|
33
|
+
unlinkSync(lockPath);
|
|
34
|
+
writeFileSync(lockPath, String(process.pid) + '\n', { flag: 'wx' });
|
|
35
|
+
return;
|
|
30
36
|
}
|
|
37
|
+
|
|
38
|
+
throw new Error(`Another csp operation is running (PID: ${content}). Remove ${lockPath} if stale.`);
|
|
31
39
|
}
|
|
32
40
|
};
|
|
33
41
|
|
|
34
|
-
|
|
35
|
-
export const releaseLock = () => {
|
|
36
|
-
const lockPath = join(PROFILES_DIR, LOCK_FILE);
|
|
42
|
+
const releaseLockFile = (lockPath) => {
|
|
37
43
|
try {
|
|
38
44
|
if (existsSync(lockPath)) unlinkSync(lockPath);
|
|
39
45
|
} catch {
|
|
@@ -41,6 +47,18 @@ export const releaseLock = () => {
|
|
|
41
47
|
}
|
|
42
48
|
};
|
|
43
49
|
|
|
50
|
+
// Acquire a lock file — atomic O_EXCL prevents TOCTOU race
|
|
51
|
+
export const acquireLock = () => {
|
|
52
|
+
const lockPath = join(PROFILES_DIR, LOCK_FILE);
|
|
53
|
+
acquireLockFile(lockPath);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// Release the lock file
|
|
57
|
+
export const releaseLock = () => {
|
|
58
|
+
const lockPath = join(PROFILES_DIR, LOCK_FILE);
|
|
59
|
+
releaseLockFile(lockPath);
|
|
60
|
+
};
|
|
61
|
+
|
|
44
62
|
// Wrapper that acquires/releases lock around async function
|
|
45
63
|
export const withLock = async (fn) => {
|
|
46
64
|
acquireLock();
|
|
@@ -51,24 +69,37 @@ export const withLock = async (fn) => {
|
|
|
51
69
|
}
|
|
52
70
|
};
|
|
53
71
|
|
|
54
|
-
|
|
55
|
-
|
|
72
|
+
const runtimeLockName = (profileName) => {
|
|
73
|
+
return `.runtime.${profileName}.lock`;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export const acquireRuntimeLock = (profileName) => {
|
|
77
|
+
const lockPath = join(PROFILES_DIR, runtimeLockName(profileName));
|
|
78
|
+
acquireLockFile(lockPath);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const releaseRuntimeLock = (profileName) => {
|
|
82
|
+
const lockPath = join(PROFILES_DIR, runtimeLockName(profileName));
|
|
83
|
+
releaseLockFile(lockPath);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const withRuntimeLock = async (profileName, fn) => {
|
|
87
|
+
acquireRuntimeLock(profileName);
|
|
56
88
|
try {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
return false;
|
|
89
|
+
return await fn();
|
|
90
|
+
} finally {
|
|
91
|
+
releaseRuntimeLock(profileName);
|
|
61
92
|
}
|
|
62
93
|
};
|
|
63
94
|
|
|
64
|
-
// Check if claude process is running
|
|
95
|
+
// Check if claude process is running (cross-platform)
|
|
65
96
|
export const isClaudeRunning = () => {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
} catch {
|
|
70
|
-
return false;
|
|
97
|
+
if (process.env.NODE_ENV === 'test') {
|
|
98
|
+
if (process.env.CSP_TEST_CLAUDE_RUNNING === '1') return true;
|
|
99
|
+
if (process.env.CSP_TEST_CLAUDE_RUNNING === '0') return false;
|
|
71
100
|
}
|
|
101
|
+
|
|
102
|
+
return findProcess('claude');
|
|
72
103
|
};
|
|
73
104
|
|
|
74
105
|
// Print warning if Claude is detected running
|
|
@@ -78,6 +109,12 @@ export const warnIfClaudeRunning = () => {
|
|
|
78
109
|
}
|
|
79
110
|
};
|
|
80
111
|
|
|
112
|
+
export const assertClaudeNotRunning = (processChecker = isClaudeRunning) => {
|
|
113
|
+
if (processChecker()) {
|
|
114
|
+
throw new Error(CLAUDE_RUNNING_ERROR);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
81
118
|
// Create backup of current managed items, prune old backups
|
|
82
119
|
export const createBackup = () => {
|
|
83
120
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
@@ -85,8 +122,8 @@ export const createBackup = () => {
|
|
|
85
122
|
const backupPath = join(backupBase, timestamp);
|
|
86
123
|
mkdirSync(backupPath, { recursive: true });
|
|
87
124
|
|
|
88
|
-
// Save
|
|
89
|
-
const sourceMap =
|
|
125
|
+
// Save managed item map
|
|
126
|
+
const sourceMap = readCurrentItems();
|
|
90
127
|
writeFileSync(join(backupPath, 'source.json'), JSON.stringify(sourceMap, null, 2) + '\n');
|
|
91
128
|
|
|
92
129
|
// Copy mutable files
|
package/src/symlink-manager.js
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
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
|
-
};
|