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
package/src/commands/create.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { mkdirSync, cpSync, existsSync, writeFileSync, readdirSync } from 'node:fs';
|
|
1
|
+
import { mkdirSync, cpSync, existsSync, statSync, writeFileSync, copyFileSync, readdirSync, lstatSync } from 'node:fs';
|
|
2
2
|
import { join, resolve } from 'node:path';
|
|
3
3
|
import { addProfile, getActive, setActive, profileExists, getProfileDir } from '../profile-store.js';
|
|
4
|
-
import { saveFiles } from '../file-operations.js';
|
|
5
|
-
import {
|
|
4
|
+
import { saveFiles, updateSettingsPaths } from '../file-operations.js';
|
|
5
|
+
import { CLAUDE_DIR, MANAGED_ITEMS, MANAGED_DIRS, SOURCE_FILE, NEVER_CLONE } from '../constants.js';
|
|
6
6
|
import { success, error, info, warn } from '../output-helpers.js';
|
|
7
7
|
|
|
8
8
|
export const createCommand = (name, options) => {
|
|
@@ -32,44 +32,102 @@ export const createCommand = (name, options) => {
|
|
|
32
32
|
|
|
33
33
|
mkdirSync(profileDir, { recursive: true });
|
|
34
34
|
|
|
35
|
-
//
|
|
35
|
+
// Copy items from source kit into profile dir
|
|
36
36
|
const sourceMap = {};
|
|
37
|
-
for (const item of
|
|
37
|
+
for (const item of MANAGED_ITEMS) {
|
|
38
38
|
const target = join(sourcePath, item);
|
|
39
39
|
if (existsSync(target)) {
|
|
40
|
-
|
|
40
|
+
const dest = join(profileDir, item);
|
|
41
|
+
try {
|
|
42
|
+
if (statSync(target).isDirectory()) {
|
|
43
|
+
cpSync(target, dest, { recursive: true });
|
|
44
|
+
} else {
|
|
45
|
+
copyFileSync(target, dest);
|
|
46
|
+
}
|
|
47
|
+
sourceMap[item] = dest;
|
|
48
|
+
} catch { /* skip */ }
|
|
41
49
|
}
|
|
42
50
|
}
|
|
43
51
|
|
|
44
52
|
if (Object.keys(sourceMap).length === 0) {
|
|
45
|
-
warn(`No recognized items found in "${sourcePath}". Expected: ${
|
|
53
|
+
warn(`No recognized items found in "${sourcePath}". Expected: ${MANAGED_ITEMS.join(', ')}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Inherit missing items from current ~/.claude state
|
|
57
|
+
for (const item of MANAGED_ITEMS) {
|
|
58
|
+
if (sourceMap[item]) continue;
|
|
59
|
+
const src = join(CLAUDE_DIR, item);
|
|
60
|
+
if (!existsSync(src)) continue;
|
|
61
|
+
try {
|
|
62
|
+
const dest = join(profileDir, item);
|
|
63
|
+
if (statSync(src).isDirectory()) {
|
|
64
|
+
cpSync(src, dest, { recursive: true });
|
|
65
|
+
} else {
|
|
66
|
+
copyFileSync(src, dest);
|
|
67
|
+
}
|
|
68
|
+
sourceMap[item] = dest;
|
|
69
|
+
} catch { /* skip */ }
|
|
46
70
|
}
|
|
47
71
|
|
|
48
72
|
writeFileSync(join(profileDir, SOURCE_FILE), JSON.stringify(sourceMap, null, 2) + '\n');
|
|
49
|
-
info(`
|
|
73
|
+
info(`Copied from kit at ${sourcePath}`);
|
|
50
74
|
info(`Items found: ${Object.keys(sourceMap).join(', ') || 'none'}`);
|
|
51
75
|
|
|
52
76
|
// Also copy current mutable files
|
|
53
77
|
saveFiles(profileDir);
|
|
54
78
|
} else {
|
|
55
|
-
// Create
|
|
79
|
+
// Create new profile — full clone of ~/.claude/ (blacklist approach)
|
|
56
80
|
mkdirSync(profileDir, { recursive: true });
|
|
57
81
|
|
|
58
|
-
// Create self-contained directories for each symlink directory item
|
|
59
82
|
const sourceMap = {};
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
83
|
+
let entries;
|
|
84
|
+
try {
|
|
85
|
+
entries = readdirSync(CLAUDE_DIR);
|
|
86
|
+
} catch {
|
|
87
|
+
entries = [];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for (const entry of entries) {
|
|
91
|
+
if (NEVER_CLONE.includes(entry)) continue;
|
|
92
|
+
|
|
93
|
+
const src = join(CLAUDE_DIR, entry);
|
|
94
|
+
try {
|
|
95
|
+
const stat = lstatSync(src);
|
|
96
|
+
|
|
97
|
+
if (stat.isDirectory()) {
|
|
98
|
+
const dest = join(profileDir, entry);
|
|
99
|
+
cpSync(src, dest, { recursive: true });
|
|
100
|
+
if (MANAGED_ITEMS.includes(entry)) {
|
|
101
|
+
sourceMap[entry] = dest;
|
|
102
|
+
}
|
|
103
|
+
} else if (stat.isFile()) {
|
|
104
|
+
const dest = join(profileDir, entry);
|
|
105
|
+
copyFileSync(src, dest);
|
|
106
|
+
if (MANAGED_ITEMS.includes(entry)) {
|
|
107
|
+
sourceMap[entry] = dest;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
} catch { /* skip unreadable items */ }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Ensure empty dirs for any missing MANAGED_DIRS
|
|
114
|
+
for (const item of MANAGED_DIRS) {
|
|
115
|
+
if (!sourceMap[item]) {
|
|
116
|
+
const itemDir = join(profileDir, item);
|
|
117
|
+
mkdirSync(itemDir, { recursive: true });
|
|
118
|
+
sourceMap[item] = itemDir;
|
|
119
|
+
}
|
|
64
120
|
}
|
|
65
121
|
|
|
66
122
|
writeFileSync(join(profileDir, SOURCE_FILE), JSON.stringify(sourceMap, null, 2) + '\n');
|
|
67
|
-
|
|
68
|
-
//
|
|
69
|
-
|
|
123
|
+
|
|
124
|
+
// Update absolute paths in settings.json
|
|
125
|
+
updateSettingsPaths(profileDir, 'save');
|
|
126
|
+
|
|
127
|
+
info('Created new profile (full clone of current state)');
|
|
70
128
|
}
|
|
71
129
|
|
|
72
|
-
addProfile(name, { description: options.description || '' });
|
|
130
|
+
addProfile(name, { description: options.description || '', mode: 'account-session' });
|
|
73
131
|
|
|
74
132
|
// Set as active if first profile
|
|
75
133
|
if (!getActive()) {
|
package/src/commands/current.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import { getActive } from '../profile-store.js';
|
|
2
|
-
import { getProfileDir } from '../profile-store.js';
|
|
1
|
+
import { getActive, getProfileDir, getProfileMeta } from '../profile-store.js';
|
|
3
2
|
import { success, info, warn } from '../output-helpers.js';
|
|
4
3
|
|
|
5
4
|
export const currentCommand = () => {
|
|
@@ -8,6 +7,12 @@ export const currentCommand = () => {
|
|
|
8
7
|
warn('No active profile. Run "csp create <name>" to create one.');
|
|
9
8
|
return;
|
|
10
9
|
}
|
|
11
|
-
success(`Active profile: ${active}`);
|
|
10
|
+
success(`Active legacy profile: ${active}`);
|
|
12
11
|
info(`Location: ${getProfileDir(active)}`);
|
|
12
|
+
|
|
13
|
+
const meta = getProfileMeta(active);
|
|
14
|
+
if (meta?.lastLaunchAt) {
|
|
15
|
+
info(`Last isolated launch: ${meta.lastLaunchAt}`);
|
|
16
|
+
if (meta.runtimeDir) info(`Isolated runtime: ${meta.runtimeDir}`);
|
|
17
|
+
}
|
|
13
18
|
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { getActive, setActive } from '../profile-store.js';
|
|
2
|
+
import { success, info } from '../output-helpers.js';
|
|
3
|
+
import { DEFAULT_PROFILE } from '../constants.js';
|
|
4
|
+
|
|
5
|
+
export const deactivateCommand = async () => {
|
|
6
|
+
const active = getActive();
|
|
7
|
+
if (!active) {
|
|
8
|
+
info('No active profile to deactivate.');
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (active === DEFAULT_PROFILE) {
|
|
13
|
+
info('Default profile uses ~/.claude directly. Nothing to deactivate.');
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Just reset to default — never touch ~/.claude
|
|
18
|
+
setActive(DEFAULT_PROFILE);
|
|
19
|
+
|
|
20
|
+
success(`Profile "${active}" deactivated. Reset to default.`);
|
|
21
|
+
info('~/.claude was not modified.');
|
|
22
|
+
};
|
package/src/commands/delete.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { rmSync } from 'node:fs';
|
|
2
2
|
import { createInterface } from 'node:readline';
|
|
3
|
-
import { getActive, removeProfile, profileExists, getProfileDir } from '../profile-store.js';
|
|
4
|
-
import { success, error, warn } from '../output-helpers.js';
|
|
3
|
+
import { getActive, clearActive, removeProfile, profileExists, getProfileDir } from '../profile-store.js';
|
|
4
|
+
import { success, error, warn, info } from '../output-helpers.js';
|
|
5
|
+
import { DEFAULT_PROFILE } from '../constants.js';
|
|
5
6
|
|
|
6
7
|
const confirm = (question) => {
|
|
7
8
|
return new Promise((resolve) => {
|
|
@@ -14,14 +15,13 @@ const confirm = (question) => {
|
|
|
14
15
|
};
|
|
15
16
|
|
|
16
17
|
export const deleteCommand = async (name, options) => {
|
|
17
|
-
if (
|
|
18
|
-
error(
|
|
18
|
+
if (name === DEFAULT_PROFILE) {
|
|
19
|
+
error('Cannot delete the default profile.');
|
|
19
20
|
process.exit(1);
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
error(`Cannot delete active profile "${name}". Switch to another profile first.`);
|
|
23
|
+
if (!profileExists(name)) {
|
|
24
|
+
error(`Profile "${name}" does not exist.`);
|
|
25
25
|
process.exit(1);
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -33,8 +33,20 @@ export const deleteCommand = async (name, options) => {
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
const active = getActive();
|
|
36
37
|
const profileDir = getProfileDir(name);
|
|
38
|
+
|
|
39
|
+
// Delete profile directory
|
|
37
40
|
rmSync(profileDir, { recursive: true, force: true });
|
|
38
41
|
removeProfile(name);
|
|
42
|
+
|
|
43
|
+
// If deleting the active profile, just clear the marker
|
|
44
|
+
// NEVER touch ~/.claude — leave symlinks/files as-is
|
|
45
|
+
if (active === name) {
|
|
46
|
+
clearActive();
|
|
47
|
+
info('Was active profile — active marker cleared.');
|
|
48
|
+
info('Symlinks in ~/.claude may now be dangling. Run "csp use <profile>" to switch.');
|
|
49
|
+
}
|
|
50
|
+
|
|
39
51
|
success(`Profile "${name}" deleted.`);
|
|
40
52
|
};
|
package/src/commands/diff.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { readFileSync, existsSync, readdirSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import chalk from 'chalk';
|
|
4
|
-
import { profileExists,
|
|
5
|
-
import { SOURCE_FILE } from '../constants.js';
|
|
4
|
+
import { profileExists, getActive, getEffectiveDir } from '../profile-store.js';
|
|
5
|
+
import { SOURCE_FILE, ALL_MANAGED } from '../constants.js';
|
|
6
6
|
import { error, info } from '../output-helpers.js';
|
|
7
7
|
|
|
8
8
|
export const diffCommand = (profileA, profileB) => {
|
|
@@ -31,19 +31,29 @@ export const diffCommand = (profileA, profileB) => {
|
|
|
31
31
|
process.exit(1);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
const dirA =
|
|
35
|
-
const dirB =
|
|
34
|
+
const dirA = getEffectiveDir(nameA);
|
|
35
|
+
const dirB = getEffectiveDir(nameB);
|
|
36
36
|
|
|
37
37
|
console.log(`\n${chalk.bold('Comparing:')} ${chalk.cyan(nameA)} ↔ ${chalk.cyan(nameB)}\n`);
|
|
38
38
|
|
|
39
|
-
// Compare source.json (
|
|
39
|
+
// Compare source.json (managed item sources)
|
|
40
40
|
const sourceA = readJsonSafe(join(dirA, SOURCE_FILE));
|
|
41
41
|
const sourceB = readJsonSafe(join(dirB, SOURCE_FILE));
|
|
42
|
-
diffObject('
|
|
42
|
+
diffObject('Managed item sources (source.json)', sourceA, sourceB, nameA, nameB);
|
|
43
43
|
|
|
44
44
|
// Compare files that exist in either profile
|
|
45
|
-
|
|
46
|
-
const
|
|
45
|
+
// When reading from CLAUDE_DIR (active/default profile), filter to only managed items
|
|
46
|
+
const active = getActive();
|
|
47
|
+
const managedSet = new Set(ALL_MANAGED);
|
|
48
|
+
const listManagedFiles = (dir) => {
|
|
49
|
+
const all = readdirSync(dir).filter((f) => f !== SOURCE_FILE);
|
|
50
|
+
if ((dir === dirA && active === nameA) || (dir === dirB && active === nameB)) {
|
|
51
|
+
return new Set(all.filter((f) => managedSet.has(f)));
|
|
52
|
+
}
|
|
53
|
+
return new Set(all);
|
|
54
|
+
};
|
|
55
|
+
const filesA = listManagedFiles(dirA, nameA);
|
|
56
|
+
const filesB = listManagedFiles(dirB, nameB);
|
|
47
57
|
|
|
48
58
|
const allFiles = new Set([...filesA, ...filesB]);
|
|
49
59
|
const diffs = [];
|
|
@@ -73,7 +83,7 @@ export const diffCommand = (profileA, profileB) => {
|
|
|
73
83
|
}
|
|
74
84
|
|
|
75
85
|
if (diffs.length === 0) {
|
|
76
|
-
info('Profiles are identical (excluding
|
|
86
|
+
info('Profiles are identical (excluding source paths).');
|
|
77
87
|
} else {
|
|
78
88
|
console.log(chalk.bold('File differences:'));
|
|
79
89
|
for (const d of diffs) {
|
package/src/commands/export.js
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
|
-
import { existsSync } from 'node:fs';
|
|
3
2
|
import { resolve } from 'node:path';
|
|
4
|
-
import { profileExists, getProfileDir } from '../profile-store.js';
|
|
5
|
-
import {
|
|
3
|
+
import { profileExists, getProfileDir, getActive } from '../profile-store.js';
|
|
4
|
+
import { isWindows } from '../platform.js';
|
|
5
|
+
import { saveItems } from '../item-manager.js';
|
|
6
|
+
import { saveFiles, updateSettingsPaths } from '../file-operations.js';
|
|
7
|
+
import { success, error, info } from '../output-helpers.js';
|
|
8
|
+
import { DEFAULT_PROFILE } from '../constants.js';
|
|
6
9
|
|
|
7
10
|
export const exportCommand = (name, options) => {
|
|
11
|
+
if (name === DEFAULT_PROFILE) {
|
|
12
|
+
error('Cannot export the default profile (it uses ~/.claude directly).');
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
|
|
8
16
|
if (!profileExists(name)) {
|
|
9
17
|
error(`Profile "${name}" does not exist.`);
|
|
10
18
|
process.exit(1);
|
|
@@ -14,11 +22,28 @@ export const exportCommand = (name, options) => {
|
|
|
14
22
|
const output = options.output || `./${name}.csp.tar.gz`;
|
|
15
23
|
const outputPath = resolve(output);
|
|
16
24
|
|
|
25
|
+
// If exporting active profile, save a copy first (non-destructive)
|
|
26
|
+
const active = getActive();
|
|
27
|
+
if (active === name) {
|
|
28
|
+
saveItems(profileDir);
|
|
29
|
+
saveFiles(profileDir);
|
|
30
|
+
updateSettingsPaths(profileDir, 'save');
|
|
31
|
+
info('Saved current state before export.');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const tarArgs = isWindows
|
|
35
|
+
? ['--force-local', '-czf', outputPath, '-C', profileDir.replace(/\\/g, '/'), '.']
|
|
36
|
+
: ['-czf', outputPath, '-C', profileDir, '.'];
|
|
37
|
+
|
|
17
38
|
try {
|
|
18
|
-
execFileSync('tar'
|
|
39
|
+
execFileSync(isWindows ? 'tar.exe' : 'tar', tarArgs, { stdio: 'pipe' });
|
|
19
40
|
success(`Profile "${name}" exported to ${outputPath}`);
|
|
20
41
|
} catch (err) {
|
|
21
|
-
|
|
42
|
+
if (err.code === 'ENOENT') {
|
|
43
|
+
error('tar command not found. On Windows, tar is available on Windows 10+.');
|
|
44
|
+
} else {
|
|
45
|
+
error(`Failed to export: ${err.message}`);
|
|
46
|
+
}
|
|
22
47
|
process.exit(1);
|
|
23
48
|
}
|
|
24
49
|
};
|
package/src/commands/import.js
CHANGED
|
@@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
2
2
|
import { existsSync, mkdirSync } from 'node:fs';
|
|
3
3
|
import { resolve, basename } from 'node:path';
|
|
4
4
|
import { addProfile, profileExists, getProfileDir, ensureProfilesDir } from '../profile-store.js';
|
|
5
|
+
import { isWindows } from '../platform.js';
|
|
5
6
|
import { validateProfile } from '../profile-validator.js';
|
|
6
7
|
import { success, error, warn } from '../output-helpers.js';
|
|
7
8
|
|
|
@@ -24,10 +25,18 @@ export const importCommand = (file, options) => {
|
|
|
24
25
|
const profileDir = getProfileDir(name);
|
|
25
26
|
mkdirSync(profileDir, { recursive: true });
|
|
26
27
|
|
|
28
|
+
const tarArgs = isWindows
|
|
29
|
+
? ['--force-local', '-xzf', filePath, '-C', profileDir.replace(/\\/g, '/')]
|
|
30
|
+
: ['-xzf', filePath, '-C', profileDir];
|
|
31
|
+
|
|
27
32
|
try {
|
|
28
|
-
execFileSync('tar'
|
|
33
|
+
execFileSync(isWindows ? 'tar.exe' : 'tar', tarArgs, { stdio: 'pipe' });
|
|
29
34
|
} catch (err) {
|
|
30
|
-
|
|
35
|
+
if (err.code === 'ENOENT') {
|
|
36
|
+
error('tar command not found. On Windows, tar is available on Windows 10+.');
|
|
37
|
+
} else {
|
|
38
|
+
error(`Failed to extract: ${err.message}`);
|
|
39
|
+
}
|
|
31
40
|
process.exit(1);
|
|
32
41
|
}
|
|
33
42
|
|
package/src/commands/init.js
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
|
-
import { ensureProfilesDir, getActive,
|
|
3
|
-
import {
|
|
4
|
-
import { success, info, warn } from '../output-helpers.js';
|
|
2
|
+
import { ensureProfilesDir, getActive, addProfile, setActive } from '../profile-store.js';
|
|
3
|
+
import { success, info } from '../output-helpers.js';
|
|
5
4
|
import { PROFILES_DIR } from '../constants.js';
|
|
6
5
|
|
|
7
6
|
export const initCommand = () => {
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
const active = getActive();
|
|
8
|
+
if (existsSync(PROFILES_DIR) && active) {
|
|
10
9
|
info(`Already initialized. Active profile: "${active}"`);
|
|
11
10
|
info(`Profiles directory: ${PROFILES_DIR}`);
|
|
12
11
|
return;
|
|
@@ -15,7 +14,10 @@ export const initCommand = () => {
|
|
|
15
14
|
ensureProfilesDir();
|
|
16
15
|
info('Initializing Claude Switch Profile...');
|
|
17
16
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
addProfile('default', { description: 'Vanilla Claude defaults', mode: 'legacy' });
|
|
18
|
+
setActive('default');
|
|
19
|
+
|
|
20
|
+
success('Initialization complete.');
|
|
21
|
+
info('"default" profile uses ~/.claude directly — no copy/restore needed.');
|
|
22
|
+
info('Run "csp create <name>" to capture your current setup into a new profile.');
|
|
21
23
|
};
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { profileExists } from '../profile-store.js';
|
|
5
|
+
import { useCommand } from './use.js';
|
|
6
|
+
import { ensureRuntimeInstance } from '../runtime-instance-manager.js';
|
|
7
|
+
import { withRuntimeLock } from '../safety.js';
|
|
8
|
+
import { error, info, warn } from '../output-helpers.js';
|
|
9
|
+
import { isWindows } from '../platform.js';
|
|
10
|
+
import { LAUNCH_CONFIG_ENV, DEFAULT_PROFILE } from '../constants.js';
|
|
11
|
+
import { buildEffectiveLaunchEnv, parseDotEnvLaunchEnv, parseSettingsLaunchEnv, sanitizeInheritedLaunchEnv } from '../launch-effective-env-resolver.js';
|
|
12
|
+
|
|
13
|
+
const isTruthyDebugValue = (value) => {
|
|
14
|
+
if (value === undefined || value === null) return false;
|
|
15
|
+
const normalized = String(value).trim().toLowerCase();
|
|
16
|
+
return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on';
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const findWindowsClaudePath = (env, dependencies = {}) => {
|
|
20
|
+
const execRunner = dependencies.execFileSync || execFileSync;
|
|
21
|
+
const pathExists = dependencies.existsSync || existsSync;
|
|
22
|
+
|
|
23
|
+
const pathMatches = (() => {
|
|
24
|
+
try {
|
|
25
|
+
return execRunner('where.exe', ['claude'], {
|
|
26
|
+
encoding: 'utf-8',
|
|
27
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
28
|
+
env,
|
|
29
|
+
})
|
|
30
|
+
.trim()
|
|
31
|
+
.split(/\r?\n/)
|
|
32
|
+
.map((entry) => entry.trim())
|
|
33
|
+
.filter(Boolean);
|
|
34
|
+
} catch {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
})();
|
|
38
|
+
|
|
39
|
+
const resolvedFromPath = pathMatches.find((entry) => /\.(exe|cmd|bat)$/i.test(entry)) || pathMatches[0];
|
|
40
|
+
if (resolvedFromPath && pathExists(resolvedFromPath)) {
|
|
41
|
+
return resolvedFromPath;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const fallbackCandidates = [
|
|
45
|
+
env.USERPROFILE ? join(env.USERPROFILE, '.local', 'bin', 'claude.exe') : null,
|
|
46
|
+
env.APPDATA ? join(env.APPDATA, 'npm', 'claude.cmd') : null,
|
|
47
|
+
env.USERPROFILE ? join(env.USERPROFILE, '.bun', 'bin', 'claude.exe') : null,
|
|
48
|
+
].filter(Boolean);
|
|
49
|
+
|
|
50
|
+
return fallbackCandidates.find((candidate) => pathExists(candidate)) || null;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const resolveClaudeLaunchTarget = (env = process.env, dependencies = {}) => {
|
|
54
|
+
const windows = dependencies.isWindows ?? isWindows;
|
|
55
|
+
if (!windows) {
|
|
56
|
+
return { command: 'claude', shell: false };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const resolvedPath = findWindowsClaudePath(env, dependencies);
|
|
60
|
+
if (!resolvedPath) {
|
|
61
|
+
return { command: 'claude', shell: true };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const requiresShell = /\.(cmd|bat)$/i.test(resolvedPath);
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
command: requiresShell ? `"${resolvedPath}"` : resolvedPath,
|
|
68
|
+
shell: requiresShell,
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const readProfileSettingsLaunchEnv = (profileDir) => {
|
|
73
|
+
const settingsPath = join(profileDir, 'settings.json');
|
|
74
|
+
if (!existsSync(settingsPath)) return {};
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const rawSettings = readFileSync(settingsPath, 'utf-8');
|
|
78
|
+
return parseSettingsLaunchEnv(rawSettings);
|
|
79
|
+
} catch {
|
|
80
|
+
warn(`Could not parse launch env from ${settingsPath}; falling back to inherited env.`);
|
|
81
|
+
return {};
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const readProfileDotEnvLaunchEnv = (profileDir) => {
|
|
86
|
+
const dotEnvPath = join(profileDir, '.env');
|
|
87
|
+
if (!existsSync(dotEnvPath)) return {};
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const rawDotEnv = readFileSync(dotEnvPath, 'utf-8');
|
|
91
|
+
return parseDotEnvLaunchEnv(rawDotEnv);
|
|
92
|
+
} catch {
|
|
93
|
+
warn(`Could not parse launch env from ${dotEnvPath}; falling back to inherited env.`);
|
|
94
|
+
return {};
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const readProfileLaunchEnvSources = (runtimeDir) => {
|
|
99
|
+
return {
|
|
100
|
+
profileSettingsEnv: readProfileSettingsLaunchEnv(runtimeDir),
|
|
101
|
+
profileDotEnvEnv: readProfileDotEnvLaunchEnv(runtimeDir),
|
|
102
|
+
};
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const formatLaunchEnvDiagnostics = (diagnostics = {}) => {
|
|
106
|
+
const keys = diagnostics.anthropicKeys || [];
|
|
107
|
+
const sources = diagnostics.anthropicKeySources || {};
|
|
108
|
+
|
|
109
|
+
if (!keys.length) {
|
|
110
|
+
return 'ANTHROPIC_* keys: none';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const keyDetails = keys
|
|
114
|
+
.map((key) => `${key}<=${sources[key] || 'unknown'}`)
|
|
115
|
+
.join(', ');
|
|
116
|
+
|
|
117
|
+
return `ANTHROPIC_* keys: ${keyDetails}`;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export const stripInheritedLaunchEnv = (env = process.env) => {
|
|
121
|
+
const sanitized = sanitizeInheritedLaunchEnv(env);
|
|
122
|
+
for (const key of Object.keys(sanitized)) {
|
|
123
|
+
if (key.toUpperCase().startsWith('ANTHROPIC_')) {
|
|
124
|
+
delete sanitized[key];
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return sanitized;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export const launchCommand = async (name, claudeArgs, options = {}) => {
|
|
131
|
+
if (!profileExists(name)) {
|
|
132
|
+
error(`Profile "${name}" does not exist. Run "csp list" to see available profiles.`);
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let args = [...(claudeArgs || [])];
|
|
137
|
+
const legacyFromArgs = args.includes('--legacy-global');
|
|
138
|
+
if (legacyFromArgs) {
|
|
139
|
+
args = args.filter((a) => a !== '--legacy-global');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let launchEnv = { ...process.env };
|
|
143
|
+
|
|
144
|
+
if (options.legacyGlobal || legacyFromArgs) {
|
|
145
|
+
// Legacy path: keep historical behavior for compatibility.
|
|
146
|
+
await useCommand(name, { save: true, skipClaudeCheck: true });
|
|
147
|
+
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
|
+
} else {
|
|
152
|
+
const runtimeDir = await withRuntimeLock(name, async () => ensureRuntimeInstance(name));
|
|
153
|
+
const { profileSettingsEnv, profileDotEnvEnv } = readProfileLaunchEnvSources(runtimeDir);
|
|
154
|
+
const { launchEnv: resolvedLaunchEnv, diagnostics } = buildEffectiveLaunchEnv({
|
|
155
|
+
parentEnv: process.env,
|
|
156
|
+
profileSettingsEnv,
|
|
157
|
+
profileDotEnvEnv,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
launchEnv = {
|
|
161
|
+
...resolvedLaunchEnv,
|
|
162
|
+
[LAUNCH_CONFIG_ENV]: runtimeDir,
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// CLAUDE_CONFIG_DIR already redirects user-level config lookups to the
|
|
166
|
+
// runtime dir, so the "user" settings source reads {runtimeDir}/settings.json
|
|
167
|
+
// — which is the correct profile-specific settings. We do NOT exclude "user"
|
|
168
|
+
// because Claude Code gates skill/hook discovery on it being enabled.
|
|
169
|
+
|
|
170
|
+
// Always show launch diagnostics for debugging credential issues
|
|
171
|
+
info(`Launch env diagnostics (${name}): ${formatLaunchEnvDiagnostics(diagnostics)}`);
|
|
172
|
+
info(`CLAUDE_CONFIG_DIR=${launchEnv[LAUNCH_CONFIG_ENV]}`);
|
|
173
|
+
info(`ANTHROPIC_AUTH_TOKEN=${launchEnv.ANTHROPIC_AUTH_TOKEN ? launchEnv.ANTHROPIC_AUTH_TOKEN.slice(0, 8) + '...' : '(not set)'}`);
|
|
174
|
+
info(`ANTHROPIC_BASE_URL=${launchEnv.ANTHROPIC_BASE_URL || '(not set)'}`);
|
|
175
|
+
|
|
176
|
+
if (isTruthyDebugValue(process.env.CSP_DEBUG_LAUNCH_ENV)) {
|
|
177
|
+
info(`[DEBUG] Full launch env ANTHROPIC keys: ${JSON.stringify(Object.fromEntries(Object.entries(launchEnv).filter(([k]) => k.startsWith('ANTHROPIC_'))))}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
info(`Launching isolated session for profile "${name}": claude ${args.join(' ')}`.trim());
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const launchTarget = resolveClaudeLaunchTarget(launchEnv);
|
|
184
|
+
|
|
185
|
+
const child = spawn(launchTarget.command, args, {
|
|
186
|
+
stdio: 'inherit',
|
|
187
|
+
shell: launchTarget.shell,
|
|
188
|
+
detached: false,
|
|
189
|
+
env: launchEnv,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Forward signals to child instead of killing parent
|
|
193
|
+
const forwardSignal = (sig) => {
|
|
194
|
+
try {
|
|
195
|
+
child.kill(sig);
|
|
196
|
+
} catch {
|
|
197
|
+
// Child may have already exited
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
process.on('SIGINT', forwardSignal);
|
|
201
|
+
process.on('SIGTERM', forwardSignal);
|
|
202
|
+
|
|
203
|
+
child.on('error', (err) => {
|
|
204
|
+
error(`Failed to launch Claude: ${err.message}`);
|
|
205
|
+
process.exit(1);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
child.on('exit', (code) => {
|
|
209
|
+
process.removeListener('SIGINT', forwardSignal);
|
|
210
|
+
process.removeListener('SIGTERM', forwardSignal);
|
|
211
|
+
process.exit(code || 0);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// Keep process alive while Claude runs
|
|
215
|
+
return new Promise(() => {});
|
|
216
|
+
};
|
package/src/commands/list.js
CHANGED
|
@@ -20,7 +20,8 @@ export const listCommand = () => {
|
|
|
20
20
|
const label = isActive ? chalk.green.bold(name) : name;
|
|
21
21
|
const desc = meta.description ? chalk.dim(` — ${meta.description}`) : '';
|
|
22
22
|
const date = meta.created ? chalk.dim(` (${meta.created.split('T')[0]})`) : '';
|
|
23
|
-
|
|
23
|
+
const mode = meta.mode ? chalk.dim(` [${meta.mode}]`) : '';
|
|
24
|
+
console.log(`${marker}${label}${desc}${date}${mode}`);
|
|
24
25
|
}
|
|
25
26
|
console.log('');
|
|
26
27
|
};
|
package/src/commands/save.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { getActive, profileExists, getProfileDir } from '../profile-store.js';
|
|
2
|
-
import {
|
|
3
|
-
import { saveFiles } from '../file-operations.js';
|
|
4
|
-
import { success, error } from '../output-helpers.js';
|
|
2
|
+
import { saveItems } from '../item-manager.js';
|
|
3
|
+
import { saveFiles, updateSettingsPaths } from '../file-operations.js';
|
|
4
|
+
import { success, error, info } from '../output-helpers.js';
|
|
5
|
+
import { DEFAULT_PROFILE } from '../constants.js';
|
|
5
6
|
|
|
6
7
|
export const saveCommand = () => {
|
|
7
8
|
const active = getActive();
|
|
@@ -10,13 +11,19 @@ export const saveCommand = () => {
|
|
|
10
11
|
process.exit(1);
|
|
11
12
|
}
|
|
12
13
|
|
|
14
|
+
if (active === DEFAULT_PROFILE) {
|
|
15
|
+
info('Default profile uses ~/.claude directly. No save needed.');
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
13
19
|
if (!profileExists(active)) {
|
|
14
20
|
error(`Active profile "${active}" directory is missing.`);
|
|
15
21
|
process.exit(1);
|
|
16
22
|
}
|
|
17
23
|
|
|
18
24
|
const profileDir = getProfileDir(active);
|
|
19
|
-
|
|
25
|
+
saveItems(profileDir);
|
|
20
26
|
saveFiles(profileDir);
|
|
27
|
+
updateSettingsPaths(profileDir, 'save');
|
|
21
28
|
success(`Saved current state to profile "${active}"`);
|
|
22
29
|
};
|