claude-switch-profile 1.4.2 → 1.4.4

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.
Binary file
package/CHANGELOG.md CHANGED
@@ -9,6 +9,14 @@ All notable changes to `claude-switch-profile` are documented here.
9
9
 
10
10
  ---
11
11
 
12
+ ## [1.4.2] - 2026-03-31
13
+
14
+ ### Changed
15
+ - Re-released the previous patch after an interrupted publish flow (OTP step).
16
+ - No code changes from `1.4.1`; this version reflects successful npm publication and release metadata synchronization.
17
+
18
+ ---
19
+
12
20
  ## [1.4.1] - 2026-03-31
13
21
 
14
22
  ### Changed
package/README.md CHANGED
@@ -550,6 +550,40 @@ Set `CSP_DEBUG_LAUNCH_ENV=1` to print extended launch diagnostics. Do not use in
550
550
 
551
551
  ---
552
552
 
553
+ ### exec
554
+
555
+ Run an arbitrary command inside isolated profile runtime env. This does **not** change global active profile.
556
+
557
+ ```bash
558
+ csp exec <name> -- <command> [args...]
559
+ ```
560
+
561
+ **Examples:**
562
+
563
+ Run any CLI tool with profile runtime env:
564
+ ```bash
565
+ csp exec work -- env
566
+ csp exec work -- node scripts/check-env.js
567
+ ```
568
+
569
+ Run a shell function/alias defined by your interactive shell:
570
+ ```bash
571
+ csp exec hd -- claude-hd2
572
+ ```
573
+
574
+ **Behavior:**
575
+ 1. Validates target profile exists
576
+ 2. Ensures the profile snapshot exists; for legacy installs missing `default/`, guarded backfill only runs when the active profile is `default` or no active profile is set
577
+ 3. Prepares per-profile runtime under `~/.claude-profiles/.runtime/<name>`
578
+ 4. Resolves effective allowlisted `ANTHROPIC_*` launch env (`ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_BASE_URL`, `ANTHROPIC_MODEL`) with precedence: `settings.json env` > profile `.env` allowlist > parent process env
579
+ 5. Strips inherited `CLAUDECODE`, inherited `CLAUDE_CONFIG_DIR`, inherited `ANTHROPIC_*`, and Claude session env vars before applying resolved allowlisted values
580
+ 6. On Unix-like systems, runs the command through your interactive shell so shell functions/aliases can resolve like a normal terminal command; on Windows, keeps direct spawn behavior with `.cmd` / `.bat` wrapper detection
581
+ 7. Reasserts `CLAUDE_CONFIG_DIR` and allowlisted `ANTHROPIC_*` after shell init so profile isolation wins over shell startup overrides
582
+ 8. Inherits stdin/stdout/stderr and forwards child exit code
583
+ 9. Keeps `.active` unchanged and never mutates global `~/.claude`
584
+
585
+ ---
586
+
553
587
  ### uninstall
554
588
 
555
589
  Remove all profiles and restore Claude Code to its pre-CSP state.
@@ -688,23 +722,6 @@ Another csp operation is running (PID: 12345).
688
722
  Remove ~/.claude-profiles/.lock if stale.
689
723
  ```
690
724
 
691
- ### Automatic Backups
692
-
693
- Every profile switch creates a timestamped backup:
694
-
695
- ```
696
- ~/.claude-profiles/.backup/
697
- ├── 2026-03-11T14-30-45-123Z/
698
- │ ├── source.json
699
- │ ├── settings.json
700
- │ ├── .env
701
- │ └── ...
702
- └── 2026-03-11T15-45-22-456Z/
703
- └── ...
704
- ```
705
-
706
- Backups are pruned automatically; CSP keeps only the 2 most recent backups. You can manually restore by copying from backup directory.
707
-
708
725
  ### Claude Process Detection
709
726
 
710
727
  When switching profiles, CSP detects if Claude Code is running:
@@ -943,6 +960,10 @@ See [CHANGELOG.md](CHANGELOG.md) for version history and migration guidance.
943
960
  └── package.json
944
961
  ```
945
962
 
