Skip to content

Commit e0afc30

Browse files
committed
fix: use shutil.which to get full npx path for Windows compatibility
The original PR attempted to fix Windows compatibility by using shell=True with shlex.quote(), but this approach caused the command to hang because shlex.quote() is designed for Unix shells, not Windows cmd.exe. The correct solution is simpler and more robust: - Use shutil.which('npx') to get the full executable path - Use the full path in a list with shell=False - Modern Python handles .cmd files correctly on Windows with full paths This approach: - Works cross-platform (Windows, macOS, Linux) - Maintains security by keeping shell=False - Avoids complex platform-specific quoting logic - Prevents the hanging issue caused by incorrect shell escaping Tested locally and the CLI now responds correctly without hanging.
1 parent cf297d4 commit e0afc30

File tree

3 files changed

+15
-274
lines changed

3 files changed

+15
-274
lines changed

src/promptfoo/cli.py

Lines changed: 15 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,10 @@
66
"""
77

88
import os
9-
import platform
109
import shutil
1110
import subprocess
1211
import sys
13-
from typing import NoReturn, Union
12+
from typing import NoReturn
1413

1514

1615
def check_node_installed() -> bool:
@@ -40,72 +39,39 @@ def main() -> NoReturn:
4039
"""
4140
Main entry point for the promptfoo CLI wrapper.
4241
43-
Tries to use globally installed promptfoo first, falls back to npx.
42+
Executes `npx promptfoo@latest <args>` and passes through all arguments.
4443
Exits with the same exit code as the underlying promptfoo command.
4544
"""
4645
# Check for Node.js installation
4746
if not check_node_installed():
4847
print_installation_help()
4948
sys.exit(1)
5049

51-
is_windows = platform.system() == "Windows"
52-
53-
# Try to find a globally installed promptfoo first
54-
promptfoo_path = shutil.which("promptfoo")
55-
56-
# Build the command
57-
# On Windows: use shell=True for .cmd file compatibility
58-
# On Unix: use shell=False for security
59-
cmd: Union[str, list[str]]
60-
use_shell: bool
61-
62-
if is_windows:
63-
# Windows requires shell=True or cmd.exe to run .cmd files
64-
# Use subprocess list form which is safer than string form
65-
import shlex
66-
67-
if promptfoo_path:
68-
args = ["promptfoo"] + sys.argv[1:]
69-
else:
70-
# Fall back to npx
71-
if not shutil.which("npx"):
72-
print("ERROR: promptfoo is not installed and npx is not available.", file=sys.stderr)
73-
print("Please install promptfoo globally: npm install -g promptfoo", file=sys.stderr)
74-
sys.exit(1)
75-
args = ["npx", "--yes", "promptfoo@latest"] + sys.argv[1:]
76-
77-
# On Windows, use shell=True with properly quoted arguments
78-
cmd = " ".join(shlex.quote(arg) for arg in args)
79-
use_shell = True
80-
else:
81-
# Unix: use shell=False for security
82-
if promptfoo_path:
83-
cmd = [promptfoo_path] + sys.argv[1:]
84-
else:
85-
npx_path = shutil.which("npx")
86-
if not npx_path:
87-
print("ERROR: promptfoo is not installed and npx is not available.", file=sys.stderr)
88-
print("Please install promptfoo globally: npm install -g promptfoo", file=sys.stderr)
89-
sys.exit(1)
90-
cmd = [npx_path, "--yes", "promptfoo@latest"] + sys.argv[1:]
91-
use_shell = False
50+
# Get the full path to npx
51+
# This is crucial for Windows where npx is actually npx.cmd
52+
# Using the full path works cross-platform with shell=False
53+
npx_path = shutil.which("npx")
54+
if not npx_path:
55+
print("ERROR: npx is not available. Please ensure Node.js is properly installed.", file=sys.stderr)
56+
sys.exit(1)
57+
58+
# Build the command: npx promptfoo@latest <args>
59+
# Use the full path to npx and keep shell=False for security and reliability
60+
cmd = [npx_path, "--yes", "promptfoo@latest"] + sys.argv[1:]
9261

9362
try:
94-
# Execute the command
63+
# Execute the command and inherit stdio
9564
result = subprocess.run(
9665
cmd,
9766
env=os.environ.copy(),
9867
check=False, # Don't raise exception on non-zero exit
99-
shell=use_shell,
68+
shell=False, # Keep shell=False for security - works on all platforms with full path
10069
)
10170
sys.exit(result.returncode)
10271
except KeyboardInterrupt:
10372
# Handle Ctrl+C gracefully
10473
print("\nInterrupted by user", file=sys.stderr)
10574
sys.exit(130)
106-
except subprocess.TimeoutExpired:
107-
print("ERROR: Command timed out after waiting too long", file=sys.stderr)
108-
sys.exit(1)
10975
except Exception as e:
11076
print(f"ERROR: Failed to execute promptfoo: {e}", file=sys.stderr)
11177
sys.exit(1)

tests/__init__.py

Lines changed: 0 additions & 1 deletion
This file was deleted.

tests/test_cli.py

Lines changed: 0 additions & 224 deletions
This file was deleted.

0 commit comments

Comments
 (0)