Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
39 changes: 2 additions & 37 deletions docs/environment-configuration-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
43 changes: 2 additions & 41 deletions scripts/setup_environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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'
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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}')
Expand Down
37 changes: 17 additions & 20 deletions tests/e2e/test_env_loader_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -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', {})
Expand All @@ -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,
Expand All @@ -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)

Expand All @@ -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', [])
Expand Down
37 changes: 0 additions & 37 deletions tests/e2e/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading
Loading