From 58408b794ae73b789b6e413fde4f49bd02d600c2 Mon Sep 17 00:00:00 2001 From: Rachit Chaudhary Date: Fri, 3 Apr 2026 12:34:49 +0530 Subject: [PATCH 1/4] feat: auto-detect known CLI commands to bypass AI agent (zero tokens) --- code_puppy/cli_runner.py | 18 ++ code_puppy/command_line/shell_passthrough.py | 200 +++++++++++++++++++ tests/test_shell_passthrough.py | 76 +++++++ 3 files changed, 294 insertions(+) diff --git a/code_puppy/cli_runner.py b/code_puppy/cli_runner.py index 458f51dec..bc32b8a27 100644 --- a/code_puppy/cli_runner.py +++ b/code_puppy/cli_runner.py @@ -395,12 +395,17 @@ 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_shell_passthrough, ) if is_shell_passthrough(initial_command): execute_shell_passthrough(initial_command) initial_command = None + elif is_known_cli_command(initial_command): + # Auto-detect known CLI commands — bypass AI agent, zero tokens + execute_shell_passthrough(f"!{initial_command.strip()}") + initial_command = None # Initialize the runtime agent manager if initial_command: @@ -597,6 +602,7 @@ 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_shell_passthrough, ) @@ -604,6 +610,12 @@ 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 route them directly to the shell — zero tokens consumed. + if is_known_cli_command(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 +1026,7 @@ 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_shell_passthrough, ) @@ -1021,6 +1034,11 @@ async def execute_single_prompt(prompt: str, message_renderer) -> None: execute_shell_passthrough(prompt) return + # Auto-detect known CLI commands — bypass AI agent, zero tokens consumed + if is_known_cli_command(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..2af2d24d6 100644 --- a/code_puppy/command_line/shell_passthrough.py +++ b/code_puppy/command_line/shell_passthrough.py @@ -3,13 +3,21 @@ 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 subprocess import sys import time @@ -25,6 +33,198 @@ # 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", + } +) + +# 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. + + This lets users type ``ls -la`` or ``git status`` directly — without the + ``!`` prefix — and have Code Puppy route the input to the shell instead of + the AI agent (zero tokens consumed). + + The check is intentionally conservative: + * Only the very first token is tested against ``KNOWN_CLI_COMMANDS``. + * Input that already starts with ``!`` or ``/`` is excluded (handled + elsewhere). + * Single-word inputs that match (e.g. ``pwd``) are accepted. + + Args: + task: Raw user input string. + + Returns: + True if the first word of *task* is a known CLI command. + """ + stripped = task.strip() + # Already handled by other code paths + 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() + return first_word in KNOWN_CLI_COMMANDS + def _get_console() -> Console: """Get a Rich console for direct output. diff --git a/tests/test_shell_passthrough.py b/tests/test_shell_passthrough.py index 97486a111..2f9bdc4e6 100644 --- a/tests/test_shell_passthrough.py +++ b/tests/test_shell_passthrough.py @@ -9,10 +9,12 @@ from code_puppy.command_line.shell_passthrough import ( _BANNER_NAME, + KNOWN_CLI_COMMANDS, SHELL_PASSTHROUGH_PREFIX, _format_banner, execute_shell_passthrough, extract_command, + is_known_cli_command, is_shell_passthrough, ) @@ -69,6 +71,80 @@ 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). + """ + + # ── Positive cases ──────────────────────────────────────────────────── + + def test_ls_alone(self): + """Bare `ls` is a known CLI command.""" + assert is_known_cli_command("ls") is True + + def test_ls_with_flags(self): + """`ls -la` is auto-detected.""" + assert is_known_cli_command("ls -la") is True + + def test_ls_pipe_grep(self): + """`ls | grep test` is auto-detected via the leading `ls`.""" + assert is_known_cli_command("ls | grep test") is True + + def test_git_status(self): + """`git status` is auto-detected.""" + assert is_known_cli_command("git status") is True + + def test_grep_pattern(self): + """`grep -r foo .` is auto-detected.""" + assert is_known_cli_command("grep -r foo .") is True + + def test_pwd(self): + """Single-word known command is accepted.""" + assert is_known_cli_command("pwd") is True + + def test_leading_whitespace(self): + """Leading whitespace before a known command is tolerated.""" + assert is_known_cli_command(" ls -la") is True + + def test_case_insensitive_first_word(self): + """First-word check is case-insensitive (LS → 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 + + # ── 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 + + class TestExtractCommand: """Test command extraction from pass-through input.""" From c28de6c60df1725f7f7fc2d676344771f0214181 Mon Sep 17 00:00:00 2001 From: Rachit Chaudhary Date: Tue, 7 Apr 2026 10:14:18 +0530 Subject: [PATCH 2/4] harden is_known_cli_command with PATH check and ambiguity guard tests --- code_puppy/command_line/shell_passthrough.py | 74 ++++++++++--- tests/test_shell_passthrough.py | 104 ++++++++++++++++--- 2 files changed, 153 insertions(+), 25 deletions(-) diff --git a/code_puppy/command_line/shell_passthrough.py b/code_puppy/command_line/shell_passthrough.py index 2af2d24d6..119fc836f 100644 --- a/code_puppy/command_line/shell_passthrough.py +++ b/code_puppy/command_line/shell_passthrough.py @@ -18,6 +18,7 @@ import os import re +import shutil import subprocess import sys import time @@ -190,31 +191,62 @@ } ) +# 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 filesystem paths +_SHELL_INTENT_RE = re.compile(r"[|&;<>`$()]|(?:^|\s)-\w|(?:^|\s)(\.{1,2}/|/)") + # 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. + """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): - This lets users type ``ls -la`` or ``git status`` directly — without the - ``!`` prefix — and have Code Puppy route the input to the shell instead of - the AI agent (zero tokens consumed). + 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.). - The check is intentionally conservative: - * Only the very first token is tested against ``KNOWN_CLI_COMMANDS``. - * Input that already starts with ``!`` or ``/`` is excluded (handled - elsewhere). - * Single-word inputs that match (e.g. ``pwd``) are accepted. + Single-word inputs (``pwd``, ``git``) skip stage 3 — they're unambiguous. Args: task: Raw user input string. Returns: - True if the first word of *task* is a known CLI command. + True if the input should be routed directly to the shell. """ stripped = task.strip() - # Already handled by other code paths + + # Already handled by other code paths — skip early. if stripped.startswith(SHELL_PASSTHROUGH_PREFIX) or stripped.startswith("/"): return False @@ -223,7 +255,25 @@ def is_known_cli_command(task: str) -> bool: return False first_word = match.group(1).lower() - return first_word in KNOWN_CLI_COMMANDS + + # 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 def _get_console() -> Console: diff --git a/tests/test_shell_passthrough.py b/tests/test_shell_passthrough.py index 2f9bdc4e6..887bc80f0 100644 --- a/tests/test_shell_passthrough.py +++ b/tests/test_shell_passthrough.py @@ -8,7 +8,9 @@ from unittest.mock import AsyncMock, MagicMock, patch from code_puppy.command_line.shell_passthrough import ( + _AMBIGUOUS_CLI_COMMANDS, _BANNER_NAME, + _SHELL_INTENT_RE, KNOWN_CLI_COMMANDS, SHELL_PASSTHROUGH_PREFIX, _format_banner, @@ -77,47 +79,75 @@ class TestIsKnownCliCommand: 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.""" - assert is_known_cli_command("ls") is True + 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.""" - assert is_known_cli_command("ls -la") is True + 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 via the leading `ls`.""" - assert is_known_cli_command("ls | grep test") is True + """`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.""" - assert is_known_cli_command("git status") is True + with patch("shutil.which", return_value="/usr/bin/git"): + assert is_known_cli_command("git status") is True - def test_grep_pattern(self): - """`grep -r foo .` is auto-detected.""" - assert is_known_cli_command("grep -r foo .") 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(self): - """Single-word known command is accepted.""" - assert is_known_cli_command("pwd") 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.""" - assert is_known_cli_command(" ls -la") is True + 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).""" - assert is_known_cli_command("LS -la") is True + 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): @@ -144,6 +174,54 @@ 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 + class TestExtractCommand: """Test command extraction from pass-through input.""" From a2cd3950fd754df489cfdbb2b32118f87741a923 Mon Sep 17 00:00:00 2001 From: Rachit Chaudhary Date: Fri, 10 Apr 2026 10:43:04 +0530 Subject: [PATCH 3/4] Cross-platform shell support (PowerShell + Bash) with session caching --- code_puppy/cli_runner.py | 18 +- code_puppy/command_line/shell_passthrough.py | 137 ++++++++++- tests/test_shell_passthrough.py | 239 +++++++++++++++++-- 3 files changed, 364 insertions(+), 30 deletions(-) diff --git a/code_puppy/cli_runner.py b/code_puppy/cli_runner.py index bc32b8a27..37e78d4ad 100644 --- a/code_puppy/cli_runner.py +++ b/code_puppy/cli_runner.py @@ -396,14 +396,17 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non 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): - # Auto-detect known CLI commands — bypass AI agent, zero tokens + 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 @@ -603,6 +606,7 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non from code_puppy.command_line.shell_passthrough import ( execute_shell_passthrough, is_known_cli_command, + is_powershell_cmdlet, is_shell_passthrough, ) @@ -611,8 +615,9 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non continue # Auto-detect known CLI commands (e.g. `ls`, `git status`, `grep …`) - # and route them directly to the shell — zero tokens consumed. - if is_known_cli_command(task): + # 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 @@ -1027,6 +1032,7 @@ async def execute_single_prompt(prompt: str, message_renderer) -> None: from code_puppy.command_line.shell_passthrough import ( execute_shell_passthrough, is_known_cli_command, + is_powershell_cmdlet, is_shell_passthrough, ) @@ -1034,8 +1040,8 @@ async def execute_single_prompt(prompt: str, message_renderer) -> None: execute_shell_passthrough(prompt) return - # Auto-detect known CLI commands — bypass AI agent, zero tokens consumed - if is_known_cli_command(prompt): + # 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 diff --git a/code_puppy/command_line/shell_passthrough.py b/code_puppy/command_line/shell_passthrough.py index 119fc836f..b07d468ed 100644 --- a/code_puppy/command_line/shell_passthrough.py +++ b/code_puppy/command_line/shell_passthrough.py @@ -211,10 +211,17 @@ ) # 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 filesystem paths -_SHELL_INTENT_RE = re.compile(r"[|&;<>`$()]|(?:^|\s)-\w|(?:^|\s)(\.{1,2}/|/)") +# | & ; < > ` $ ( ) — 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+)") @@ -276,6 +283,123 @@ def is_known_cli_command(task: str) -> bool: 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. @@ -363,9 +487,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 887bc80f0..fbaae55ce 100644 --- a/tests/test_shell_passthrough.py +++ b/tests/test_shell_passthrough.py @@ -5,18 +5,23 @@ """ import asyncio +import os from unittest.mock import AsyncMock, MagicMock, patch +import code_puppy.command_line.shell_passthrough as shell_passthrough_module 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, ) @@ -222,6 +227,186 @@ 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.""" + shell_passthrough_module._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.""" + shell_passthrough_module._cached_platform_shell = None + + def test_unix_uses_shell_env(self): + """On Unix, _get_platform_shell uses $SHELL.""" + self._reset_shell_cache() + with ( + patch("code_puppy.command_line.shell_passthrough.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("code_puppy.command_line.shell_passthrough.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() + with ( + patch("code_puppy.command_line.shell_passthrough.sys") as mock_sys, + patch("shutil.which", side_effect=lambda x: "C:\\pwsh.exe" if x == "pwsh" else None), + ): + 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() + with ( + patch("code_puppy.command_line.shell_passthrough.sys") as mock_sys, + patch( + "shutil.which", + side_effect=lambda x: "C:\\powershell.exe" if x == "powershell" else None, + ), + ): + 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("code_puppy.command_line.shell_passthrough.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("code_puppy.command_line.shell_passthrough.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.""" @@ -288,29 +473,33 @@ def _mock_console(self): """Create a mock Rich Console for capturing print calls.""" return MagicMock() + @patch("code_puppy.command_line.shell_passthrough._get_platform_shell") @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): + 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._get_platform_shell", return_value=["/bin/sh", "-c"]) @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): + 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 @@ -321,9 +510,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._get_platform_shell", return_value=["/bin/sh", "-c"]) @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): + 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 @@ -334,9 +524,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._get_platform_shell", return_value=["/bin/sh", "-c"]) @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): + 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 @@ -347,9 +538,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._get_platform_shell", return_value=["/bin/sh", "-c"]) @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): + 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 @@ -372,9 +564,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._get_platform_shell", return_value=["/bin/sh", "-c"]) @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): + def test_inherits_stdio(self, mock_get_console, mock_run, _): """Command should inherit stdin/stdout/stderr from parent.""" import sys @@ -389,10 +582,11 @@ 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._get_platform_shell", return_value=["/bin/sh", "-c"]) @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): + 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 @@ -403,9 +597,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._get_platform_shell", return_value=["/bin/sh", "-c"]) @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): + 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 @@ -417,9 +612,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._get_platform_shell", return_value=["/bin/sh", "-c"]) @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): + 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 @@ -430,9 +626,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._get_platform_shell", return_value=["/bin/sh", "-c"]) @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): + 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 @@ -440,11 +637,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._get_platform_shell", return_value=["/bin/sh", "-c"]) @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): + 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 @@ -467,10 +666,11 @@ class TestInitialCommandPassthrough: entry point that must honour the same passthrough guarantee. """ + @patch("code_puppy.command_line.shell_passthrough._get_platform_shell", return_value=["/bin/sh", "-c"]) @patch("code_puppy.command_line.shell_passthrough._get_console") @patch("code_puppy.command_line.shell_passthrough.subprocess.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. @@ -516,13 +716,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_platform_shell", return_value=["/bin/sh", "-c"]) @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): + 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 @@ -541,7 +743,8 @@ 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() From 02ba3b56b8b12b1dbbbd9b4f658ef92ef1c1739b Mon Sep 17 00:00:00 2001 From: Rachit Chaudhary Date: Mon, 13 Apr 2026 12:50:39 +0530 Subject: [PATCH 4/4] Format fix --- code_puppy/command_line/shell_passthrough.py | 3 +- tests/test_shell_passthrough.py | 148 +++++++++++-------- 2 files changed, 84 insertions(+), 67 deletions(-) diff --git a/code_puppy/command_line/shell_passthrough.py b/code_puppy/command_line/shell_passthrough.py index b07d468ed..496d04cbd 100644 --- a/code_puppy/command_line/shell_passthrough.py +++ b/code_puppy/command_line/shell_passthrough.py @@ -317,8 +317,7 @@ def _is_powershell_on_path() -> bool: global _powershell_available if _powershell_available is None: _powershell_available = ( - shutil.which("pwsh") is not None - or shutil.which("powershell") is not None + shutil.which("pwsh") is not None or shutil.which("powershell") is not None ) return _powershell_available diff --git a/tests/test_shell_passthrough.py b/tests/test_shell_passthrough.py index fbaae55ce..54bad971b 100644 --- a/tests/test_shell_passthrough.py +++ b/tests/test_shell_passthrough.py @@ -8,7 +8,7 @@ import os from unittest.mock import AsyncMock, MagicMock, patch -import code_puppy.command_line.shell_passthrough as shell_passthrough_module +import code_puppy.command_line.shell_passthrough as sp_mod from code_puppy.command_line.shell_passthrough import ( _AMBIGUOUS_CLI_COMMANDS, _BANNER_NAME, @@ -25,6 +25,12 @@ 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.""" @@ -252,7 +258,7 @@ class TestIsPowershellCmdlet: def _reset_ps_cache(self): """Reset the cached PowerShell availability before each test.""" - shell_passthrough_module._powershell_available = None + sp_mod._powershell_available = None # ── Positive cases (pwsh on PATH) ───────────────────────────────────── @@ -337,13 +343,13 @@ class TestGetPlatformShell: def _reset_shell_cache(self): """Reset the cached platform shell before each test.""" - shell_passthrough_module._cached_platform_shell = None + 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("code_puppy.command_line.shell_passthrough.sys") as mock_sys, + patch(f"{_SP}.sys") as mock_sys, patch.dict(os.environ, {"SHELL": "/bin/zsh"}), ): mock_sys.platform = "linux" @@ -353,7 +359,7 @@ def test_unix_fallback_to_sh(self): """On Unix without $SHELL, fallback to /bin/sh.""" self._reset_shell_cache() with ( - patch("code_puppy.command_line.shell_passthrough.sys") as mock_sys, + patch(f"{_SP}.sys") as mock_sys, patch.dict(os.environ, {}, clear=True), ): mock_sys.platform = "linux" @@ -362,9 +368,13 @@ def test_unix_fallback_to_sh(self): 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("code_puppy.command_line.shell_passthrough.sys") as mock_sys, - patch("shutil.which", side_effect=lambda x: "C:\\pwsh.exe" if x == "pwsh" else None), + patch(f"{_SP}.sys") as mock_sys, + patch("shutil.which", side_effect=_pwsh_only), ): mock_sys.platform = "win32" assert _get_platform_shell() == ["pwsh", "-Command"] @@ -372,21 +382,25 @@ def test_windows_prefers_pwsh(self): 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("code_puppy.command_line.shell_passthrough.sys") as mock_sys, - patch( - "shutil.which", - side_effect=lambda x: "C:\\powershell.exe" if x == "powershell" else None, - ), + patch(f"{_SP}.sys") as mock_sys, + patch("shutil.which", side_effect=_ps_only), ): mock_sys.platform = "win32" - assert _get_platform_shell() == ["powershell", "-Command"] + 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("code_puppy.command_line.shell_passthrough.sys") as mock_sys, + patch(f"{_SP}.sys") as mock_sys, patch("shutil.which", return_value=None), ): mock_sys.platform = "win32" @@ -396,7 +410,7 @@ def test_result_is_cached(self): """Second call returns cached result without re-querying shutil.which.""" self._reset_shell_cache() with ( - patch("code_puppy.command_line.shell_passthrough.sys") as mock_sys, + patch(f"{_SP}.sys") as mock_sys, patch.dict(os.environ, {"SHELL": "/bin/bash"}), ): mock_sys.platform = "linux" @@ -444,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() @@ -458,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() @@ -473,9 +487,9 @@ def _mock_console(self): """Create a mock Rich Console for capturing print calls.""" return MagicMock() - @patch("code_puppy.command_line.shell_passthrough._get_platform_shell") - @patch("code_puppy.command_line.shell_passthrough.subprocess.run") - @patch("code_puppy.command_line.shell_passthrough._get_console") + @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() @@ -496,9 +510,9 @@ def test_successful_command(self, mock_get_console, mock_run, mock_shell): last_call = str(console.print.call_args_list[-1]) assert "Done" in last_call - @patch("code_puppy.command_line.shell_passthrough._get_platform_shell", return_value=["/bin/sh", "-c"]) - @patch("code_puppy.command_line.shell_passthrough.subprocess.run") - @patch("code_puppy.command_line.shell_passthrough._get_console") + @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() @@ -510,9 +524,9 @@ 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._get_platform_shell", return_value=["/bin/sh", "-c"]) - @patch("code_puppy.command_line.shell_passthrough.subprocess.run") - @patch("code_puppy.command_line.shell_passthrough._get_console") + @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() @@ -524,9 +538,9 @@ 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._get_platform_shell", return_value=["/bin/sh", "-c"]) - @patch("code_puppy.command_line.shell_passthrough.subprocess.run") - @patch("code_puppy.command_line.shell_passthrough._get_console") + @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() @@ -538,9 +552,9 @@ 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._get_platform_shell", return_value=["/bin/sh", "-c"]) - @patch("code_puppy.command_line.shell_passthrough.subprocess.run") - @patch("code_puppy.command_line.shell_passthrough._get_console") + @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() @@ -552,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() @@ -564,9 +578,9 @@ 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._get_platform_shell", return_value=["/bin/sh", "-c"]) - @patch("code_puppy.command_line.shell_passthrough.subprocess.run") - @patch("code_puppy.command_line.shell_passthrough._get_console") + @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 @@ -582,11 +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._get_platform_shell", return_value=["/bin/sh", "-c"]) - @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 @@ -597,9 +613,9 @@ 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._get_platform_shell", return_value=["/bin/sh", "-c"]) - @patch("code_puppy.command_line.shell_passthrough.subprocess.run") - @patch("code_puppy.command_line.shell_passthrough._get_console") + @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() @@ -612,9 +628,9 @@ 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._get_platform_shell", return_value=["/bin/sh", "-c"]) - @patch("code_puppy.command_line.shell_passthrough.subprocess.run") - @patch("code_puppy.command_line.shell_passthrough._get_console") + @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() @@ -626,9 +642,9 @@ 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._get_platform_shell", return_value=["/bin/sh", "-c"]) - @patch("code_puppy.command_line.shell_passthrough.subprocess.run") - @patch("code_puppy.command_line.shell_passthrough._get_console") + @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() @@ -640,9 +656,9 @@ def test_rich_markup_escaped_in_command(self, mock_get_console, mock_run, _): # 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._get_platform_shell", return_value=["/bin/sh", "-c"]) - @patch("code_puppy.command_line.shell_passthrough.subprocess.run") - @patch("code_puppy.command_line.shell_passthrough._get_console") + @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() @@ -666,16 +682,16 @@ class TestInitialCommandPassthrough: entry point that must honour the same passthrough guarantee. """ - @patch("code_puppy.command_line.shell_passthrough._get_platform_shell", return_value=["/bin/sh", "-c"]) - @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, _ ): - """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 @@ -708,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, ), ): @@ -721,9 +739,9 @@ def test_interactive_mode_initial_command_calls_passthrough( # Agent processing must NOT have been triggered mock_run_prompt.assert_not_called() - @patch("code_puppy.command_line.shell_passthrough._get_platform_shell", return_value=["/bin/sh", "-c"]) - @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_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 @@ -749,8 +767,8 @@ def test_execute_single_prompt_calls_passthrough(self, mock_run, mock_console, _ 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 ):