Skip to content

Commit a505ae8

Browse files
authored
Merge pull request #218 from alex-feel/alex-feel-dev
Add Fish shell support for environment variable management
2 parents e2c75c5 + 7e59976 commit a505ae8

File tree

2 files changed

+328
-42
lines changed

2 files changed

+328
-42
lines changed

scripts/setup_environment.py

Lines changed: 180 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -551,8 +551,10 @@ def add_directory_to_windows_path(directory: str) -> tuple[bool, str]:
551551
winreg.CloseKey(reg_key)
552552
return (
553553
False,
554-
f'PATH too long ({len(new_path)} chars, limit 1024). '
555-
f'Please manually add: {normalized_dir}',
554+
(
555+
f'PATH too long ({len(new_path)} chars, limit 1024). '
556+
f'Please manually add: {normalized_dir}'
557+
),
556558
)
557559

558560
# Write new PATH to registry
@@ -1841,6 +1843,8 @@ def get_all_shell_config_files() -> list[Path]:
18411843
home / '.zshenv', # All zsh instances (recommended for env vars)
18421844
home / '.zprofile', # Zsh login shells (macOS default since Catalina)
18431845
home / '.zshrc', # Interactive zsh shells
1846+
# Fish files
1847+
home / '.config' / 'fish' / 'config.fish', # Fish shell config
18441848
]
18451849

18461850
# On Linux, only include zsh files if zsh is installed
@@ -1850,14 +1854,55 @@ def get_all_shell_config_files() -> list[Path]:
18501854
# Filter out zsh-specific files if zsh is not installed
18511855
config_files = [f for f in config_files if not f.name.startswith('.zsh')]
18521856

1857+
# Only include fish config if fish is installed
1858+
fish_path = shutil.which('fish')
1859+
if not fish_path:
1860+
config_files = [f for f in config_files if 'fish' not in str(f)]
1861+
18531862
return config_files
18541863

18551864

1865+
def _get_export_line(config_file: Path, name: str, value: str) -> str:
1866+
"""Generate the appropriate export line for the shell type.
1867+
1868+
Args:
1869+
config_file: Path to the shell config file.
1870+
name: Environment variable name.
1871+
value: Environment variable value.
1872+
1873+
Returns:
1874+
str: The export line in the appropriate syntax for the shell.
1875+
"""
1876+
# Fish shell uses different syntax
1877+
if 'fish' in str(config_file):
1878+
return f'set -gx {name} "{value}"'
1879+
# Bash/Zsh use export
1880+
return f'export {name}="{value}"'
1881+
1882+
1883+
def _get_export_prefix(config_file: Path, name: str) -> str:
1884+
"""Get the line prefix to match for an existing export.
1885+
1886+
Args:
1887+
config_file: Path to the shell config file.
1888+
name: Environment variable name.
1889+
1890+
Returns:
1891+
str: The prefix to match (e.g., 'export NAME=' or 'set -gx NAME ').
1892+
"""
1893+
# Fish shell uses different syntax
1894+
if 'fish' in str(config_file):
1895+
return f'set -gx {name} '
1896+
# Bash/Zsh use export
1897+
return f'export {name}='
1898+
1899+
18561900
def add_export_to_file(config_file: Path, name: str, value: str) -> bool:
18571901
"""Add or update an environment variable export in a shell config file.
18581902
18591903
Uses markers to manage a block of exports set by claude-code-toolbox.
18601904
Updates existing variables within the block, or adds new ones.
1905+
Automatically uses the correct syntax for the shell type (bash/zsh vs fish).
18611906
18621907
Args:
18631908
config_file: Path to the shell config file.
@@ -1867,7 +1912,8 @@ def add_export_to_file(config_file: Path, name: str, value: str) -> bool:
18671912
Returns:
18681913
bool: True if successful, False otherwise.
18691914
"""
1870-
export_line = f'export {name}="{value}"'
1915+
export_line = _get_export_line(config_file, name, value)
1916+
export_prefix = _get_export_prefix(config_file, name)
18711917

