Skip to content

Commit 24feb5a

Browse files
feat: add fish shell support for venv activation instructions
Detect the user's shell (fish, zsh, bash) and show the correct venv activation command. Fish shell users now see 'source .venv/bin/activate.fish' instead of the bash-only 'source .venv/bin/activate'. Closes #36 Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
1 parent 2b59513 commit 24feb5a

File tree

3 files changed

+89
-3
lines changed

3 files changed

+89
-3
lines changed

src/dspy_cli/server/runner.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,8 @@ def notify_cli(msg: str, level: str = "info"):
221221
click.echo(" 2. Subclass dspy.Module")
222222
click.echo(" 3. Are not named with a leading underscore")
223223
click.echo(" 4. If you are using external dependencies:")
224-
click.echo(" - Ensure your venv is activated")
224+
from dspy_cli.utils.venv import venv_activate_command
225+
click.echo(f" - Ensure your venv is activated ({venv_activate_command()})")
225226
click.echo(" - Make sure you have dspy-cli as a local dependency")
226227
click.echo(" - Install them using pip install -e .")
227228

src/dspy_cli/utils/venv.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,42 @@ def validate_python_version(python: Path, min_version: tuple = (3, 9)) -> tuple[
143143
return False, ""
144144

145145

146+
def detect_shell() -> str:
147+
"""Detect the current shell type.
148+
149+
Returns:
150+
Shell name: "fish", "zsh", "bash", or "sh" (default fallback).
151+
"""
152+
if os.environ.get("FISH_VERSION"):
153+
return "fish"
154+
155+
shell = os.environ.get("SHELL", "")
156+
for name in ("fish", "zsh", "bash"):
157+
if shell.endswith(f"/{name}"):
158+
return name
159+
160+
return "sh"
161+
162+
163+
def venv_activate_command(venv_dir: str = ".venv") -> str:
164+
"""Return the shell-appropriate venv activation command.
165+
166+
Args:
167+
venv_dir: Virtual environment directory name.
168+
169+
Returns:
170+
Activation command string for the current shell.
171+
"""
172+
shell = detect_shell()
173+
if shell == "fish":
174+
return f"source {venv_dir}/bin/activate.fish"
175+
return f"source {venv_dir}/bin/activate"
176+
177+
146178
def show_venv_warning():
147179
"""Display warning and guidance when no venv is detected."""
180+
activate_cmd = venv_activate_command()
181+
148182
click.echo(click.style("⚠ Warning: No virtual environment detected", fg="yellow"))
149183
click.echo()
150184
click.echo("Running without a project venv may cause import errors if dependencies")
@@ -154,7 +188,7 @@ def show_venv_warning():
154188
click.echo(" 1. Add dspy-cli to your project:")
155189
click.echo(" uv add dspy-cli")
156190
click.echo(" 2. Create and activate a venv:")
157-
click.echo(" uv sync (or python -m venv .venv && source .venv/bin/activate)")
191+
click.echo(f" uv sync (or python -m venv .venv && {activate_cmd})")
158192
click.echo(" 3. Use a task runner:")
159193
click.echo(" uv run dspy-cli serve")
160194
click.echo(" 4. Specify Python interpreter:")

tests/test_venv_utils.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@
77
from pathlib import Path
88

99

10-
from dspy_cli.utils.venv import sanitize_env_for_exec, validate_python_version
10+
from dspy_cli.utils.venv import (
11+
detect_shell,
12+
sanitize_env_for_exec,
13+
validate_python_version,
14+
venv_activate_command,
15+
)
1116

1217

1318
class TestValidatePythonVersion:
@@ -119,3 +124,49 @@ def test_preserves_other_vars(self):
119124

120125
# Cleanup
121126
os.environ.pop("MY_CUSTOM_VAR", None)
127+
128+
129+
class TestDetectShell:
130+
"""Tests for shell detection."""
131+
132+
def test_fish_via_fish_version(self, monkeypatch):
133+
monkeypatch.setenv("FISH_VERSION", "3.6.1")
134+
monkeypatch.setenv("SHELL", "/bin/bash")
135+
assert detect_shell() == "fish"
136+
137+
def test_fish_via_shell_env(self, monkeypatch):
138+
monkeypatch.delenv("FISH_VERSION", raising=False)
139+
monkeypatch.setenv("SHELL", "/usr/local/bin/fish")
140+
assert detect_shell() == "fish"
141+
142+
def test_bash_via_shell_env(self, monkeypatch):
143+
monkeypatch.delenv("FISH_VERSION", raising=False)
144+
monkeypatch.setenv("SHELL", "/bin/bash")
145+
assert detect_shell() == "bash"
146+
147+
def test_zsh_via_shell_env(self, monkeypatch):
148+
monkeypatch.delenv("FISH_VERSION", raising=False)
149+
monkeypatch.setenv("SHELL", "/bin/zsh")
150+
assert detect_shell() == "zsh"
151+
152+
def test_fallback_to_sh(self, monkeypatch):
153+
monkeypatch.delenv("FISH_VERSION", raising=False)
154+
monkeypatch.delenv("SHELL", raising=False)
155+
assert detect_shell() == "sh"
156+
157+
158+
class TestVenvActivateCommand:
159+
"""Tests for shell-aware activation command."""
160+
161+
def test_fish_uses_activate_fish(self, monkeypatch):
162+
monkeypatch.setenv("FISH_VERSION", "3.6.1")
163+
assert venv_activate_command() == "source .venv/bin/activate.fish"
164+
165+
def test_bash_uses_activate(self, monkeypatch):
166+
monkeypatch.delenv("FISH_VERSION", raising=False)
167+
monkeypatch.setenv("SHELL", "/bin/bash")
168+
assert venv_activate_command() == "source .venv/bin/activate"
169+
170+
def test_custom_venv_dir(self, monkeypatch):
171+
monkeypatch.setenv("FISH_VERSION", "3.6.1")
172+
assert venv_activate_command("venv") == "source venv/bin/activate.fish"

0 commit comments

Comments
 (0)