963
+ ## Planned / Future Features
964
+
965
+ - Automatic backups during profile switch flow (`csp use`) with backup retention/pruning.
966
+
946
967
  ## License
947
968
 
948
969
  MIT
package/bin/csp.js CHANGED
@@ -16,7 +16,7 @@ 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
18
  import { uninstallCommand } from '../src/commands/uninstall.js';
19
- import { launchCommand } from '../src/commands/launch.js';
19
+ import { launchCommand, execCommand } from '../src/commands/launch.js';
20
20
  import { deactivateCommand } from '../src/commands/deactivate.js';
21
21
  import { toggleCommand } from '../src/commands/toggle.js';
22
22
  import { statusCommand } from '../src/commands/status.js';
@@ -130,6 +130,26 @@ program
130
130
  launchCommand(name, claudeArgs, options);
131
131
  });
132
132
 
133
+ program
134
+ .command('exec <name> [args...]')
135
+ .description('Run arbitrary command inside isolated profile runtime environment')
136
+ .allowUnknownOption(true)
137
+ .enablePositionalOptions(true)
138
+ .passThroughOptions(true)
139
+ .action((name, args, _options, cmd) => {
140
+ const passthrough = [...(args || [])];
141
+ const unknown = cmd.args.filter((a) => a !== name && !passthrough.includes(a));
142
+ const tokens = [...passthrough, ...unknown];
143
+
144
+ while (tokens[0] === '--') {
145
+ tokens.shift();
146
+ }
147
+
148
+ const command = tokens[0];
149
+ const commandArgs = tokens.slice(1);
150
+ execCommand(name, command, commandArgs);
151
+ });
152
+
133
153
  program
134
154
  .command('uninstall')
135
155
  .description('Remove all profiles and restore Claude Code to pre-CSP state')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-switch-profile",
3
- "version": "1.4.2",
3
+ "version": "1.4.4",
4
4
  "description": "CLI tool for managing multiple Claude Code profiles",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,9 +14,41 @@ import { fileURLToPath } from 'node:url';
14
14
  const __dirname = dirname(fileURLToPath(import.meta.url));
15
15
  const ROOT = join(__dirname, '..');
16
16
  const PKG_PATH = join(ROOT, 'package.json');
17
+ const TEST_SUMMARY_PATTERN = /^# (tests|suites|pass|fail|cancelled|skipped|todo|duration_ms)\b/;
18
+
19
+ const run = (cmd, options = {}) => {
20
+ return execSync(cmd, {
21
+ cwd: ROOT,
22
+ encoding: 'utf-8',
23
+ stdio: 'inherit',
24
+ ...options,
25
+ });
26
+ };
27
+
28
+ const runQuiet = (cmd) => run(cmd, { stdio: 'pipe' }).trim();
29
+
30
+ const fail = (message, details = '') => {
31
+ console.error(`✗ ${message}`);
32
+ if (details) console.error(details);
33
+ process.exit(1);
34
+ };
17
35
 