18721918
try:
18731919
# Read existing content
@@ -1902,7 +1948,7 @@ def add_export_to_file(config_file: Path, name: str, value: str) -> bool:
19021948
for line in block_lines:
19031949
if line in (ENV_VAR_MARKER_START, ENV_VAR_MARKER_END):
19041950
continue
1905-
if line.strip().startswith(f'export {name}='):
1951+
if line.strip().startswith(export_prefix):
19061952
# Update existing variable
19071953
new_block_lines.append(export_line)
19081954
found = True
@@ -1941,11 +1987,99 @@ def add_export_to_file(config_file: Path, name: str, value: str) -> bool:
19411987
return False
19421988

19431989

1990+
def _is_bash_zsh_export_line(line: str, name: str) -> bool:
1991+
"""Check if a line is a bash/zsh export for the given variable name.
1992+
1993+
Matches patterns:
1994+
- export NAME="value"
1995+
- export NAME='value'
1996+
- export NAME=value
1997+
- NAME="value" (without export keyword)
1998+
1999+
Does NOT match:
2000+
- Comments containing the variable name
2001+
- Lines where the variable name is part of another word
2002+
2003+
Args:
2004+
line: The line to check.
2005+
name: The environment variable name.
2006+
2007+
Returns:
2008+
bool: True if line exports the variable, False otherwise.
2009+
"""
2010+
stripped = line.strip()
2011+
2012+
# Skip comments
2013+
if stripped.startswith('#'):
2014+
return False
2015+
2016+
# Match "export NAME=" or "NAME=" patterns
2017+
# Must be at start of line (after stripping) to avoid partial matches
2018+
return stripped.startswith((f'export {name}=', f'{name}='))
2019+
2020+
2021+
def _is_fish_set_line(line: str, name: str) -> bool:
2022+
"""Check if a line is a fish shell set command for the given variable name.
2023+
2024+
Matches patterns:
2025+
- set -gx NAME "value"
2026+
- set -gx NAME 'value'
2027+
- set -Ux NAME "value"
2028+
- set NAME "value"
2029+
- And variations with different flag orders
2030+
2031+
Does NOT match:
2032+
- Comments containing the variable name
2033+
- Lines where the variable name is part of another word
2034+
2035+
Args:
2036+
line: The line to check.
2037+
name: The environment variable name.
2038+
2039+
Returns:
2040+
bool: True if line sets the variable, False otherwise.
2041+
"""
2042+
stripped = line.strip()
2043+
2044+
# Skip comments
2045+
if stripped.startswith('#'):
2046+
return False
2047+
2048+
# Fish shell set pattern: set [-flags] NAME value
2049+
# Pattern matches: set (with optional flags like -gx, -Ux, etc.) followed by NAME and value
2050+
fish_pattern = rf'^set\s+(?:-[gGxXUu]+\s+)*{re.escape(name)}\s+'
2051+
return bool(re.match(fish_pattern, stripped))
2052+
2053+
2054+
def _is_env_var_line(config_file: Path, line: str, name: str) -> bool:
2055+
"""Check if a line sets the given environment variable (any shell syntax).
2056+
2057+
Detects the shell type from the config file path and checks accordingly.
2058+
2059+
Args:
2060+
config_file: Path to the shell config file.
2061+
line: The line to check.
2062+
name: The environment variable name.
2063+
2064+
Returns:
2065+
bool: True if line sets the variable, False otherwise.
2066+
"""
2067+
# Check if this is a fish config file
2068+
is_fish = 'fish' in str(config_file)
2069+
2070+
if is_fish:
2071+
return _is_fish_set_line(line, name)
2072+
return _is_bash_zsh_export_line(line, name)
2073+
2074+
19442075
def remove_export_from_file(config_file: Path, name: str) -> bool:
19452076
"""Remove an environment variable export from a shell config file.
19462077
1947-
Removes the variable from the claude-code-toolbox marker block.
1948-
If the block becomes empty, removes the entire block.
2078+
Removes the variable from:
2079+
1. The claude-code-toolbox marker block (if exists)
2080+
2. ALSO from anywhere else in the file (legacy/manual additions)
2081+
2082+
If the marker block becomes empty after removal, removes the entire block.
19492083
19502084
Args:
19512085
config_file: Path to the shell config file.
@@ -1959,45 +2093,49 @@ def remove_export_from_file(config_file: Path, name: str) -> bool:
19592093

19602094
try:
19612095
content = config_file.read_text(encoding='utf-8')
2096+
original_content = content
2097+
lines = content.split('\n')
2098+
new_lines: list[str] = []
19622099

1963-
if ENV_VAR_MARKER_START not in content:
1964-
# No marker block, nothing to remove
1965-
return True
1966-
1967-
start_idx = content.find(ENV_VAR_MARKER_START)
1968-
end_idx = content.find(ENV_VAR_MARKER_END)
1969-
1970-
if end_idx == -1:
1971-
end_idx = len(content)
1972-
1973-
before = content[:start_idx]
1974-
block = content[start_idx : end_idx + len(ENV_VAR_MARKER_END)]
1975-
after = content[end_idx + len(ENV_VAR_MARKER_END) :]
1976-
1977-
# Parse and filter the block
1978-
block_lines = block.split('\n')
1979-
new_block_lines: list[str] = []
1980-
1981-
for line in block_lines:
1982-
if line in (ENV_VAR_MARKER_START, ENV_VAR_MARKER_END):
1983-
continue
1984-
if line.strip().startswith(f'export {name}='):
2100+
# First pass: Remove the variable from ANYWHERE in the file
2101+
for line in lines:
2102+
if _is_env_var_line(config_file, line, name):
19852103
# Skip this line (remove the variable)
19862104
continue
1987-
if line.strip():
1988-
new_block_lines.append(line)
1989-
1990-
# Reconstruct
1991-
if new_block_lines:
1992-
# Block still has content
1993-
new_block = ENV_VAR_MARKER_START + '\n' + '\n'.join(new_block_lines) + '\n' + ENV_VAR_MARKER_END
1994-
new_content = before + new_block + after
1995-
else:
1996-
# Block is empty, remove it entirely
1997-
# Clean up extra newlines
1998-
new_content = before.rstrip('\n') + '\n' + after.lstrip('\n')
1999-
2000-
config_file.write_text(new_content, encoding='utf-8')
2105+
new_lines.append(line)
2106+
2107+
new_content = '\n'.join(new_lines)
2108+
2109+
# Clean up empty marker blocks
2110+
if ENV_VAR_MARKER_START in new_content:
2111+
start_idx = new_content.find(ENV_VAR_MARKER_START)
2112+
end_idx = new_content.find(ENV_VAR_MARKER_END)
2113+
2114+
if end_idx != -1:
2115+
before = new_content[:start_idx]
2116+
block = new_content[start_idx : end_idx + len(ENV_VAR_MARKER_END)]
2117+
after = new_content[end_idx + len(ENV_VAR_MARKER_END) :]
2118+
2119+
# Check if block is empty (only markers and whitespace)
2120+
block_lines = block.split('\n')
2121+
has_content = False
2122+
for block_line in block_lines:
2123+
if block_line in (ENV_VAR_MARKER_START, ENV_VAR_MARKER_END):
2124+
continue
2125+
if block_line.strip():
2126+
has_content = True
2127+
break
2128+
2129+
if not has_content:
2130+
# Block is empty, remove it entirely
2131+
new_content = before.rstrip('\n') + '\n' + after.lstrip('\n')
2132+
# Handle edge case where file becomes only newlines
2133+
if new_content.strip() == '':
2134+
new_content = ''
2135+
2136+
# Only write if content changed
2137+
if new_content != original_content:
2138+
config_file.write_text(new_content, encoding='utf-8')
20012139
return True
20022140

20032141
except OSError as e:

0 commit comments

Comments
 (0)