diff --git a/CLAUDE.md b/CLAUDE.md index 584a54c..ffe399f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,7 +83,7 @@ YAML configs define complete environments: dependencies, agents, MCP servers (au `_validate_merge_keys()` is a DRY helper extracted from the former inline validation, reused for both leaf and per-entry merge-keys validation. `InheritEntry` is a Pydantic model (`config: str`, `merge_keys: list[str] | None` with alias `merge-keys`, `extra='forbid'`) containing an inline `mergeable` frozenset (DRY violation covered by parity test `test_mergeable_config_keys_parity.py`). `_resolve_list_inherit()` threads `auth_param` through `load_config_from_source()` for each entry, extends the existing `visited` set for circular dependency detection, and builds `InheritanceChainEntry` entries for each loaded config. -**Env Loader Files:** `generate_env_loader_files()` creates Rustup-style shell scripts containing ONLY `os-env-variables` (not `env-variables`). Per-command files: `~/.claude/{cmd}/env.sh`, `env.fish` (if Fish installed), `env.ps1` (Windows), `env.cmd` (Windows). Global files: `~/.claude/toolbox-env.sh`, `toolbox-env.fish`, `toolbox-env.ps1` (Windows), `toolbox-env.cmd` (Windows). `None`-valued (deletion) vars are excluded. `create_launcher_script()` injects guarded source lines in all 6 launcher variants so commands auto-load env vars. +**Env Loader Files:** `generate_env_loader_files()` creates Rustup-style shell scripts containing ONLY `os-env-variables` (not `env-variables`). Per-command files: `~/.claude/{cmd}/env.sh`, `env.fish` (if Fish installed), `env.ps1` (Windows), `env.cmd` (Windows). Files are generated only when `command_names` is provided; without `command_names`, the function returns `{}`. `None`-valued (deletion) vars are excluded. `create_launcher_script()` injects guarded source lines in all 6 launcher variants so commands auto-load env vars. **Fish Dual-Mechanism:** `set_os_env_variable_unix()` writes `set -gx` to `config.fish` (durable persistence) AND calls `set -Ux` via subprocess (instant propagation to all running Fish sessions). For deletions, `set -Ue` removes the universal variable. The `config.fish` write is authoritative; `set -Ux` is complementary. diff --git a/docs/environment-configuration-guide.md b/docs/environment-configuration-guide.md index 814d25c..5246ec2 100644 --- a/docs/environment-configuration-guide.md +++ b/docs/environment-configuration-guide.md @@ -719,15 +719,6 @@ When `os-env-variables` are configured, the setup generates Rustup-style env loa | `~/.claude/{cmd}/env.ps1` | PowerShell | Windows only | | `~/.claude/{cmd}/env.cmd` | CMD batch | Windows only | -**Global convenience files** (always generated when `os-env-variables` are non-empty): - -| File | Shell | Generated When | -|-------------------------------|------------|----------------| -| `~/.claude/toolbox-env.sh` | Bash/Zsh | Always | -| `~/.claude/toolbox-env.fish` | Fish | Fish installed | -| `~/.claude/toolbox-env.ps1` | PowerShell | Windows only | -| `~/.claude/toolbox-env.cmd` | CMD batch | Windows only | - Variables set to `null` (deletions) are excluded from loader files. ##### Automatic Loading via Launchers @@ -736,35 +727,9 @@ When `command-names` is specified, the generated launcher scripts automatically The source line is guarded by a file-existence check, so launchers work normally even when no `os-env-variables` are configured. -##### Manual Sourcing for Bare `claude` - -Users who run bare `claude` (without a command-name launcher) can manually source the global loader to apply OS environment variables to their current shell session: - -**Bash/Zsh:** - -```bash -source ~/.claude/toolbox-env.sh -``` - -**Fish:** - -```fish -source ~/.claude/toolbox-env.fish -``` - -**PowerShell (Windows):** - -```powershell -. ~/.claude/toolbox-env.ps1 -``` - -**CMD (Windows):** - -```batch -%USERPROFILE%\.claude\toolbox-env.cmd -``` +##### Applying OS Environment Variables -Alternatively, open a new terminal -- shell profiles are updated during setup and will load the variables automatically. +When `os-env-variables` are configured, the setup writes them to shell profile files (`.bashrc`, `.zshrc`, `.profile`, `config.fish` on Unix; Windows Registry on Windows). Open a new terminal to load the updated variables automatically. ##### Fish Dual-Mechanism diff --git a/scripts/setup_environment.py b/scripts/setup_environment.py index e095c66..7fffc83 100644 --- a/scripts/setup_environment.py +++ b/scripts/setup_environment.py @@ -5838,12 +5838,6 @@ def generate_env_loader_files( ~/.claude/{cmd}/env.ps1 (PowerShell, Windows only) ~/.claude/{cmd}/env.cmd (CMD batch, Windows only) - Global convenience files (always generated when os_env_vars non-empty): - ~/.claude/toolbox-env.sh - ~/.claude/toolbox-env.fish (if Fish installed) - ~/.claude/toolbox-env.ps1 (PowerShell, Windows only) - ~/.claude/toolbox-env.cmd (CMD batch, Windows only) - Args: os_env_vars: Dict of env var names to values. None values = deletions (excluded from loader files since the var should not exist). @@ -5860,8 +5854,6 @@ def generate_env_loader_files( return {} generated: dict[str, Path] = {} - home = get_real_user_home() - claude_dir = home / '.claude' # Build file content for each shell type sh_header = '# Auto-generated by claude-code-toolbox -- do not edit manually\n' @@ -5944,9 +5936,6 @@ def _write_loader( if command_names and config_base_dir: _write_loader(config_base_dir, 'env.sh', 'env.fish', 'env.ps1', 'env.cmd') - # Global convenience files (always generated) - _write_loader(claude_dir, 'toolbox-env.sh', 'toolbox-env.fish', 'toolbox-env.ps1', 'toolbox-env.cmd') - return generated @@ -10718,42 +10707,14 @@ def main() -> None: print(' * Use "claude" to start Claude Code with configured environment') # Environment guidance based on what was configured - if os_env_variables and generated_env_files: + if os_env_variables: active_env_count = sum(1 for v in os_env_variables.values() if v is not None) if active_env_count > 0: if command_names: print(f' * Environment: Commands auto-load {active_env_count} OS env var(s)') - home = get_real_user_home() - if sys.platform == 'win32': - ps1_loader = home / '.claude' / 'toolbox-env.ps1' - cmd_loader = home / '.claude' / 'toolbox-env.cmd' - print(f' For bare "claude" (PowerShell): . {ps1_loader}') - print(f' For bare "claude" (CMD): {cmd_loader}') - else: - shell_name = os.environ.get('SHELL', '') - if 'fish' in shell_name: - fish_loader = home / '.claude' / 'toolbox-env.fish' - print(f' For bare "claude": source {fish_loader}') - else: - sh_loader = home / '.claude' / 'toolbox-env.sh' - print(f' For bare "claude": source {sh_loader}') else: print(f' * Environment: {active_env_count} OS env var(s) configured') - home = get_real_user_home() - if sys.platform == 'win32': - ps1_loader = home / '.claude' / 'toolbox-env.ps1' - cmd_loader = home / '.claude' / 'toolbox-env.cmd' - print(f' To apply now (PowerShell): . {ps1_loader}') - print(f' To apply now (CMD): {cmd_loader}') - else: - shell_name = os.environ.get('SHELL', '') - if 'fish' in shell_name: - fish_loader = home / '.claude' / 'toolbox-env.fish' - print(f' To apply now: source {fish_loader}') - else: - sh_loader = home / '.claude' / 'toolbox-env.sh' - print(f' To apply now: source {sh_loader}') - print(' Or open a new terminal for automatic loading') + print(' Open a new terminal for automatic loading') print() print(f'{Colors.YELLOW}Available Commands (after starting Claude):{Colors.NC}') diff --git a/tests/e2e/test_env_loader_files.py b/tests/e2e/test_env_loader_files.py index 5a3a8ff..1adb3fa 100644 --- a/tests/e2e/test_env_loader_files.py +++ b/tests/e2e/test_env_loader_files.py @@ -22,42 +22,37 @@ class TestEnvLoaderFileGeneration: """E2E tests for generate_env_loader_files() output.""" - def test_global_env_loader_files_exist( + def test_no_global_env_loader_files_generated( self, e2e_isolated_home: dict[str, Path], golden_config: dict[str, Any], ) -> None: - """Verify global convenience files are generated for os-env-variables. + """Verify no global convenience files are generated for os-env-variables. - Checks: - - toolbox-env.sh exists in ~/.claude/ - - toolbox-env.ps1 exists in ~/.claude/ (Windows only) - - Content contains correct export syntax - - Deletion variables (None values) are excluded + Without command-names, no env loader files should be generated. + Shell profiles are the authoritative persistence layer. """ paths = e2e_isolated_home os_env_vars = golden_config.get('os-env-variables', {}) if not os_env_vars: pytest.skip('No os-env-variables in golden config') - generate_env_loader_files(os_env_vars, None, None) + result = generate_env_loader_files(os_env_vars, None, None) - errors = validate_env_loader_files( - paths['claude_dir'], os_env_vars, command_name=None, - ) - assert not errors, 'Global env loader validation failed:\n' + '\n'.join(errors) + assert result == {}, 'Expected no files without command_names' + global_sh = paths['claude_dir'] / 'toolbox-env.sh' + assert not global_sh.exists(), 'toolbox-env.sh should not be generated' def test_per_command_env_loader_files_exist( self, e2e_isolated_home: dict[str, Path], golden_config: dict[str, Any], ) -> None: - """Verify per-command env loader files are generated alongside global files. + """Verify per-command env loader files are generated. Checks: - env.sh exists in ~/.claude/{cmd}/ - env.ps1 exists in ~/.claude/{cmd}/ (Windows only) - - Global files are also generated """ paths = e2e_isolated_home os_env_vars = golden_config.get('os-env-variables', {}) @@ -84,11 +79,11 @@ def test_no_files_when_all_deletions( paths = e2e_isolated_home all_deletions: dict[str, str | None] = {'DEL_A': None, 'DEL_B': None} - result = generate_env_loader_files(all_deletions, None, None) + cmd_dir = paths['claude_dir'] / 'test-cmd' + cmd_dir.mkdir(parents=True, exist_ok=True) + result = generate_env_loader_files(all_deletions, ['test-cmd'], cmd_dir) assert result == {}, 'Expected empty result for all-deletion vars' - global_sh = paths['claude_dir'] / 'toolbox-env.sh' - assert not global_sh.exists(), 'toolbox-env.sh should not exist for all-deletion vars' def test_deletion_vars_excluded_from_content( self, @@ -101,10 +96,12 @@ def test_deletion_vars_excluded_from_content( 'DELETE_VAR': None, } - generate_env_loader_files(mixed_vars, None, None) + cmd_dir = paths['claude_dir'] / 'test-cmd' + cmd_dir.mkdir(parents=True, exist_ok=True) + generate_env_loader_files(mixed_vars, ['test-cmd'], cmd_dir) errors = validate_env_loader_files( - paths['claude_dir'], mixed_vars, command_name=None, + paths['claude_dir'], mixed_vars, command_name='test-cmd', ) assert not errors, 'Mixed vars validation failed:\n' + '\n'.join(errors) @@ -114,7 +111,7 @@ def test_cmd_env_loader_files_on_windows( e2e_isolated_home: dict[str, Path], golden_config: dict[str, Any], ) -> None: - """Verify env.cmd and toolbox-env.cmd generated on Windows.""" + """Verify env.cmd generated on Windows for per-command config.""" paths = e2e_isolated_home claude_dir = paths['claude_dir'] cmd_names = golden_config.get('command-names', []) diff --git a/tests/e2e/validators.py b/tests/e2e/validators.py index a5fd6bc..54687eb 100644 --- a/tests/e2e/validators.py +++ b/tests/e2e/validators.py @@ -1376,7 +1376,6 @@ def validate_env_loader_files( loader files containing ONLY non-None os-env-variables with proper syntax. Validates: - - Global toolbox-env.sh exists in ~/.claude/ - Per-command env.sh exists in ~/.claude/{cmd}/ (when command_name provided) - File content contains correct export syntax for each shell type - None-valued (deletion) variables are excluded from loader files @@ -1397,44 +1396,8 @@ def validate_env_loader_files( deletion_vars = [k for k, v in os_env_vars.items() if v is None] if not active_vars: - # No active vars means no files should be generated - global_sh = claude_dir / 'toolbox-env.sh' - if global_sh.exists(): - errors.append( - f'toolbox-env.sh exists but no active os-env-variables: {global_sh}', - ) return errors - # --- Global convenience files --- - global_sh = claude_dir / 'toolbox-env.sh' - if not global_sh.exists(): - errors.append(f'Global env loader not found: {global_sh}') - else: - sh_content = global_sh.read_text(encoding='utf-8') - errors.extend(_validate_sh_loader_content(sh_content, active_vars, deletion_vars, 'toolbox-env.sh')) - - # Global PS1 on Windows - if sys.platform == 'win32': - global_ps1 = claude_dir / 'toolbox-env.ps1' - if not global_ps1.exists(): - errors.append(f'Global PS1 env loader not found on Windows: {global_ps1}') - else: - ps1_content = global_ps1.read_text(encoding='utf-8') - errors.extend( - _validate_ps1_loader_content(ps1_content, active_vars, deletion_vars, 'toolbox-env.ps1'), - ) - - # Global CMD on Windows - if sys.platform == 'win32': - global_cmd = claude_dir / 'toolbox-env.cmd' - if not global_cmd.exists(): - errors.append(f'Global CMD env loader not found on Windows: {global_cmd}') - else: - cmd_content = global_cmd.read_text(encoding='utf-8') - errors.extend( - _validate_cmd_loader_content(cmd_content, active_vars, deletion_vars, 'toolbox-env.cmd'), - ) - # --- Per-command files --- if command_name: cmd_dir = claude_dir / command_name diff --git a/tests/test_setup_environment.py b/tests/test_setup_environment.py index 981094a..fc67339 100644 --- a/tests/test_setup_environment.py +++ b/tests/test_setup_environment.py @@ -13160,8 +13160,9 @@ def test_none_values_excluded_from_files(self, tmp_path: Path, monkeypatch: pyte monkeypatch.setattr(setup_environment, 'get_real_user_home', lambda: tmp_path) monkeypatch.setattr(shutil, 'which', lambda _cmd: None) # No fish + cmd_dir = tmp_path / '.claude' / 'my-cmd' result = setup_environment.generate_env_loader_files( - {'KEEP': 'val', 'DELETE': None}, None, None, + {'KEEP': 'val', 'DELETE': None}, ['my-cmd'], cmd_dir, ) assert len(result) > 0 # Check that the sh file only has KEEP, not DELETE @@ -13171,21 +13172,18 @@ def test_none_values_excluded_from_files(self, tmp_path: Path, monkeypatch: pyte assert 'KEEP' in content assert 'DELETE' not in content - def test_global_files_generated_without_commands(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """Without command_names, only global convenience files are generated.""" + def test_no_files_generated_without_commands(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Without command_names, no env loader files are generated.""" monkeypatch.setattr(setup_environment, 'get_real_user_home', lambda: tmp_path) monkeypatch.setattr(shutil, 'which', lambda _cmd: None) result = setup_environment.generate_env_loader_files( {'MY_VAR': 'value'}, None, None, ) - # Should generate toolbox-env.sh - sh_files = [p for k, p in result.items() if k.startswith('sh:')] - assert len(sh_files) == 1 - assert sh_files[0].name == 'toolbox-env.sh' + assert result == {} - def test_per_command_and_global_files(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """With command_names, generates per-command + global files.""" + def test_per_command_files_only(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """With command_names, generates only per-command files.""" monkeypatch.setattr(setup_environment, 'get_real_user_home', lambda: tmp_path) monkeypatch.setattr(shutil, 'which', lambda _cmd: None) @@ -13196,15 +13194,16 @@ def test_per_command_and_global_files(self, tmp_path: Path, monkeypatch: pytest. sh_files = [p for k, p in result.items() if k.startswith('sh:')] names = {p.name for p in sh_files} assert 'env.sh' in names # per-command - assert 'toolbox-env.sh' in names # global + assert 'toolbox-env.sh' not in names # no global def test_sh_format_correctness(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """Verify bash/zsh export syntax.""" monkeypatch.setattr(setup_environment, 'get_real_user_home', lambda: tmp_path) monkeypatch.setattr(shutil, 'which', lambda _cmd: None) - setup_environment.generate_env_loader_files({'FOO': 'bar', 'BAZ': 'qux'}, None, None) - sh_path = tmp_path / '.claude' / 'toolbox-env.sh' + cmd_dir = tmp_path / '.claude' / 'my-cmd' + setup_environment.generate_env_loader_files({'FOO': 'bar', 'BAZ': 'qux'}, ['my-cmd'], cmd_dir) + sh_path = cmd_dir / 'env.sh' content = sh_path.read_text() assert 'export FOO="bar"' in content assert 'export BAZ="qux"' in content @@ -13217,8 +13216,9 @@ def test_fish_format_generated_when_fish_available( monkeypatch.setattr(setup_environment, 'get_real_user_home', lambda: tmp_path) monkeypatch.setattr(shutil, 'which', lambda cmd: '/usr/bin/fish' if cmd == 'fish' else None) - setup_environment.generate_env_loader_files({'FOO': 'bar'}, None, None) - fish_path = tmp_path / '.claude' / 'toolbox-env.fish' + cmd_dir = tmp_path / '.claude' / 'my-cmd' + setup_environment.generate_env_loader_files({'FOO': 'bar'}, ['my-cmd'], cmd_dir) + fish_path = cmd_dir / 'env.fish' assert fish_path.exists() content = fish_path.read_text() assert 'set -gx FOO "bar"' in content @@ -13230,8 +13230,9 @@ def test_fish_not_generated_when_unavailable( monkeypatch.setattr(setup_environment, 'get_real_user_home', lambda: tmp_path) monkeypatch.setattr(shutil, 'which', lambda _cmd: None) - setup_environment.generate_env_loader_files({'FOO': 'bar'}, None, None) - fish_path = tmp_path / '.claude' / 'toolbox-env.fish' + cmd_dir = tmp_path / '.claude' / 'my-cmd' + setup_environment.generate_env_loader_files({'FOO': 'bar'}, ['my-cmd'], cmd_dir) + fish_path = cmd_dir / 'env.fish' assert not fish_path.exists() @pytest.mark.skipif(sys.platform != 'win32', reason='Windows-specific test') @@ -13252,10 +13253,11 @@ def test_special_chars_escaped_in_sh(self, tmp_path: Path, monkeypatch: pytest.M monkeypatch.setattr(setup_environment, 'get_real_user_home', lambda: tmp_path) monkeypatch.setattr(shutil, 'which', lambda _cmd: None) + cmd_dir = tmp_path / '.claude' / 'my-cmd' setup_environment.generate_env_loader_files( - {'TRICKY': 'has "quotes" and $dollar and `backtick`'}, None, None, + {'TRICKY': 'has "quotes" and $dollar and `backtick`'}, ['my-cmd'], cmd_dir, ) - sh_path = tmp_path / '.claude' / 'toolbox-env.sh' + sh_path = cmd_dir / 'env.sh' content = sh_path.read_text() assert r'\"quotes\"' in content assert r'\$dollar' in content @@ -13306,20 +13308,6 @@ def test_special_chars_escaped_in_cmd( content = cmd_path.read_text() assert 'SET "TRICKY=has 50%% discount"' in content - @pytest.mark.skipif(sys.platform != 'win32', reason='Windows-specific test') - def test_global_cmd_file_on_windows( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, - ) -> None: - """Global toolbox-env.cmd generated on Windows.""" - monkeypatch.setattr(setup_environment, 'get_real_user_home', lambda: tmp_path) - monkeypatch.setattr(shutil, 'which', lambda _cmd: None) - - setup_environment.generate_env_loader_files({'MY_VAR': 'value'}, None, None) - cmd_path = tmp_path / '.claude' / 'toolbox-env.cmd' - assert cmd_path.exists() - content = cmd_path.read_text() - assert 'SET "MY_VAR=value"' in content - def test_non_string_values_converted_to_strings( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -13336,7 +13324,8 @@ def test_non_string_values_converted_to_strings( 'DELETE_VAR': None, } - result = setup_environment.generate_env_loader_files(mixed_type_vars, None, None) + cmd_dir = tmp_path / '.claude' / 'my-cmd' + result = setup_environment.generate_env_loader_files(mixed_type_vars, ['my-cmd'], cmd_dir) assert len(result) > 0 # Check that the sh file contains string representations