Files
python-sdk/pkg/hanzo-tools-shell/hanzo_tools/shell/shell_detect.py
T
zeekay 8d30e5a9e3 feat(shell): remove cmd, add ksh/tcsh/csh support, shell-first exposure
- Remove 'cmd' from default TOOLS list - only detected shell exposed
- Add KshTool, TcshTool, CshTool for enterprise Unix shells
- Fallback to ShellTool (smart auto-detect) if shell detection fails
- Support ksh93, pdksh, mksh as aliases for ksh
- Bump hanzo-tools-shell to 0.6.1, hanzo-mcp to 0.10.37
2026-01-11 19:22:32 -08:00

402 lines
13 KiB
Python

"""Shell detection for hanzo-mcp.
Detects the user's active shell and login shell to expose only
the relevant shell tool via MCP. On Mac with Homebrew, this will
detect and use /opt/homebrew/bin/zsh if that's what the user has
configured.
Environment variables for override:
- HANZO_MCP_SHELL: Force a specific shell (e.g., "zsh", "bash")
- HANZO_MCP_FORCE_SHELL: Force a specific shell path (e.g., "/opt/homebrew/bin/zsh")
"""
from __future__ import annotations
import os
import platform
import shlex
import shutil
import subprocess
from dataclasses import dataclass
from typing import Optional, Tuple, Dict, List
# Common shells (names you might see from `ps -o comm=`) and typical full paths.
KNOWN_SHELL_NAMES = {
"zsh", "bash", "fish", "sh", "ksh", "tcsh", "csh", "dash", "nu", "xonsh",
"pwsh", "powershell",
}
KNOWN_SHELL_PATH_BASENAMES = KNOWN_SHELL_NAMES | {"busybox"} # sometimes sh is busybox
# Shells we support with dedicated tools
SUPPORTED_SHELLS = {"zsh", "bash", "fish", "dash", "ksh", "tcsh", "csh"}
@dataclass(frozen=True)
class ShellInfo:
"""Information about detected shells."""
# "Login shell" as configured on the user account (what ssh/login uses).
login_shell: Optional[str]
# The shell that appears to have invoked this process (best-effort).
invoking_shell: Optional[str]
# $SHELL (often, but not always, matches login shell)
env_shell: Optional[str]
# Debug evidence for logging / telemetry.
evidence: Dict[str, str]
def _run(cmd: List[str]) -> Tuple[int, str, str]:
"""Run a command and return (returncode, stdout, stderr)."""
try:
p = subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=False,
)
return p.returncode, p.stdout.strip(), p.stderr.strip()
except Exception as e:
return 127, "", f"{type(e).__name__}: {e}"
def _which(cmd: str) -> Optional[str]:
"""Find command in PATH."""
paths = os.environ.get("PATH", "").split(os.pathsep)
for d in paths:
p = os.path.join(d, cmd)
if os.path.isfile(p) and os.access(p, os.X_OK):
return p
return None
def _parse_passwd_line(line: str) -> Optional[str]:
"""Parse passwd format: name:pw:uid:gid:gecos:dir:shell"""
parts = line.split(":")
if len(parts) >= 7:
shell = parts[6].strip()
return shell or None
return None
def get_login_shell() -> Tuple[Optional[str], Dict[str, str]]:
"""
Tries multiple sources to determine the *account login shell*.
Order of preference:
1) Python's pwd database (POSIX)
2) macOS: `id -P user` (NSS/passwd view)
3) Linux: `getent passwd user`
"""
evidence: Dict[str, str] = {}
user = os.environ.get("USER") or os.environ.get("LOGNAME") or ""
# POSIX: pwd module uses NSS; on macOS it generally reflects Directory Services.
if os.name == "posix":
try:
import pwd
if user:
shell = pwd.getpwnam(user).pw_shell
evidence["pwd.getpwnam"] = shell
if shell:
return shell, evidence
except Exception as e:
evidence["pwd.getpwnam_error"] = f"{type(e).__name__}: {e}"
system = platform.system().lower()
# macOS: `id -P user` prints passwd-style record.
if system == "darwin" and user:
rc, out, err = _run(["id", "-P", user])
evidence["id_-P_rc"] = str(rc)
if err:
evidence["id_-P_err"] = err
if rc == 0 and out:
evidence["id_-P_out"] = out
# id -P output is colon-separated, shell is the last field.
shell = out.split(":")[-1].strip() or None
if shell:
return shell, evidence
# Linux/BSD: getent is the most robust CLI view into NSS.
if user and _which("getent"):
rc, out, err = _run(["getent", "passwd", user])
evidence["getent_rc"] = str(rc)
if err:
evidence["getent_err"] = err
if rc == 0 and out:
evidence["getent_out"] = out
shell = _parse_passwd_line(out)
if shell:
return shell, evidence
# As a last resort: $SHELL (not authoritative for login shell)
env_shell = os.environ.get("SHELL")
if env_shell:
evidence["fallback_env_SHELL"] = env_shell
return env_shell, evidence
def _ps_comm(pid: int) -> Optional[str]:
"""Get executable name for a process."""
rc, out, _ = _run(["ps", "-p", str(pid), "-o", "comm="])
if rc == 0 and out:
return out.strip()
return None
def _ps_args(pid: int) -> Optional[str]:
"""Get full command line for a process."""
rc, out, _ = _run(["ps", "-p", str(pid), "-o", "args="])
if rc == 0 and out:
return out.strip()
return None
def get_invoking_shell(max_hops: int = 12) -> Tuple[Optional[str], Dict[str, str]]:
"""
Best-effort guess of the *shell that invoked this process* by walking parent PIDs.
This is useful if hanzo-mcp is launched from a user's interactive shell.
If your process is daemonized / launched by a service manager, this may return None.
"""
evidence: Dict[str, str] = {}
if os.name != "posix":
return None, {"note": "invoking shell detection is POSIX-only"}
pid = os.getpid()
ppid = os.getppid()
evidence["self_pid"] = str(pid)
evidence["self_ppid"] = str(ppid)
# Walk parent chain looking for a known shell.
cur = ppid
for hop in range(max_hops):
comm = _ps_comm(cur) or ""
args = _ps_args(cur) or ""
evidence[f"hop_{hop}_pid"] = str(cur)
if comm:
evidence[f"hop_{hop}_comm"] = comm
if args:
evidence[f"hop_{hop}_args"] = args
base = os.path.basename(comm).strip()
if base in KNOWN_SHELL_PATH_BASENAMES:
# Try to return a plausible full path if args contains one.
if os.path.isabs(comm):
return comm, evidence
# Pull first token from args if it looks like a path to shell.
try:
argv0 = shlex.split(args)[0]
except Exception:
argv0 = ""
if argv0 and (os.path.isabs(argv0) or os.path.basename(argv0) in KNOWN_SHELL_NAMES):
return argv0, evidence
return comm, evidence
# Next parent: use `ps -o ppid=` to get parent of parent.
rc, out, _ = _run(["ps", "-p", str(cur), "-o", "ppid="])
if rc != 0 or not out.strip():
break
next_ppid = int(out.strip())
if next_ppid <= 1 or next_ppid == cur:
break
cur = next_ppid
return None, evidence
def normalize_shell(shell: Optional[str]) -> Optional[str]:
"""Normalize shell path - return as-is if valid."""
if not shell:
return None
shell = shell.strip()
return shell or None
def shell_basename(shell: Optional[str]) -> Optional[str]:
"""Get the basename of a shell path (e.g., '/opt/homebrew/bin/zsh' -> 'zsh')."""
if not shell:
return None
return os.path.basename(shell).strip() or None
def choose_default_shell(info: ShellInfo) -> str:
"""
Policy: prefer invoking shell if detected; else login shell; else $SHELL; else /bin/sh.
"""
for candidate in (info.invoking_shell, info.login_shell, info.env_shell):
c = normalize_shell(candidate)
if c:
return c
# Very conservative fallback
return "C:\\Windows\\System32\\cmd.exe" if os.name == "nt" else "/bin/sh"
def detect_shells() -> ShellInfo:
"""Detect all shell information."""
login_shell, login_ev = get_login_shell()
invoking_shell, inv_ev = get_invoking_shell()
env_shell = os.environ.get("SHELL")
evidence: Dict[str, str] = {}
evidence.update({f"login.{k}": v for k, v in login_ev.items()})
evidence.update({f"invoking.{k}": v for k, v in inv_ev.items()})
if env_shell:
evidence["env.SHELL"] = env_shell
return ShellInfo(
login_shell=normalize_shell(login_shell),
invoking_shell=normalize_shell(invoking_shell),
env_shell=normalize_shell(env_shell),
evidence=evidence,
)
def resolve_shell_path(shell_name: str) -> Optional[str]:
"""
Resolve a shell name to its full path, preferring Homebrew on macOS.
Args:
shell_name: Shell name (e.g., 'zsh', 'bash')
Returns:
Full path to shell or None if not found
"""
# Check environment override first
force_shell = os.environ.get("HANZO_MCP_FORCE_SHELL")
if force_shell:
if os.path.isfile(force_shell) and os.access(force_shell, os.X_OK):
return force_shell
# Priority paths - Homebrew first on macOS
search_paths = [
f"/opt/homebrew/bin/{shell_name}", # Apple Silicon Homebrew
f"/usr/local/bin/{shell_name}", # Intel Homebrew
f"/bin/{shell_name}", # System
f"/usr/bin/{shell_name}", # System alternative
]
for path in search_paths:
if os.path.isfile(path) and os.access(path, os.X_OK):
return path
# Fallback to which
return shutil.which(shell_name)
def get_active_shell() -> Tuple[str, str]:
"""
Get the user's active shell name and path.
Respects environment overrides:
- HANZO_MCP_SHELL: Force shell name (e.g., "zsh")
- HANZO_MCP_FORCE_SHELL: Force shell path (e.g., "/opt/homebrew/bin/zsh")
Returns:
Tuple of (shell_name, shell_path)
e.g., ("zsh", "/opt/homebrew/bin/zsh")
"""
# Check for explicit override
override_shell = os.environ.get("HANZO_MCP_SHELL")
if override_shell:
override_shell = override_shell.strip().lower()
if override_shell in SUPPORTED_SHELLS:
path = resolve_shell_path(override_shell)
if path:
return override_shell, path
# Check for explicit path override
force_path = os.environ.get("HANZO_MCP_FORCE_SHELL")
if force_path and os.path.isfile(force_path) and os.access(force_path, os.X_OK):
name = shell_basename(force_path)
if name and name in SUPPORTED_SHELLS:
return name, force_path
# If it's an unknown shell, still use it but expose as "shell"
return name or "shell", force_path
# Detect from environment
info = detect_shells()
chosen_path = choose_default_shell(info)
chosen_name = shell_basename(chosen_path)
# Map to supported shell name
if chosen_name in SUPPORTED_SHELLS:
# Resolve to best available path (prefer Homebrew)
best_path = resolve_shell_path(chosen_name)
return chosen_name, best_path or chosen_path
# Fallback: if detected shell isn't supported, default to zsh
for fallback in ["zsh", "bash", "fish", "dash"]:
path = resolve_shell_path(fallback)
if path:
return fallback, path
# Ultimate fallback
return "sh", "/bin/sh"
def get_shell_tool_class(shell_name: str):
"""
Get the appropriate shell tool class for the given shell name.
Args:
shell_name: Shell name (e.g., 'zsh', 'bash')
Returns:
Shell tool class or None if not supported
"""
# Import here to avoid circular imports
from hanzo_tools.shell.shell_tools import (
ZshTool, BashTool, FishTool, DashTool, KshTool, TcshTool, CshTool
)
shell_map = {
"zsh": ZshTool,
"bash": BashTool,
"fish": FishTool,
"dash": DashTool,
"ksh": KshTool,
"ksh93": KshTool, # Alias
"pdksh": KshTool, # Alias
"mksh": KshTool, # Alias
"tcsh": TcshTool,
"csh": CshTool,
}
return shell_map.get(shell_name.lower())
# Cache for shell detection (avoid repeated subprocess calls)
_cached_shell: Optional[Tuple[str, str]] = None
def get_cached_active_shell() -> Tuple[str, str]:
"""Get cached active shell info (detects once per process)."""
global _cached_shell
if _cached_shell is None:
_cached_shell = get_active_shell()
return _cached_shell
def clear_shell_cache():
"""Clear the shell detection cache (useful for testing)."""
global _cached_shell
_cached_shell = None
if __name__ == "__main__":
info = detect_shells()
shell_name, shell_path = get_active_shell()
print("=== Shell Detection ===")
print(f"login_shell : {info.login_shell}")
print(f"invoking_shell: {info.invoking_shell}")
print(f"env_shell : {info.env_shell}")
print(f"")
print(f"=== Active Shell ===")
print(f"name : {shell_name}")
print(f"path : {shell_path}")
print(f"")
print(f"=== Environment ===")
print(f"HANZO_MCP_SHELL : {os.environ.get('HANZO_MCP_SHELL', '(not set)')}")
print(f"HANZO_MCP_FORCE_SHELL: {os.environ.get('HANZO_MCP_FORCE_SHELL', '(not set)')}")