18
- const run = (cmd) => execSync(cmd, { cwd: ROOT, stdio: 'inherit' });
19
- const runQuiet = (cmd) => execSync(cmd, { cwd: ROOT, encoding: 'utf-8' }).trim();
36
+ const formatCommandOutput = (error) => {
37
+ const stdout = error?.stdout?.toString?.() || '';
38
+ const stderr = error?.stderr?.toString?.() || '';
39
+ return [stdout.trim(), stderr.trim()].filter(Boolean).join('\n');
40
+ };
41
+
42
+ const printTestSummary = (output) => {
43
+ const summary = output
44
+ .split(/\r?\n/)
45
+ .map((line) => line.trim())
46
+ .filter((line) => TEST_SUMMARY_PATTERN.test(line))
47
+ .map((line) => line.replace(/^#\s*/, ''))
48
+ .join('\n');
49
+
50
+ if (summary) console.log(summary);
51
+ };
20
52
 
21
53
  // --- Helpers ---
22
54
 
@@ -33,17 +65,14 @@ const bumpVersion = (current, type) => {
33
65
  const checkCleanWorkingTree = () => {
34
66
  const status = runQuiet('git status --porcelain');
35
67
  if (status) {
36
- console.error('Working tree is not clean. Commit or stash changes first.');
37
- console.error(status);
38
- process.exit(1);
68
+ fail('Working tree is not clean. Commit or stash changes first.', status);
39
69
  }
40
70
  };
41
71
 
42
72
  const checkOnMainBranch = () => {
43
73
  const branch = runQuiet('git branch --show-current');
44
74
  if (branch !== 'main' && branch !== 'master') {
45
- console.error(`✗ Must be on main/master branch. Current: ${branch}`);
46
- process.exit(1);
75
+ fail(`Must be on main/master branch. Current: ${branch}`);
47
76
  }
48
77
  };
49
78
 
@@ -51,8 +80,20 @@ const checkNpmAuth = () => {
51
80
  try {
52
81
  runQuiet('npm whoami');
53
82
  } catch {
54
- console.error('Not logged in to npm. Run "npm login" first.');
55
- process.exit(1);
83
+ fail('Not logged in to npm. Run "npm login" first.');
84
+ }
85
+ };
86
+
87
+ const runTests = () => {
88
+ console.log('Running tests...');
89
+
90
+ try {
91
+ const output = run('npm test', { stdio: 'pipe' });
92
+ printTestSummary(output);
93
+ console.log('✓ Tests passed\n');
94
+ } catch (error) {
95
+ const output = formatCommandOutput(error);
96
+ fail('Tests failed. Release aborted.', output);
56
97
  }
57
98
  };
58
99
 
@@ -61,50 +102,45 @@ const checkNpmAuth = () => {
61
102
  const main = () => {
62
103
  const type = process.argv[2] || 'patch';
63
104
  if (!['patch', 'minor', 'major'].includes(type)) {
64
- console.error(`✗ Invalid bump type: "${type}". Use patch, minor, or major.`);
65
- process.exit(1);
105
+ fail(`Invalid bump type: "${type}". Use patch, minor, or major.`);
66
106
  }
67
107
 
68
- // Pre-flight checks
69
108
  console.log('Pre-flight checks...');
70
109
  checkCleanWorkingTree();
71
110
  checkOnMainBranch();
72
111
  checkNpmAuth();
73
112
  console.log('✓ All checks passed\n');
74
113
 
75
- // Read current version
76
114
  const pkg = JSON.parse(readFileSync(PKG_PATH, 'utf-8'));
77
115
  const oldVersion = pkg.version;
78
116
  const newVersion = bumpVersion(oldVersion, type);
79
117
 
80
118
  console.log(`Bumping version: ${oldVersion} → ${newVersion} (${type})\n`);
81
119
 
82
- // Run tests
83
- console.log('Running tests...');
84
- run('npm test');
85
- console.log('✓ Tests passed\n');
120
+ runTests();
86
121
 
87
- // Update package.json version
88
122
  pkg.version = newVersion;
89
123
  writeFileSync(PKG_PATH, JSON.stringify(pkg, null, 2) + '\n');
90
124
  console.log(`✓ Updated package.json to ${newVersion}`);
91
125
 
92
- // Git commit + tag
93
126
  run('git add package.json');
94
127
  run(`git commit -m "chore(release): v${newVersion}"`);
95
128
  run(`git tag -a v${newVersion} -m "v${newVersion}"`);
96
129
  console.log(`✓ Created tag v${newVersion}`);
97
130
 
98
- // Publish to npm
99
131
  console.log('\nPublishing to npm...');
100
132
  run('npm publish');
101
133
  console.log(`✓ Published claude-switch-profile@${newVersion}`);
102
134
 
103
- // Push to remote
104
- run('git push && git push --tags');
105
- console.log(`✓ Pushed to remote with tags\n`);
135
+ run('git push');
136
+ run('git push --tags');
137
+ console.log('✓ Pushed to remote with tags\n');
106
138
 
107
139
  console.log(`🎉 Released v${newVersion} successfully!`);
108
140
  };
109
141
 
110
- main();
142
+ try {
143
+ main();
144
+ } catch (error) {
145
+ fail('Release failed.', formatCommandOutput(error));
146
+ }
@@ -1,14 +1,21 @@
1
1
  import { execFileSync, spawn } from 'node:child_process';
2
- import { existsSync, readFileSync } from 'node:fs';
3
- import { join } from 'node:path';
4
- import { profileExists, ensureDefaultProfileSnapshot } from '../profile-store.js';
2
+ import { basename, join } from 'node:path';
3
+ import { accessSync, constants as fsConstants, existsSync } from 'node:fs';
4
+ import { info, error } from '../output-helpers.js';
5
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
6
  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';
7
+ import { LAUNCH_CONFIG_ENV } from '../constants.js';
8
+ import {
9
+ ensureLaunchProfileReady,
10
+ formatLaunchEnvDiagnostics,
11
+ resolveIsolatedLaunchContext,
12
+ stripInheritedLaunchEnv,
13
+ } from '../isolated-launch-context.js';
14
+
15
+ export { ensureLaunchProfileReady, formatLaunchEnvDiagnostics, resolveIsolatedLaunchContext, stripInheritedLaunchEnv };
16
+
17
+ const SHELL_REASSERT_ENV_KEYS = [LAUNCH_CONFIG_ENV, 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_MODEL'];
18
+ const SAFE_SHELL_COMMAND = /^[A-Za-z0-9_./:@%+-]+$/;
12
19
 
13
20
  const isTruthyDebugValue = (value) => {
14
21
  if (value === undefined || value === null) return false;
@@ -61,161 +68,187 @@ export const resolveClaudeLaunchTarget = (env = process.env, dependencies = {})
61
68
  return { command: 'claude', shell: true };
62
69
  }
63
70
 
64
- const requiresShell = /\.(cmd|bat)$/i.test(resolvedPath);
65
-
66
71
  return {
67
- command: requiresShell ? `"${resolvedPath}"` : resolvedPath,
68
- shell: requiresShell,
72
+ command: /\.(cmd|bat)$/i.test(resolvedPath) ? `"${resolvedPath}"` : resolvedPath,
73
+ shell: /\.(cmd|bat)$/i.test(resolvedPath),
69
74
  };
70
75
  };
71
76
 
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 {};
77
+ export const resolveExecTarget = (command, env = process.env, dependencies = {}) => {
78
+ const windows = dependencies.isWindows ?? isWindows;
79
+ if (!windows) {
80
+ return { command, shell: false };
82
81
  }
83
- };
84
82
 
85
- const readProfileDotEnvLaunchEnv = (profileDir) => {
86
- const dotEnvPath = join(profileDir, '.env');
87
- if (!existsSync(dotEnvPath)) return {};
83
+ const execRunner = dependencies.execFileSync || execFileSync;
84
+ const fileName = basename(command || '').toLowerCase();
85
+ if (/\.(cmd|bat)$/i.test(command || '') || fileName === 'cmd' || fileName === 'cmd.exe') {
86
+ return {
87
+ command: /\s/.test(command || '') ? `"${command}"` : command,
88
+ shell: true,
89
+ };
90
+ }
88
91
 
89
92
  try {
90
- const rawDotEnv = readFileSync(dotEnvPath, 'utf-8');
91
- return parseDotEnvLaunchEnv(rawDotEnv);
93
+ const resolvedPath = execRunner('where.exe', [command], {
94
+ encoding: 'utf-8',
95
+ stdio: ['ignore', 'pipe', 'ignore'],
96
+ env,
97
+ })
98
+ .trim()
99
+ .split(/\r?\n/)
100
+ .map((entry) => entry.trim())
101
+ .find(Boolean);
102
+
103
+ if (resolvedPath && /\.(cmd|bat)$/i.test(resolvedPath)) {
104
+ return { command, shell: true };
105
+ }
92
106
  } catch {
93
- warn(`Could not parse launch env from ${dotEnvPath}; falling back to inherited env.`);
94
- return {};
107
+ // Fall back to default non-shell spawn behavior.
95
108
  }
109
+
110
+ return { command, shell: false };
96
111
  };
97
112
 
98
- const readProfileLaunchEnvSources = (runtimeDir) => {
99
- return {
100
- profileSettingsEnv: readProfileSettingsLaunchEnv(runtimeDir),
101
- profileDotEnvEnv: readProfileDotEnvLaunchEnv(runtimeDir),
113
+ const runSpawnedCommand = ({ command, args = [], env, shell = false, errorLabel }) => {
114
+ const child = spawn(command, args, {
115
+ stdio: 'inherit',
116
+ shell,
117
+ detached: false,
118
+ env,
119
+ });
120
+
121
+ const forwardSignal = (sig) => {
122
+ try {
123
+ child.kill(sig);
124
+ } catch {
125
+ // Child may have already exited
126
+ }
102
127
  };
103
- };
104
128
 
105
- const formatLaunchEnvDiagnostics = (diagnostics = {}) => {
106
- const keys = diagnostics.anthropicKeys || [];
107
- const sources = diagnostics.anthropicKeySources || {};
129
+ process.on('SIGINT', forwardSignal);
130
+ process.on('SIGTERM', forwardSignal);
108
131
 
109
- if (!keys.length) {
110
- return 'ANTHROPIC_* keys: none';
111
- }
132
+ child.on('error', (err) => {
133
+ error(`${errorLabel}: ${err.message}`);
134
+ process.exit(1);
135
+ });
136
+
137
+ child.on('exit', (code) => {
138
+ process.removeListener('SIGINT', forwardSignal);
139
+ process.removeListener('SIGTERM', forwardSignal);
140
+ process.exit(code || 0);
141
+ });
112
142
 
113
- const keyDetails = keys
114
- .map((key) => `${key}<=${sources[key] || 'unknown'}`)
115
- .join(', ');
143
+ return new Promise(() => {});
144
+ };
116
145
 
117
- return `ANTHROPIC_* keys: ${keyDetails}`;
146
+ const formatCommand = (command, args = []) => {
147
+ return [command, ...args].filter((part) => part !== undefined && part !== null && String(part).length > 0).join(' ');
118
148
  };
119
149
 
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
- }
150
+ const quoteShellToken = (value) => `'${String(value).replaceAll("'", `'"'"'`)}'`;
151
+ const formatShellCommandToken = (value) => (SAFE_SHELL_COMMAND.test(String(value || '')) ? String(value) : quoteShellToken(value));
152
+ const isExecutableFile = (filePath) => {
153
+ if (!filePath) return false;
154
+ try {
155
+ accessSync(filePath, fsConstants.X_OK);
156
+ return true;
157
+ } catch {
158
+ return false;
126
159
  }
127
- return sanitized;
160
+ };
161
+ const getInteractiveShellPath = (env = process.env) => {
162
+ if (isExecutableFile(env.SHELL)) return env.SHELL;
163
+ return '/bin/sh';
128
164
  };
129
165
 
130
- export const launchCommand = async (name, claudeArgs, options = {}) => {
131
- if (name === DEFAULT_PROFILE) {
132
- try {
133
- ensureDefaultProfileSnapshot();
134
- } catch (err) {
135
- error(err.message);
136
- process.exit(1);
166
+ const buildShellEvalCommand = (command, args = []) => {
167
+ return [formatShellCommandToken(command), ...args.map((arg) => quoteShellToken(arg))].join(' ');
168
+ };
169
+
170
+ const buildShellEnvReassertion = (env) => {
171
+ return SHELL_REASSERT_ENV_KEYS.map((key) => {
172
+ if (Object.prototype.hasOwnProperty.call(env, key) && env[key] !== undefined) {
173
+ return `export ${key}=${quoteShellToken(env[key])}`;
137
174
  }
138
- }
175
+ return `unset ${key}`;
176
+ }).join('; ');
177
+ };
139
178
 
140
- if (!profileExists(name)) {
141
- error(`Profile "${name}" does not exist. Run "csp list" to see available profiles.`);
142
- process.exit(1);
143
- }
179
+ const executeViaInteractiveShell = ({ command, args = [], launchEnv, errorLabel }) => {
180
+ const shellPath = getInteractiveShellPath(launchEnv);
181
+ const shellEnv = { ...launchEnv, CSP_EXEC_CMD: buildShellEvalCommand(command, args) };
182
+ const shellScript = `${buildShellEnvReassertion(launchEnv)}; eval "$CSP_EXEC_CMD"`;
144
183
 
145
- let args = [...(claudeArgs || [])];
146
- const legacyFromArgs = args.includes('--legacy-global');
147
- if (legacyFromArgs) {
148
- args = args.filter((a) => a !== '--legacy-global');
149
- }
184
+ return runSpawnedCommand({
185
+ command: shellPath,
186
+ args: ['-ic', shellScript],
187
+ env: shellEnv,
188
+ errorLabel,
189
+ });
190
+ };
150
191
 
151
- let launchEnv = { ...process.env };
192
+ export const launchCommand = async (name, claudeArgs, options = {}) => {
193
+ ensureLaunchProfileReady(name);
152
194
 
153
- if (options.legacyGlobal || legacyFromArgs) {
195
+ let args = [...(claudeArgs || [])];
196
+ if (options.legacyGlobal || args.includes('--legacy-global')) {
197
+ args = args.filter((arg) => arg !== '--legacy-global');
154
198
  await useCommand(name, { save: true, skipClaudeCheck: true });
155
199
  info(`Launching legacy/global mode: claude ${args.join(' ')}`.trim());
156
- } else {
157
- const runtimeDir = await withRuntimeLock(name, async () => ensureRuntimeInstance(name));
158
- const { profileSettingsEnv, profileDotEnvEnv } = readProfileLaunchEnvSources(runtimeDir);
159
- const { launchEnv: resolvedLaunchEnv, diagnostics } = buildEffectiveLaunchEnv({
160
- parentEnv: process.env,
161
- profileSettingsEnv,
162
- profileDotEnvEnv,
163
- });
164
-
165
- launchEnv = {
166
- ...resolvedLaunchEnv,
167
- [LAUNCH_CONFIG_ENV]: runtimeDir,
168
- };
169
-
170
- // CLAUDE_CONFIG_DIR already redirects user-level config lookups to the
171
- // runtime dir, so the "user" settings source reads {runtimeDir}/settings.json
172
- // — which is the correct profile-specific settings. We do NOT exclude "user"
173
- // because Claude Code gates skill/hook discovery on it being enabled.
200
+ return runSpawnedCommand({ command: 'claude', args, env: { ...process.env }, errorLabel: 'Failed to launch Claude' });
201
+ }
174
202
 
175
- // Always show launch diagnostics for debugging credential issues
176
- info(`Launch env diagnostics (${name}): ${formatLaunchEnvDiagnostics(diagnostics)}`);
177
- info(`CLAUDE_CONFIG_DIR=${launchEnv[LAUNCH_CONFIG_ENV]}`);
178
- info(`ANTHROPIC_AUTH_TOKEN=${launchEnv.ANTHROPIC_AUTH_TOKEN ? launchEnv.ANTHROPIC_AUTH_TOKEN.slice(0, 8) + '...' : '(not set)'}`);
179
- info(`ANTHROPIC_BASE_URL=${launchEnv.ANTHROPIC_BASE_URL || '(not set)'}`);
203
+ const isolated = await resolveIsolatedLaunchContext(name, process.env);
204
+ const launchEnv = isolated.launchEnv;
180
205
 
181
- if (isTruthyDebugValue(process.env.CSP_DEBUG_LAUNCH_ENV)) {
182
- info(`[DEBUG] Full launch env ANTHROPIC keys: ${JSON.stringify(Object.fromEntries(Object.entries(launchEnv).filter(([k]) => k.startsWith('ANTHROPIC_'))))}`);
183
- }
206
+ info(`Launch env diagnostics (${name}): ${formatLaunchEnvDiagnostics(isolated.diagnostics)}`);
207
+ info(`CLAUDE_CONFIG_DIR=${launchEnv[LAUNCH_CONFIG_ENV]}`);
208
+ info(`ANTHROPIC_AUTH_TOKEN=${launchEnv.ANTHROPIC_AUTH_TOKEN ? `${launchEnv.ANTHROPIC_AUTH_TOKEN.slice(0, 8)}...` : '(not set)'}`);
209
+ info(`ANTHROPIC_BASE_URL=${launchEnv.ANTHROPIC_BASE_URL || '(not set)'}`);
184
210
 
185
- info(`Launching isolated session for profile "${name}": claude ${args.join(' ')}`.trim());
211
+ if (isTruthyDebugValue(process.env.CSP_DEBUG_LAUNCH_ENV)) {
212
+ info(`[DEBUG] Full launch env ANTHROPIC keys: ${JSON.stringify(Object.fromEntries(Object.entries(launchEnv).filter(([key]) => key.startsWith('ANTHROPIC_'))))}`);
186
213
  }
187
214
 
215
+ info(`Launching isolated session for profile "${name}": claude ${args.join(' ')}`.trim());
216
+
188
217
  const launchTarget = resolveClaudeLaunchTarget(launchEnv);
218
+ return runSpawnedCommand({ command: launchTarget.command, args, env: launchEnv, shell: launchTarget.shell, errorLabel: 'Failed to launch Claude' });
219
+ };
189
220
 
190
- const child = spawn(launchTarget.command, args, {
191
- stdio: 'inherit',
192
- shell: launchTarget.shell,
193
- detached: false,
194
- env: launchEnv,
195
- });
221
+ export const execCommand = async (name, command, commandArgs = []) => {
222
+ ensureLaunchProfileReady(name);
196
223
 
197
- // Forward signals to child instead of killing parent
198
- const forwardSignal = (sig) => {
199
- try {
200
- child.kill(sig);
201
- } catch {
202
- // Child may have already exited
203
- }
204
- };
205
- process.on('SIGINT', forwardSignal);
206
- process.on('SIGTERM', forwardSignal);
207
-
208
- child.on('error', (err) => {
209
- error(`Failed to launch Claude: ${err.message}`);
224
+ const normalizedCommand = typeof command === 'string' ? command.trim() : '';
225
+ if (!normalizedCommand) {
226
+ error('Missing command. Usage: csp exec <name> -- <cmd> [args...]');
210
227
  process.exit(1);
211
- });
228
+ }
212
229
 
213
- child.on('exit', (code) => {
214
- process.removeListener('SIGINT', forwardSignal);
215
- process.removeListener('SIGTERM', forwardSignal);
216
- process.exit(code || 0);
217
- });
230
+ const isolated = await resolveIsolatedLaunchContext(name, process.env);
231
+ const launchEnv = isolated.launchEnv;
218
232
 
219
- // Keep process alive while Claude runs
220
- return new Promise(() => {});
233
+ info(`Launch env diagnostics (${name}): ${formatLaunchEnvDiagnostics(isolated.diagnostics)}`);
234
+ info(`CLAUDE_CONFIG_DIR=${launchEnv[LAUNCH_CONFIG_ENV]}`);
235
+ info(`Executing isolated command for profile "${name}": ${formatCommand(normalizedCommand, commandArgs)}`);
236
+
237
+ if (!isWindows) {
238
+ return executeViaInteractiveShell({
239
+ command: normalizedCommand,
240
+ args: commandArgs,
241
+ launchEnv,
242
+ errorLabel: `Failed to execute command "${normalizedCommand}"`,
243
+ });
244
+ }
245
+
246
+ const execTarget = resolveExecTarget(normalizedCommand, launchEnv);
247
+ return runSpawnedCommand({
248
+ command: execTarget.command,
249
+ args: commandArgs,
250
+ env: launchEnv,
251
+ shell: execTarget.shell,
252
+ errorLabel: `Failed to execute command "${normalizedCommand}"`,
253
+ });
221
254
  };
@@ -0,0 +1,100 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { profileExists, ensureDefaultProfileSnapshot } from './profile-store.js';
4
+ import { ensureRuntimeInstance } from './runtime-instance-manager.js';
5
+ import { withRuntimeLock } from './safety.js';
6
+ import { error, warn } from './output-helpers.js';
7
+ import { LAUNCH_CONFIG_ENV, DEFAULT_PROFILE } from './constants.js';
8
+ import {
9
+ buildEffectiveLaunchEnv,
10
+ parseDotEnvLaunchEnv,
11
+ parseSettingsLaunchEnv,
12
+ sanitizeInheritedLaunchEnv,
13
+ } from './launch-effective-env-resolver.js';
14
+
15
+ const readProfileSettingsLaunchEnv = (profileDir) => {
16
+ const settingsPath = join(profileDir, 'settings.json');
17
+ if (!existsSync(settingsPath)) return {};
18
+
19
+ try {
20
+ return parseSettingsLaunchEnv(readFileSync(settingsPath, 'utf-8'));
21
+ } catch {
22
+ warn(`Could not parse launch env from ${settingsPath}; falling back to inherited env.`);
23
+ return {};
24
+ }
25
+ };
26
+
27
+ const readProfileDotEnvLaunchEnv = (profileDir) => {
28
+ const dotEnvPath = join(profileDir, '.env');
29
+ if (!existsSync(dotEnvPath)) return {};
30
+
31
+ try {
32
+ return parseDotEnvLaunchEnv(readFileSync(dotEnvPath, 'utf-8'));
33
+ } catch {
34
+ warn(`Could not parse launch env from ${dotEnvPath}; falling back to inherited env.`);
35
+ return {};
36
+ }
37
+ };
38
+
39
+ const readProfileLaunchEnvSources = (runtimeDir) => {
40
+ return {
41
+ profileSettingsEnv: readProfileSettingsLaunchEnv(runtimeDir),
42
+ profileDotEnvEnv: readProfileDotEnvLaunchEnv(runtimeDir),
43
+ };
44
+ };
45
+
46
+ export const formatLaunchEnvDiagnostics = (diagnostics = {}) => {
47
+ const keys = diagnostics.anthropicKeys || [];
48
+ const sources = diagnostics.anthropicKeySources || {};
49
+
50
+ if (!keys.length) {
51
+ return 'ANTHROPIC_* keys: none';
52
+ }
53
+
54
+ return `ANTHROPIC_* keys: ${keys.map((key) => `${key}<=${sources[key] || 'unknown'}`).join(', ')}`;
55
+ };
56
+
57
+ export const stripInheritedLaunchEnv = (env = process.env) => {
58
+ const sanitized = sanitizeInheritedLaunchEnv(env);
59
+ for (const key of Object.keys(sanitized)) {
60
+ if (key.toUpperCase().startsWith('ANTHROPIC_')) {
61
+ delete sanitized[key];
62
+ }
63
+ }
64
+ return sanitized;
65
+ };
66
+
67
+ export const ensureLaunchProfileReady = (name) => {
68
+ if (name === DEFAULT_PROFILE) {
69
+ try {
70
+ ensureDefaultProfileSnapshot();
71
+ } catch (err) {
72
+ error(err.message);
73
+ process.exit(1);
74
+ }
75
+ }
76
+
77
+ if (!profileExists(name)) {
78
+ error(`Profile "${name}" does not exist. Run "csp list" to see available profiles.`);
79
+ process.exit(1);
80
+ }
81
+ };
82
+
83
+ export const resolveIsolatedLaunchContext = async (name, parentEnv = process.env) => {
84
+ const runtimeDir = await withRuntimeLock(name, async () => ensureRuntimeInstance(name));
85
+ const { profileSettingsEnv, profileDotEnvEnv } = readProfileLaunchEnvSources(runtimeDir);
86
+ const { launchEnv: resolvedLaunchEnv, diagnostics } = buildEffectiveLaunchEnv({
87
+ parentEnv,
88
+ profileSettingsEnv,
89
+ profileDotEnvEnv,
90
+ });
91
+
92
+ return {
93
+ runtimeDir,
94
+ diagnostics,
95
+ launchEnv: {
96
+ ...resolvedLaunchEnv,
97
+ [LAUNCH_CONFIG_ENV]: runtimeDir,
98
+ },
99
+ };
100
+ };