diff --git a/code_puppy/cli_runner.py b/code_puppy/cli_runner.py index 3f8d2c028..ea7ed8e2d 100644 --- a/code_puppy/cli_runner.py +++ b/code_puppy/cli_runner.py @@ -395,12 +395,20 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non if initial_command: from code_puppy.command_line.shell_passthrough import ( execute_shell_passthrough, + is_known_cli_command, + is_powershell_cmdlet, is_shell_passthrough, ) if is_shell_passthrough(initial_command): execute_shell_passthrough(initial_command) initial_command = None + elif is_known_cli_command(initial_command) or is_powershell_cmdlet( + initial_command + ): + # Auto-detect known CLI / PowerShell commands — bypass AI, zero tokens + execute_shell_passthrough(f"!{initial_command.strip()}") + initial_command = None # Initialize the runtime agent manager if initial_command: @@ -597,6 +605,8 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non # Shell pass-through: ! executes directly, bypassing the agent from code_puppy.command_line.shell_passthrough import ( execute_shell_passthrough, + is_known_cli_command, + is_powershell_cmdlet, is_shell_passthrough, ) @@ -604,6 +614,13 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non execute_shell_passthrough(task) continue + # Auto-detect known CLI commands (e.g. `ls`, `git status`, `grep …`) + # and PowerShell cmdlets (e.g. `Get-ChildItem`, `Set-Location`) + # — route directly to the shell, zero tokens consumed. + if is_known_cli_command(task) or is_powershell_cmdlet(task): + execute_shell_passthrough(f"!{task.strip()}") + continue + # Check for exit commands (plain text or command form) if task.strip().lower() in ["exit", "quit"] or task.strip().lower() in [ "/exit", @@ -1014,6 +1031,8 @@ async def execute_single_prompt(prompt: str, message_renderer) -> None: # Shell pass-through: ! bypasses the agent even in -p mode from code_puppy.command_line.shell_passthrough import ( execute_shell_passthrough, + is_known_cli_command, + is_powershell_cmdlet, is_shell_passthrough, ) @@ -1021,6 +1040,11 @@ async def execute_single_prompt(prompt: str, message_renderer) -> None: execute_shell_passthrough(prompt) return + # Auto-detect known CLI / PowerShell commands — bypass AI, zero tokens + if is_known_cli_command(prompt) or is_powershell_cmdlet(prompt): + execute_shell_passthrough(f"!{prompt.strip()}") + return + from code_puppy.messaging import emit_info emit_info(f"Executing prompt: {prompt}") diff --git a/code_puppy/command_line/shell_passthrough.py b/code_puppy/command_line/shell_passthrough.py index ecf047728..496d04cbd 100644 --- a/code_puppy/command_line/shell_passthrough.py +++ b/code_puppy/command_line/shell_passthrough.py @@ -3,13 +3,22 @@ Prepend a prompt with `!` to execute it as a shell command directly, bypassing the agent entirely. Inspired by Claude Code's `!` prefix. +Also auto-detects well-known CLI commands (e.g. ``ls``, ``git``, ``grep``) +so users can type them without the ``!`` prefix and still bypass the AI agent +— consuming zero tokens. + Examples: !ls -la !git status !python --version + ls -la ← auto-detected, no tokens used + git status ← auto-detected, no tokens used + ls | grep test ← auto-detected, no tokens used """ import os +import re +import shutil import subprocess import sys import time @@ -25,6 +34,370 @@ # Banner identifier — matches the key in DEFAULT_BANNER_COLORS _BANNER_NAME = "shell_passthrough" +# --------------------------------------------------------------------------- +# Auto-detection: known CLI commands that should bypass the AI agent +# --------------------------------------------------------------------------- +# When a user types one of these commands directly (without the ``!`` prefix), +# Code Puppy automatically routes the input to the shell — no tokens consumed. +# +# Extend this set to add more commands. Keep it sorted for readability. +KNOWN_CLI_COMMANDS: frozenset[str] = frozenset( + { + # ── Archives ────────────────────────────────────────────────────── + "bzip2", + "gunzip", + "gzip", + "tar", + "unzip", + "xz", + "zip", + # ── Build / package managers ────────────────────────────────────── + "cargo", + "cmake", + "gradle", + "make", + "maven", + "mvn", + "npm", + "npx", + "pip", + "pip3", + "poetry", + "pnpm", + "yarn", + # ── Containers / orchestration ──────────────────────────────────── + "docker", + "docker-compose", + "helm", + "kubectl", + "podman", + # ── File system ─────────────────────────────────────────────────── + "cat", + "cd", + "chmod", + "chown", + "cp", + "dir", + "du", + "file", + "find", + "head", + "la", + "less", + "ll", + "ln", + "locate", + "ls", + "mkdir", + "more", + "mv", + "popd", + "pushd", + "pwd", + "rm", + "rmdir", + "tail", + "touch", + "tree", + "wc", + # ── Language runtimes ───────────────────────────────────────────── + "go", + "java", + "javac", + "node", + "python", + "python3", + "ruby", + "rustc", + # ── Misc utilities ──────────────────────────────────────────────── + "alias", + "cal", + "date", + "df", + "echo", + "env", + "export", + "free", + "history", + "id", + "info", + "man", + "open", + "pbcopy", + "pbpaste", + "printf", + "type", + "uname", + "unalias", + "uptime", + "which", + "whereis", + "whoami", + "xclip", + "xsel", + # ── Network ─────────────────────────────────────────────────────── + "curl", + "dig", + "host", + "ifconfig", + "ip", + "nc", + "netstat", + "nslookup", + "ping", + "rsync", + "scp", + "ssh", + "wget", + # ── Process / system ────────────────────────────────────────────── + "bg", + "fg", + "htop", + "jobs", + "kill", + "killall", + "ps", + "top", + "who", + # ── System package managers ─────────────────────────────────────── + "apt", + "apt-get", + "dnf", + "pacman", + "snap", + "systemctl", + "yum", + # ── Text processing ─────────────────────────────────────────────── + "awk", + "cut", + "diff", + "egrep", + "fgrep", + "grep", + "jq", + "patch", + "rg", + "ripgrep", + "sed", + "sort", + "tee", + "tr", + "uniq", + "xargs", + # ── Version control ─────────────────────────────────────────────── + "git", + "hg", + "svn", + } +) + +# Commands that double as common English words and frequently appear at the +# start of natural-language prompts (e.g. "find memory leak in parser", +# "open the config file", "type the password"). For these we require stronger +# shell intent evidence before auto-routing. +_AMBIGUOUS_CLI_COMMANDS: frozenset[str] = frozenset( + { + "date", + "find", + "history", + "id", + "info", + "man", + "open", + "type", + "which", + "who", + } +) + +# Patterns that strongly indicate the user intends a real shell invocation: +# | & ; < > ` $ ( ) — shell operators / substitution +# -flag — a CLI flag (-v, --verbose, -la …) +# ./path ../path /abs/path — explicit Unix filesystem paths +# .\path ..\path C:\abs\path — explicit Windows filesystem paths +_SHELL_INTENT_RE = re.compile( + r"[|&;<>`$()]" + r"|(?:^|\s)-\w" + r"|(?:^|\s)\.{1,2}[/\\]" + r"|(?:^|\s)/" + r"|(?:^|\s)[A-Za-z]:\\" +) + +# Pre-compiled regex: first "word" of the input (handles leading whitespace) +_FIRST_WORD_RE = re.compile(r"^\s*(\S+)") + + +def is_known_cli_command(task: str) -> bool: + """Return True when *task* starts with a well-known CLI command name **and** + the overall input looks like a real shell invocation rather than natural + language. + + Three-stage filter (conservative by design): + + 1. **Known list** — first token must be in ``KNOWN_CLI_COMMANDS``. + 2. **PATH check** — the executable must actually exist on ``$PATH`` + (via ``shutil.which``). This rejects invented commands that happen to + share a name with a list entry on this machine. + 3. **Ambiguity guard** — for commands that are also common English words + (``find``, ``open``, ``date`` …) the rest of the input must show at + least one strong shell-intent signal: a flag (``-v``), a path + (``./src``), or a shell operator (``|``, ``>``, etc.). + + Single-word inputs (``pwd``, ``git``) skip stage 3 — they're unambiguous. + + Args: + task: Raw user input string. + + Returns: + True if the input should be routed directly to the shell. + """ + stripped = task.strip() + + # Already handled by other code paths — skip early. + if stripped.startswith(SHELL_PASSTHROUGH_PREFIX) or stripped.startswith("/"): + return False + + match = _FIRST_WORD_RE.match(stripped) + if not match: + return False + + first_word = match.group(1).lower() + + # Stage 1: must be a known CLI command. + if first_word not in KNOWN_CLI_COMMANDS: + return False + + # Stage 2: executable must exist on PATH (prevents false positives on + # machines where the command isn't installed, and catches typos). + if shutil.which(first_word) is None: + return False + + # Single-word commands are unambiguous — accept immediately. + if " " not in stripped: + return True + + # Stage 3: ambiguous English words require explicit shell-intent evidence. + if first_word in _AMBIGUOUS_CLI_COMMANDS and not _SHELL_INTENT_RE.search(stripped): + return False + + return True + + +# --------------------------------------------------------------------------- +# PowerShell Verb-Noun cmdlet detection (cross-platform) +# --------------------------------------------------------------------------- +# PowerShell cmdlets follow an approved Verb-Noun naming convention: +# Get-ChildItem, Set-Location, Invoke-WebRequest, etc. +# These are NOT in KNOWN_CLI_COMMANDS because they use a unique naming pattern. +# PowerShell Core (pwsh) runs on Windows, macOS, AND Linux — so this detection +# is NOT gated by platform. Instead, we check for `pwsh`/`powershell` on PATH. +_PS_CMDLET_RE = re.compile( + r"^(?:Get|Set|New|Remove|Start|Stop|Invoke|Write|Read|" + r"Copy|Move|Rename|Test|Out|Format|Select|Where|Sort|" + r"Add|Clear|Compare|Convert|ConvertFrom|ConvertTo|" + r"Disable|Enable|Enter|Exit|Export|Import|Join|" + r"Limit|Measure|Merge|Pop|Push|Redo|Reset|" + r"Restore|Resume|Save|Search|Send|Split|" + r"Step|Submit|Switch|Sync|Undo|Uninstall|" + r"Unlock|Unregister|Update|Use|Wait|Watch)-\w+", + re.IGNORECASE, +) + +# Cache for PowerShell availability — computed once per session. +# None means "not yet checked", True/False is the cached result. +_powershell_available: bool | None = None + + +def _is_powershell_on_path() -> bool: + """Return True if ``pwsh`` or ``powershell`` is available on ``$PATH``. + + Result is cached for the lifetime of the process so we only pay the + ``shutil.which`` cost once per session — not on every keystroke. + """ + global _powershell_available + if _powershell_available is None: + _powershell_available = ( + shutil.which("pwsh") is not None or shutil.which("powershell") is not None + ) + return _powershell_available + + +def is_powershell_cmdlet(task: str) -> bool: + """Return True when *task* looks like a PowerShell Verb-Noun cmdlet. + + Works on **any platform** where PowerShell Core (``pwsh``) or legacy + ``powershell`` is installed. The availability check is cached so only + the very first call per session incurs a ``shutil.which`` lookup. + + Examples that match: + Get-ChildItem + Get-ChildItem -Path ./src + Set-Location C:\\projects + Invoke-WebRequest https://example.com + + Examples that do NOT match: + Get some coffee (not Verb-Noun) + ls -la (handled by is_known_cli_command) + Select the right option (no hyphen-joined noun) + + Args: + task: Raw user input string. + + Returns: + True if the input should be routed to PowerShell directly. + """ + stripped = task.strip() + + # Already handled by other code paths. + if stripped.startswith(SHELL_PASSTHROUGH_PREFIX) or stripped.startswith("/"): + return False + + # Must match Verb-Noun pattern first (cheap regex check). + if not _PS_CMDLET_RE.match(stripped): + return False + + # Must have PowerShell available (cached after first call). + return _is_powershell_on_path() + + +# --------------------------------------------------------------------------- +# Platform-aware shell resolution +# --------------------------------------------------------------------------- + +# Cache for the resolved shell — computed once per session. +_cached_platform_shell: list[str] | None = None + + +def _get_platform_shell() -> list[str]: + """Return the shell executable + invocation flag for the current platform. + + - **Windows**: prefer ``pwsh`` (PowerShell Core) → ``powershell`` + (legacy) → ``cmd``. + - **Unix**: use ``$SHELL`` env var → fallback to ``/bin/sh``. + + Result is cached for the lifetime of the process so we only pay the + ``shutil.which`` cost once — not on every command. + + Returns: + A list like ``["pwsh", "-Command"]`` or ``["/bin/zsh", "-c"]`` + suitable for ``subprocess.run([*shell, command], ...)``. + """ + global _cached_platform_shell + if _cached_platform_shell is not None: + return _cached_platform_shell + + if sys.platform == "win32": + for ps in ("pwsh", "powershell"): + if shutil.which(ps): + _cached_platform_shell = [ps, "-Command"] + return _cached_platform_shell + _cached_platform_shell = ["cmd", "/c"] + else: + shell = os.environ.get("SHELL", "/bin/sh") + _cached_platform_shell = [shell, "-c"] + + return _cached_platform_shell + def _get_console() -> Console: """Get a Rich console for direct output. @@ -113,9 +486,10 @@ def execute_shell_passthrough(task: str) -> None: start_time = time.monotonic() try: + shell_args = _get_platform_shell() result = subprocess.run( - command, - shell=True, + [*shell_args, command], + shell=False, cwd=os.getcwd(), # Inherit stdio — output goes straight to the terminal stdin=sys.stdin, diff --git a/tests/test_shell_passthrough.py b/tests/test_shell_passthrough.py index 97486a111..54bad971b 100644 --- a/tests/test_shell_passthrough.py +++ b/tests/test_shell_passthrough.py @@ -5,17 +5,32 @@ """ import asyncio +import os from unittest.mock import AsyncMock, MagicMock, patch +import code_puppy.command_line.shell_passthrough as sp_mod from code_puppy.command_line.shell_passthrough import ( + _AMBIGUOUS_CLI_COMMANDS, _BANNER_NAME, + _PS_CMDLET_RE, + _SHELL_INTENT_RE, + KNOWN_CLI_COMMANDS, SHELL_PASSTHROUGH_PREFIX, _format_banner, + _get_platform_shell, execute_shell_passthrough, extract_command, + is_known_cli_command, + is_powershell_cmdlet, is_shell_passthrough, ) +# Shorthand for commonly-patched module paths +_SP = "code_puppy.command_line.shell_passthrough" +_MOCK_SHELL = f"{_SP}._get_platform_shell" +_MOCK_RUN = f"{_SP}.subprocess.run" +_MOCK_CONSOLE = f"{_SP}._get_console" + class TestIsShellPassthrough: """Test detection of shell pass-through input.""" @@ -69,6 +84,344 @@ def test_prefix_constant(self): assert SHELL_PASSTHROUGH_PREFIX == "!" +class TestIsKnownCliCommand: + """Test auto-detection of well-known CLI commands. + + Users should be able to type ``ls -la`` or ``git status`` directly and + have Code Puppy route the input to the shell without touching the AI agent + (zero tokens consumed). + + The detection uses a three-stage filter: + 1. First word must be in KNOWN_CLI_COMMANDS. + 2. Executable must exist on PATH (shutil.which). + 3. Ambiguous English words (find, open, …) require shell-intent evidence. + """ + + # ── Positive cases ──────────────────────────────────────────────────── + + def test_ls_alone(self): + """Bare `ls` is a known CLI command.""" + with patch("shutil.which", return_value="/bin/ls"): + assert is_known_cli_command("ls") is True + + def test_ls_with_flags(self): + """`ls -la` is auto-detected.""" + with patch("shutil.which", return_value="/bin/ls"): + assert is_known_cli_command("ls -la") is True + + def test_ls_pipe_grep(self): + """`ls | grep test` is auto-detected — pipe is shell-intent evidence.""" + with patch("shutil.which", return_value="/bin/ls"): + assert is_known_cli_command("ls | grep test") is True + + def test_git_status(self): + """`git status` is auto-detected.""" + with patch("shutil.which", return_value="/usr/bin/git"): + assert is_known_cli_command("git status") is True + + def test_grep_pattern_with_flag(self): + """`grep -r foo .` is auto-detected (has a flag).""" + with patch("shutil.which", return_value="/bin/grep"): + assert is_known_cli_command("grep -r foo .") is True + + def test_pwd_single_word(self): + """Single-word known command is accepted without shell-intent check.""" + with patch("shutil.which", return_value="/bin/pwd"): + assert is_known_cli_command("pwd") is True + + def test_leading_whitespace(self): + """Leading whitespace before a known command is tolerated.""" + with patch("shutil.which", return_value="/bin/ls"): + assert is_known_cli_command(" ls -la") is True + + def test_case_insensitive_first_word(self): + """First-word check is case-insensitive (LS → ls).""" + with patch("shutil.which", return_value="/bin/ls"): + assert is_known_cli_command("LS -la") is True + + def test_known_commands_set_non_empty(self): + """KNOWN_CLI_COMMANDS must contain at least the basics.""" + for cmd in ("ls", "git", "grep", "cat", "pwd", "find"): + assert cmd in KNOWN_CLI_COMMANDS + + def test_find_with_flag_passes_ambiguity_guard(self): + """`find . -name foo` has a flag → passes ambiguity guard.""" + with patch("shutil.which", return_value="/usr/bin/find"): + assert is_known_cli_command("find . -name foo") is True + + def test_find_with_path_passes_ambiguity_guard(self): + """`find ./src` has a path → passes ambiguity guard.""" + with patch("shutil.which", return_value="/usr/bin/find"): + assert is_known_cli_command("find ./src") is True + + def test_find_with_pipe_passes_ambiguity_guard(self): + """`find . | head` has a pipe → passes ambiguity guard.""" + with patch("shutil.which", return_value="/usr/bin/find"): + assert is_known_cli_command("find . | head") is True + + # ── Negative cases ──────────────────────────────────────────────────── + + def test_natural_language_not_detected(self): + """Natural language prompts must NOT be auto-detected as CLI commands.""" + assert is_known_cli_command("write me a python script") is False + + def test_slash_command_excluded(self): + """`/help` is a Code Puppy command, not a shell command.""" + assert is_known_cli_command("/help") is False + + def test_bang_prefix_excluded(self): + """`!ls` is already handled by `is_shell_passthrough`; skip here.""" + assert is_known_cli_command("!ls") is False + + def test_unknown_command(self): + """An unknown first word is not auto-detected.""" + assert is_known_cli_command("frobnicator --foo") is False + + def test_empty_string(self): + """Empty input is not a CLI command.""" + assert is_known_cli_command("") is False + + def test_whitespace_only(self): + """Whitespace-only input is not a CLI command.""" + assert is_known_cli_command(" ") is False + + def test_command_not_on_path_rejected(self): + """Known command name is rejected when not installed on PATH.""" + with patch("shutil.which", return_value=None): + assert is_known_cli_command("kubectl get pods") is False + + # ── Ambiguity guard (stage 3) ───────────────────────────────────────── + + def test_find_natural_language_blocked(self): + """`find memory leak in parser` looks like NL — blocked by ambiguity guard.""" + with patch("shutil.which", return_value="/usr/bin/find"): + assert is_known_cli_command("find memory leak in parser") is False + + def test_open_natural_language_blocked(self): + """`open the config file` looks like NL — blocked.""" + with patch("shutil.which", return_value="/usr/bin/open"): + assert is_known_cli_command("open the config file") is False + + def test_date_natural_language_blocked(self): + """`date of the last release` looks like NL — blocked.""" + with patch("shutil.which", return_value="/bin/date"): + assert is_known_cli_command("date of the last release") is False + + def test_type_natural_language_blocked(self): + """`type the password` looks like NL — blocked.""" + with patch("shutil.which", return_value="/usr/bin/type"): + assert is_known_cli_command("type the password") is False + + def test_ambiguous_set_contains_expected_words(self): + """_AMBIGUOUS_CLI_COMMANDS must include the risky English words.""" + for word in ("find", "open", "date", "type", "history", "who"): + assert word in _AMBIGUOUS_CLI_COMMANDS + + def test_shell_intent_re_matches_flag(self): + """_SHELL_INTENT_RE detects a CLI flag.""" + assert _SHELL_INTENT_RE.search("find . -name foo") is not None + + def test_shell_intent_re_matches_pipe(self): + """_SHELL_INTENT_RE detects a pipe operator.""" + assert _SHELL_INTENT_RE.search("find . | head") is not None + + def test_shell_intent_re_matches_relative_path(self): + """_SHELL_INTENT_RE detects a relative path.""" + assert _SHELL_INTENT_RE.search("find ./src") is not None + + def test_shell_intent_re_no_match_on_natural_language(self): + """_SHELL_INTENT_RE does NOT match plain English prose.""" + assert _SHELL_INTENT_RE.search("memory leak in parser") is None + + # ── Windows path detection (shell intent regex) ─────────────────────── + + def test_shell_intent_re_matches_windows_backslash_relative(self): + r"""_SHELL_INTENT_RE detects Windows relative path ``.\src``.""" + assert _SHELL_INTENT_RE.search(r"find .\src") is not None + + def test_shell_intent_re_matches_windows_parent_path(self): + r"""_SHELL_INTENT_RE detects Windows parent path ``..\config``.""" + assert _SHELL_INTENT_RE.search(r"find ..\config") is not None + + def test_shell_intent_re_matches_windows_drive_letter(self): + r"""_SHELL_INTENT_RE detects Windows absolute path ``C:\Users``.""" + assert _SHELL_INTENT_RE.search(r"find C:\Users") is not None + + +class TestIsPowershellCmdlet: + """Test auto-detection of PowerShell Verb-Noun cmdlets. + + PowerShell Core (pwsh) runs on Windows, macOS, AND Linux. + Detection is gated by ``pwsh``/``powershell`` being on PATH — not by + platform. The PATH check is cached, so we reset the cache in each test. + """ + + def _reset_ps_cache(self): + """Reset the cached PowerShell availability before each test.""" + sp_mod._powershell_available = None + + # ── Positive cases (pwsh on PATH) ───────────────────────────────────── + + def test_get_childitem_detected(self): + """Get-ChildItem is a valid PowerShell cmdlet.""" + self._reset_ps_cache() + with patch("shutil.which", return_value="/usr/local/bin/pwsh"): + assert is_powershell_cmdlet("Get-ChildItem") is True + + def test_set_location_detected(self): + """Set-Location C:\\projects is detected.""" + self._reset_ps_cache() + with patch("shutil.which", return_value="/usr/local/bin/pwsh"): + assert is_powershell_cmdlet("Set-Location C:\\projects") is True + + def test_invoke_webrequest_detected(self): + """Invoke-WebRequest is detected.""" + self._reset_ps_cache() + with patch("shutil.which", return_value="/usr/local/bin/pwsh"): + assert is_powershell_cmdlet("Invoke-WebRequest https://example.com") is True + + def test_select_string_detected(self): + """Select-String is detected (PowerShell grep equivalent).""" + self._reset_ps_cache() + with patch("shutil.which", return_value="/usr/local/bin/pwsh"): + assert is_powershell_cmdlet("Select-String -Pattern foo") is True + + def test_works_on_macos_with_pwsh(self): + """PowerShell cmdlets work on macOS when pwsh is installed.""" + self._reset_ps_cache() + with patch("shutil.which", return_value="/usr/local/bin/pwsh"): + assert is_powershell_cmdlet("Get-ChildItem") is True + + def test_works_on_linux_with_pwsh(self): + """PowerShell cmdlets work on Linux when pwsh is installed.""" + self._reset_ps_cache() + with patch("shutil.which", return_value="/usr/bin/pwsh"): + assert is_powershell_cmdlet("Get-ChildItem") is True + + # ── Negative cases ──────────────────────────────────────────────────── + + def test_natural_language_not_detected(self): + """'Get some coffee' does NOT match Verb-Noun pattern.""" + self._reset_ps_cache() + with patch("shutil.which", return_value="/usr/local/bin/pwsh"): + assert is_powershell_cmdlet("Get some coffee") is False + + def test_no_powershell_on_path_rejected(self): + """Even valid cmdlets are rejected if pwsh/powershell not on PATH.""" + self._reset_ps_cache() + with patch("shutil.which", return_value=None): + assert is_powershell_cmdlet("Get-ChildItem") is False + + def test_bang_prefix_excluded(self): + """!Get-ChildItem is handled by is_shell_passthrough.""" + self._reset_ps_cache() + with patch("shutil.which", return_value="/usr/local/bin/pwsh"): + assert is_powershell_cmdlet("!Get-ChildItem") is False + + def test_slash_prefix_excluded(self): + """/Get-ChildItem is a Code Puppy command.""" + self._reset_ps_cache() + with patch("shutil.which", return_value="/usr/local/bin/pwsh"): + assert is_powershell_cmdlet("/Get-ChildItem") is False + + def test_ps_cmdlet_regex_pattern(self): + """_PS_CMDLET_RE matches the Verb-Noun pattern.""" + assert _PS_CMDLET_RE.match("Get-ChildItem") is not None + assert _PS_CMDLET_RE.match("Set-Location") is not None + assert _PS_CMDLET_RE.match("Invoke-WebRequest") is not None + assert _PS_CMDLET_RE.match("ConvertFrom-Json") is not None + assert _PS_CMDLET_RE.match("Get some coffee") is None + assert _PS_CMDLET_RE.match("ls -la") is None + + +class TestGetPlatformShell: + """Test platform-aware shell resolution. + + _get_platform_shell() caches its result per session, so each test must + reset the cache to exercise different branches. + """ + + def _reset_shell_cache(self): + """Reset the cached platform shell before each test.""" + sp_mod._cached_platform_shell = None + + def test_unix_uses_shell_env(self): + """On Unix, _get_platform_shell uses $SHELL.""" + self._reset_shell_cache() + with ( + patch(f"{_SP}.sys") as mock_sys, + patch.dict(os.environ, {"SHELL": "/bin/zsh"}), + ): + mock_sys.platform = "linux" + assert _get_platform_shell() == ["/bin/zsh", "-c"] + + def test_unix_fallback_to_sh(self): + """On Unix without $SHELL, fallback to /bin/sh.""" + self._reset_shell_cache() + with ( + patch(f"{_SP}.sys") as mock_sys, + patch.dict(os.environ, {}, clear=True), + ): + mock_sys.platform = "linux" + assert _get_platform_shell() == ["/bin/sh", "-c"] + + def test_windows_prefers_pwsh(self): + """On Windows, prefer pwsh over powershell.""" + self._reset_shell_cache() + + def _pwsh_only(x): + return "C:\\pwsh.exe" if x == "pwsh" else None + + with ( + patch(f"{_SP}.sys") as mock_sys, + patch("shutil.which", side_effect=_pwsh_only), + ): + mock_sys.platform = "win32" + assert _get_platform_shell() == ["pwsh", "-Command"] + + def test_windows_falls_back_to_powershell(self): + """On Windows without pwsh, fall back to powershell.""" + self._reset_shell_cache() + + def _ps_only(x): + return "C:\\powershell.exe" if x == "powershell" else None + + with ( + patch(f"{_SP}.sys") as mock_sys, + patch("shutil.which", side_effect=_ps_only), + ): + mock_sys.platform = "win32" + assert _get_platform_shell() == [ + "powershell", + "-Command", + ] + + def test_windows_falls_back_to_cmd(self): + """On Windows without pwsh or powershell, fall back to cmd.""" + self._reset_shell_cache() + with ( + patch(f"{_SP}.sys") as mock_sys, + patch("shutil.which", return_value=None), + ): + mock_sys.platform = "win32" + assert _get_platform_shell() == ["cmd", "/c"] + + def test_result_is_cached(self): + """Second call returns cached result without re-querying shutil.which.""" + self._reset_shell_cache() + with ( + patch(f"{_SP}.sys") as mock_sys, + patch.dict(os.environ, {"SHELL": "/bin/bash"}), + ): + mock_sys.platform = "linux" + first = _get_platform_shell() + assert first == ["/bin/bash", "-c"] + + # Second call should return cached value even without the patch + second = _get_platform_shell() + assert second == ["/bin/bash", "-c"] + + class TestExtractCommand: """Test command extraction from pass-through input.""" @@ -105,7 +458,7 @@ class TestFormatBanner: def test_banner_uses_config_color(self): """Banner should use the color from get_banner_color.""" with patch( - "code_puppy.command_line.shell_passthrough.get_banner_color", + f"{_SP}.get_banner_color", return_value="medium_sea_green", ): banner = _format_banner() @@ -119,7 +472,7 @@ def test_banner_name_constant(self): def test_banner_matches_rich_renderer_pattern(self): """Banner format should match [bold white on {color}] pattern.""" with patch( - "code_puppy.command_line.shell_passthrough.get_banner_color", + f"{_SP}.get_banner_color", return_value="red", ): banner = _format_banner() @@ -134,29 +487,33 @@ def _mock_console(self): """Create a mock Rich Console for capturing print calls.""" return MagicMock() - @patch("code_puppy.command_line.shell_passthrough.subprocess.run") - @patch("code_puppy.command_line.shell_passthrough._get_console") - def test_successful_command(self, mock_get_console, mock_run): + @patch(_MOCK_SHELL) + @patch(_MOCK_RUN) + @patch(_MOCK_CONSOLE) + def test_successful_command(self, mock_get_console, mock_run, mock_shell): """Successful commands show a success message.""" console = self._mock_console() mock_get_console.return_value = console mock_run.return_value = MagicMock(returncode=0) + mock_shell.return_value = ["/bin/sh", "-c"] execute_shell_passthrough("!echo hello") mock_run.assert_called_once() call_kwargs = mock_run.call_args - assert call_kwargs[1]["shell"] is True - assert call_kwargs[0][0] == "echo hello" + assert call_kwargs[1]["shell"] is False + # Command is passed as [*shell_args, command] + assert call_kwargs[0][0] == ["/bin/sh", "-c", "echo hello"] # Should have printed banner, context line, and success assert console.print.call_count == 3 last_call = str(console.print.call_args_list[-1]) assert "Done" in last_call - @patch("code_puppy.command_line.shell_passthrough.subprocess.run") - @patch("code_puppy.command_line.shell_passthrough._get_console") - def test_failed_command_shows_exit_code(self, mock_get_console, mock_run): + @patch(_MOCK_SHELL, return_value=["/bin/sh", "-c"]) + @patch(_MOCK_RUN) + @patch(_MOCK_CONSOLE) + def test_failed_command_shows_exit_code(self, mock_get_console, mock_run, _): """Non-zero exit codes show the exit code.""" console = self._mock_console() mock_get_console.return_value = console @@ -167,9 +524,10 @@ def test_failed_command_shows_exit_code(self, mock_get_console, mock_run): last_call = str(console.print.call_args_list[-1]) assert "Exit code 1" in last_call - @patch("code_puppy.command_line.shell_passthrough.subprocess.run") - @patch("code_puppy.command_line.shell_passthrough._get_console") - def test_exit_code_127(self, mock_get_console, mock_run): + @patch(_MOCK_SHELL, return_value=["/bin/sh", "-c"]) + @patch(_MOCK_RUN) + @patch(_MOCK_CONSOLE) + def test_exit_code_127(self, mock_get_console, mock_run, _): """Exit code 127 (command not found) is reported properly.""" console = self._mock_console() mock_get_console.return_value = console @@ -180,9 +538,10 @@ def test_exit_code_127(self, mock_get_console, mock_run): last_call = str(console.print.call_args_list[-1]) assert "127" in last_call - @patch("code_puppy.command_line.shell_passthrough.subprocess.run") - @patch("code_puppy.command_line.shell_passthrough._get_console") - def test_keyboard_interrupt(self, mock_get_console, mock_run): + @patch(_MOCK_SHELL, return_value=["/bin/sh", "-c"]) + @patch(_MOCK_RUN) + @patch(_MOCK_CONSOLE) + def test_keyboard_interrupt(self, mock_get_console, mock_run, _): """Ctrl+C during execution shows interrupted message.""" console = self._mock_console() mock_get_console.return_value = console @@ -193,9 +552,10 @@ def test_keyboard_interrupt(self, mock_get_console, mock_run): last_call = str(console.print.call_args_list[-1]) assert "Interrupted" in last_call - @patch("code_puppy.command_line.shell_passthrough.subprocess.run") - @patch("code_puppy.command_line.shell_passthrough._get_console") - def test_generic_exception(self, mock_get_console, mock_run): + @patch(_MOCK_SHELL, return_value=["/bin/sh", "-c"]) + @patch(_MOCK_RUN) + @patch(_MOCK_CONSOLE) + def test_generic_exception(self, mock_get_console, mock_run, _): """Generic exceptions are caught and reported.""" console = self._mock_console() mock_get_console.return_value = console @@ -206,7 +566,7 @@ def test_generic_exception(self, mock_get_console, mock_run): last_call = str(console.print.call_args_list[-1]) assert "permission denied" in last_call - @patch("code_puppy.command_line.shell_passthrough._get_console") + @patch(_MOCK_CONSOLE) def test_empty_command_after_bang(self, mock_get_console): """An empty command (just spaces after !) shows usage hint.""" console = self._mock_console() @@ -218,9 +578,10 @@ def test_empty_command_after_bang(self, mock_get_console): call_arg = str(console.print.call_args) assert "Usage" in call_arg or "Empty" in call_arg - @patch("code_puppy.command_line.shell_passthrough.subprocess.run") - @patch("code_puppy.command_line.shell_passthrough._get_console") - def test_inherits_stdio(self, mock_get_console, mock_run): + @patch(_MOCK_SHELL, return_value=["/bin/sh", "-c"]) + @patch(_MOCK_RUN) + @patch(_MOCK_CONSOLE) + def test_inherits_stdio(self, mock_get_console, mock_run, _): """Command should inherit stdin/stdout/stderr from parent.""" import sys @@ -235,10 +596,13 @@ def test_inherits_stdio(self, mock_get_console, mock_run): assert call_kwargs["stdout"] is sys.stdout assert call_kwargs["stderr"] is sys.stderr - @patch("code_puppy.command_line.shell_passthrough.subprocess.run") - @patch("code_puppy.command_line.shell_passthrough._get_console") - @patch("code_puppy.command_line.shell_passthrough.os.getcwd", return_value="/tmp") - def test_uses_current_working_directory(self, mock_cwd, mock_get_console, mock_run): + @patch(_MOCK_SHELL, return_value=["/bin/sh", "-c"]) + @patch(_MOCK_RUN) + @patch(_MOCK_CONSOLE) + @patch(f"{_SP}.os.getcwd", return_value="/tmp") + def test_uses_current_working_directory( + self, mock_cwd, mock_get_console, mock_run, _ + ): """Command should run in the current working directory.""" console = self._mock_console() mock_get_console.return_value = console @@ -249,9 +613,10 @@ def test_uses_current_working_directory(self, mock_cwd, mock_get_console, mock_r call_kwargs = mock_run.call_args[1] assert call_kwargs["cwd"] == "/tmp" - @patch("code_puppy.command_line.shell_passthrough.subprocess.run") - @patch("code_puppy.command_line.shell_passthrough._get_console") - def test_banner_shown_before_command(self, mock_get_console, mock_run): + @patch(_MOCK_SHELL, return_value=["/bin/sh", "-c"]) + @patch(_MOCK_RUN) + @patch(_MOCK_CONSOLE) + def test_banner_shown_before_command(self, mock_get_console, mock_run, _): """The banner should display with SHELL PASSTHROUGH label.""" console = self._mock_console() mock_get_console.return_value = console @@ -263,9 +628,10 @@ def test_banner_shown_before_command(self, mock_get_console, mock_run): assert "SHELL PASSTHROUGH" in first_call assert "git status" in first_call - @patch("code_puppy.command_line.shell_passthrough.subprocess.run") - @patch("code_puppy.command_line.shell_passthrough._get_console") - def test_context_hint_shown(self, mock_get_console, mock_run): + @patch(_MOCK_SHELL, return_value=["/bin/sh", "-c"]) + @patch(_MOCK_RUN) + @patch(_MOCK_CONSOLE) + def test_context_hint_shown(self, mock_get_console, mock_run, _): """A context line should clarify this bypasses the AI.""" console = self._mock_console() mock_get_console.return_value = console @@ -276,9 +642,10 @@ def test_context_hint_shown(self, mock_get_console, mock_run): second_call = str(console.print.call_args_list[1]) assert "Bypassing AI" in second_call - @patch("code_puppy.command_line.shell_passthrough.subprocess.run") - @patch("code_puppy.command_line.shell_passthrough._get_console") - def test_rich_markup_escaped_in_command(self, mock_get_console, mock_run): + @patch(_MOCK_SHELL, return_value=["/bin/sh", "-c"]) + @patch(_MOCK_RUN) + @patch(_MOCK_CONSOLE) + def test_rich_markup_escaped_in_command(self, mock_get_console, mock_run, _): """Commands with Rich markup chars should be escaped to prevent injection.""" console = self._mock_console() mock_get_console.return_value = console @@ -286,11 +653,13 @@ def test_rich_markup_escaped_in_command(self, mock_get_console, mock_run): execute_shell_passthrough("!echo [bold red]oops[/bold red]") - assert mock_run.call_args[0][0] == "echo [bold red]oops[/bold red]" + # Command is the last element of [*shell_args, command] + assert mock_run.call_args[0][0][-1] == "echo [bold red]oops[/bold red]" - @patch("code_puppy.command_line.shell_passthrough.subprocess.run") - @patch("code_puppy.command_line.shell_passthrough._get_console") - def test_rich_markup_escaped_in_error(self, mock_get_console, mock_run): + @patch(_MOCK_SHELL, return_value=["/bin/sh", "-c"]) + @patch(_MOCK_RUN) + @patch(_MOCK_CONSOLE) + def test_rich_markup_escaped_in_error(self, mock_get_console, mock_run, _): """Error messages with Rich markup chars should be escaped.""" console = self._mock_console() mock_get_console.return_value = console @@ -313,15 +682,16 @@ class TestInitialCommandPassthrough: entry point that must honour the same passthrough guarantee. """ - @patch("code_puppy.command_line.shell_passthrough._get_console") - @patch("code_puppy.command_line.shell_passthrough.subprocess.run") + @patch(_MOCK_SHELL, return_value=["/bin/sh", "-c"]) + @patch(_MOCK_CONSOLE) + @patch(_MOCK_RUN) def test_interactive_mode_initial_command_calls_passthrough( - self, mock_run, mock_get_console + self, mock_run, mock_get_console, _ ): - """interactive_mode with initial_command='!ls -la' should execute shell, not agent. + """interactive_mode with initial_command='!ls -la' runs shell. - The passthrough check fires before any agent code is reached, so - run_prompt_with_attachments must never be invoked. + The passthrough check fires before any agent code is reached, + so run_prompt_with_attachments must never be invoked. """ from code_puppy.cli_runner import interactive_mode @@ -354,7 +724,9 @@ def test_interactive_mode_initial_command_calls_passthrough( new_callable=AsyncMock, ) as mock_run_prompt, patch( - "code_puppy.command_line.prompt_toolkit_completion.get_input_with_combined_completion", + "code_puppy.command_line" + ".prompt_toolkit_completion" + ".get_input_with_combined_completion", side_effect=EOFError, ), ): @@ -362,13 +734,15 @@ def test_interactive_mode_initial_command_calls_passthrough( # Shell command should have been executed via subprocess mock_run.assert_called_once() - assert mock_run.call_args[0][0] == "ls -la" + # Command is last element: ["/bin/sh", "-c", "ls -la"] + assert mock_run.call_args[0][0][-1] == "ls -la" # Agent processing must NOT have been triggered mock_run_prompt.assert_not_called() - @patch("code_puppy.command_line.shell_passthrough._get_console") - @patch("code_puppy.command_line.shell_passthrough.subprocess.run") - def test_execute_single_prompt_calls_passthrough(self, mock_run, mock_console): + @patch(_MOCK_SHELL, return_value=["/bin/sh", "-c"]) + @patch(_MOCK_CONSOLE) + @patch(_MOCK_RUN) + def test_execute_single_prompt_calls_passthrough(self, mock_run, mock_console, _): """execute_single_prompt with '!ls' should run shell, not the agent.""" from code_puppy.cli_runner import execute_single_prompt @@ -387,13 +761,14 @@ def test_execute_single_prompt_calls_passthrough(self, mock_run, mock_console): # Shell command should have been executed mock_run.assert_called_once() - assert mock_run.call_args[0][0] == "ls -la" + # Command is last element: ["/bin/sh", "-c", "ls -la"] + assert mock_run.call_args[0][0][-1] == "ls -la" # Agent should NOT have been called mock_agent.assert_not_called() mock_run_prompt.assert_not_called() - @patch("code_puppy.command_line.shell_passthrough._get_console") - @patch("code_puppy.command_line.shell_passthrough.subprocess.run") + @patch(_MOCK_CONSOLE) + @patch(_MOCK_RUN) def test_execute_single_prompt_normal_prompt_skips_passthrough( self, mock_run, mock_console ):