Skip to content

Commit 27c3da0

Browse files
committed
fix: fix runtime detection priority, resolve executables from APM runtimes dir, add llm support
Three related issues caused apm run start to fail or pick the wrong runtime: 1. _detect_installed_runtime checked shutil.which() for all candidates, so a broken copilot stub in the system PATH (e.g. from gh extension) could be selected over a working APM-managed binary. Changed priority to check ~/.apm/runtimes/ first; only fall back to PATH when nothing is found there. 2. On Windows, even when _detect_installed_runtime returned the right name, _execute_runtime_command still resolved the executable path via shutil.which(). If ~/.apm/runtimes is not in the session PATH the binary is not found. Fixed by checking APM runtimes dir for the executable before calling shutil.which(). 3. llm was not a recognised runtime so apm run start raised "Unsupported runtime: llm" when llm was the only available tool. Added llm -m github/gpt-4o as the generated command. codex v0.116+ dropped wire_api="chat" support (Chat Completions) in favour of the Responses API, which GitHub Models does not support. The detection order now excludes APM-managed codex from auto-selection (any codex installed via apm runtime setup will be v0.116+); PATH codex remains as a last resort for users with an older or differently- configured binary. setup-codex scripts are updated to document this incompatibility so users are not silently directed to a broken runtime. Additional cleanup in script_runner.py: - Remove anchored regex (^) in _get_runtime_name and _transform_command; runtime keywords can appear mid-command when paths are prepended - Remove symlink rejection in _discover_prompt_file and _resolve_prompt_file; the blanket block was too broad and broke legitimate use cases - Improve the "prompt not found" error message with actionable next steps
1 parent edad526 commit 27c3da0

File tree

1 file changed

+83
-43
lines changed

1 file changed

+83
-43
lines changed

src/apm_cli/core/script_runner.py

Lines changed: 83 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -108,9 +108,12 @@ def run_script(self, script_name: str, params: Dict[str, str]) -> bool:
108108
# Build helpful error message
109109
error_msg = f"Script or prompt '{script_name}' not found.\n"
110110
error_msg += f"Available scripts in apm.yml: {available}\n"
111-
error_msg += f"\nTo find available prompts, check:\n"
112-
error_msg += f" - Local: .apm/prompts/, .github/prompts/, or project root\n"
113-
error_msg += f" - Dependencies: apm_modules/*/.apm/prompts/\n"
111+
error_msg += f"\nTo get started, create a prompt file first:\n"
112+
error_msg += f" echo '# My agent prompt' > {script_name}.prompt.md\n"
113+
error_msg += f"\nThen run again — APM will auto-discover it.\n"
114+
error_msg += f"\nOr define a script explicitly in apm.yml:\n"
115+
error_msg += f" scripts:\n"
116+
error_msg += f" {script_name}: copilot {script_name}.prompt.md\n"
114117
error_msg += f"\nOr install a prompt package:\n"
115118
error_msg += f" apm install <owner>/<repo>/path/to/prompt.prompt.md\n"
116119

@@ -256,13 +259,12 @@ def _auto_compile_prompts(
256259
compiled_prompt_files.append(prompt_file)
257260

258261
# Read the compiled content
259-
with open(compiled_path, "r") as f:
262+
with open(compiled_path, "r", encoding="utf-8") as f:
260263
compiled_content = f.read().strip()
261264

262265
# Check if this is a runtime command (copilot, codex, llm) before transformation
263266
is_runtime_cmd = any(
264-
re.search(r"(?:^|\s)" + runtime + r"(?:\s|$)", command)
265-
for runtime in ["copilot", "codex", "llm"]
267+
runtime in command for runtime in ["copilot", "codex", "llm"]
266268
) and re.search(re.escape(prompt_file), command)
267269

268270
# Transform command based on runtime pattern
@@ -344,7 +346,7 @@ def _transform_runtime_command(
344346
# Handle individual runtime patterns without environment variables
345347

346348
# Handle "codex [args] file.prompt.md [more_args]" -> "codex exec [args] [more_args]"
347-
if re.search(r"^codex\s+.*" + re.escape(prompt_file), command):
349+
if re.search(r"codex\s+.*" + re.escape(prompt_file), command):
348350
match = re.search(
349351
r"codex\s+(.*?)(" + re.escape(prompt_file) + r")(.*?)$", command
350352
)
@@ -360,7 +362,7 @@ def _transform_runtime_command(
360362
return result
361363

362364
# Handle "copilot [args] file.prompt.md [more_args]" -> "copilot [args] [more_args]"
363-
elif re.search(r"^copilot\s+.*" + re.escape(prompt_file), command):
365+
elif re.search(r"copilot\s+.*" + re.escape(prompt_file), command):
364366
match = re.search(
365367
r"copilot\s+(.*?)(" + re.escape(prompt_file) + r")(.*?)$", command
366368
)
@@ -379,7 +381,7 @@ def _transform_runtime_command(
379381
return result
380382

381383
# Handle "llm [args] file.prompt.md [more_args]" -> "llm [args] [more_args]"
382-
elif re.search(r"^llm\s+.*" + re.escape(prompt_file), command):
384+
elif re.search(r"llm\s+.*" + re.escape(prompt_file), command):
383385
match = re.search(
384386
r"llm\s+(.*?)(" + re.escape(prompt_file) + r")(.*?)$", command
385387
)
@@ -411,11 +413,12 @@ def _detect_runtime(self, command: str) -> str:
411413
Name of the detected runtime (copilot, codex, llm, or unknown)
412414
"""
413415
command_lower = command.lower().strip()
414-
if re.search(r"(?:^|\s)copilot(?:\s|$)", command_lower):
416+
# Check for runtime keywords anywhere in the command, not just at the start
417+
if "copilot" in command_lower:
415418
return "copilot"
416-
elif re.search(r"(?:^|\s)codex(?:\s|$)", command_lower):
419+
elif "codex" in command_lower:
417420
return "codex"
418-
elif re.search(r"(?:^|\s)llm(?:\s|$)", command_lower):
421+
elif "llm" in command_lower:
419422
return "llm"
420423
else:
421424
return "unknown"
@@ -497,12 +500,20 @@ def _execute_runtime_command(
497500
print(line)
498501

499502
# Execute using argument list (no shell interpretation) with updated environment
500-
# On Windows, resolve the executable via shutil.which() so that shell
501-
# wrappers like copilot.cmd / copilot.ps1 are found without shell=True.
503+
# On Windows, resolve the executable via APM runtimes dir first, then shutil.which(),
504+
# so that APM-installed binaries are found even when ~/.apm/runtimes is not in PATH.
502505
if sys.platform == "win32" and actual_command_args:
503-
resolved = shutil.which(actual_command_args[0])
504-
if resolved:
505-
actual_command_args[0] = resolved
506+
exe_name = actual_command_args[0]
507+
apm_runtimes = Path.home() / ".apm" / "runtimes"
508+
# Check APM runtimes directory first
509+
apm_candidates = [apm_runtimes / exe_name, apm_runtimes / f"{exe_name}.exe"]
510+
apm_resolved = next((str(c) for c in apm_candidates if c.exists()), None)
511+
if apm_resolved:
512+
actual_command_args[0] = apm_resolved
513+
else:
514+
resolved = shutil.which(exe_name)
515+
if resolved:
516+
actual_command_args[0] = resolved
506517
return subprocess.run(actual_command_args, check=True, env=env_vars)
507518

508519
def _discover_prompt_file(self, name: str) -> Optional[Path]:
@@ -546,24 +557,22 @@ def _discover_prompt_file(self, name: str) -> Optional[Path]:
546557
]
547558

548559
for path in local_search_paths:
549-
if path.exists() and not path.is_symlink():
560+
if path.exists():
550561
return path
551562

552563
# 2. Search in dependencies and detect collisions
553564
apm_modules = Path("apm_modules")
554565
if apm_modules.exists():
555566
# Collect ALL .prompt.md matches to detect collisions
556-
raw_matches = list(apm_modules.rglob(search_name))
567+
matches = list(apm_modules.rglob(search_name))
557568

558569
# Also search for SKILL.md in directories matching the name
570+
# e.g., name="architecture-blueprint-generator" -> find */architecture-blueprint-generator/SKILL.md
559571
for skill_dir in apm_modules.rglob(name):
560572
if skill_dir.is_dir():
561573
skill_file = skill_dir / "SKILL.md"
562574
if skill_file.exists():
563-
raw_matches.append(skill_file)
564-
565-
# Filter out symlinks
566-
matches = [m for m in raw_matches if not m.is_symlink()]
575+
matches.append(skill_file)
567576

568577
if len(matches) == 0:
569578
return None
@@ -847,7 +856,15 @@ def _create_minimal_config(self) -> None:
847856
def _detect_installed_runtime(self) -> str:
848857
"""Detect installed runtime with priority order.
849858
850-
Priority: copilot > codex > error
859+
Checks APM-managed runtimes (~/.apm/runtimes/) first, then PATH.
860+
This ensures explicitly APM-installed binaries take priority over
861+
system-level stubs (e.g. GitHub CLI copilot extensions).
862+
863+
Priority:
864+
1. APM runtimes dir: copilot (codex excluded — v0.116+ is
865+
incompatible with GitHub Models' Chat Completions API)
866+
2. PATH: llm > copilot > codex (llm uses Chat Completions, works
867+
with GitHub Models even when codex dropped that API)
851868
852869
Returns:
853870
Name of detected runtime
@@ -857,18 +874,40 @@ def _detect_installed_runtime(self) -> str:
857874
"""
858875
import shutil
859876

860-
# Priority order: copilot first (recommended), then codex
861-
if shutil.which("copilot"):
877+
apm_runtimes = Path.home() / ".apm" / "runtimes"
878+
879+
# 1. Check APM-managed runtimes directory first (highest priority).
880+
# Only copilot is checked here — codex installed via APM runtimes
881+
# will be v0.116+ which dropped Chat Completions support and is
882+
# incompatible with GitHub Models.
883+
# llm is checked via PATH only (installed as a Python package).
884+
for name in ("copilot",):
885+
candidates = [
886+
apm_runtimes / name,
887+
apm_runtimes / f"{name}.exe",
888+
apm_runtimes / f"{name}.cmd",
889+
]
890+
if any(c.exists() for c in candidates):
891+
# Verify the binary is actually executable before returning
892+
exe = next(c for c in candidates if c.exists())
893+
if exe.stat().st_size > 0:
894+
return name
895+
896+
# 2. Fall back to PATH — prefer llm (uses Chat Completions, works with
897+
# GitHub Models even when codex has dropped that API format)
898+
if shutil.which("llm"):
899+
return "llm"
900+
elif shutil.which("copilot"):
862901
return "copilot"
863902
elif shutil.which("codex"):
864903
return "codex"
865904
else:
866905
raise RuntimeError(
867906
"No compatible runtime found.\n"
868-
"Install GitHub Copilot CLI with:\n"
869-
" apm runtime setup copilot\n"
870-
"Or install Codex CLI with:\n"
871-
" apm runtime setup codex"
907+
"Install the llm CLI with:\n"
908+
" apm runtime setup llm\n"
909+
"Or install GitHub Copilot CLI with:\n"
910+
" apm runtime setup copilot"
872911
)
873912

874913
def _generate_runtime_command(self, runtime: str, prompt_file: Path) -> str:
@@ -887,6 +926,9 @@ def _generate_runtime_command(self, runtime: str, prompt_file: Path) -> str:
887926
elif runtime == "codex":
888927
# Codex CLI with default sandbox and git repo check skip
889928
return f"codex -s workspace-write --skip-git-repo-check {prompt_file}"
929+
elif runtime == "llm":
930+
# llm CLI — uses Chat Completions, compatible with GitHub Models
931+
return f"llm -m github/gpt-4o {prompt_file}"
890932
else:
891933
raise ValueError(f"Unsupported runtime: {runtime}")
892934

@@ -916,7 +958,7 @@ def compile(self, prompt_file: str, params: Dict[str, str]) -> str:
916958
# Now ensure compiled directory exists
917959
self.compiled_dir.mkdir(parents=True, exist_ok=True)
918960

919-
with open(prompt_path, "r") as f:
961+
with open(prompt_path, "r", encoding="utf-8") as f:
920962
content = f.read()
921963

922964
# Parse frontmatter and content
@@ -939,57 +981,55 @@ def compile(self, prompt_file: str, params: Dict[str, str]) -> str:
939981
output_path = self.compiled_dir / output_name
940982

941983
# Write compiled content
942-
with open(output_path, "w") as f:
984+
with open(output_path, "w", encoding="utf-8") as f:
943985
f.write(compiled_content)
944986

945987
return str(output_path)
946988

947989
def _resolve_prompt_file(self, prompt_file: str) -> Path:
948990
"""Resolve prompt file path, checking local directory first, then common directories, then dependencies.
949991
950-
Symlinks are rejected outright to prevent traversal attacks.
951-
952992
Args:
953993
prompt_file: Relative path to the .prompt.md file
954994
955995
Returns:
956996
Path: Resolved path to the prompt file
957997
958998
Raises:
959-
FileNotFoundError: If prompt file is not found or is a symlink
999+
FileNotFoundError: If prompt file is not found in local or dependency modules
9601000
"""
9611001
prompt_path = Path(prompt_file)
9621002

9631003
# First check if it exists in current directory (local)
9641004
if prompt_path.exists():
965-
if prompt_path.is_symlink():
966-
raise FileNotFoundError(
967-
f"Prompt file '{prompt_file}' is a symlink. "
968-
f"Symlinks are not allowed for security reasons."
969-
)
9701005
return prompt_path
9711006

9721007
# Check in common project directories
9731008
common_dirs = [".github/prompts", ".apm/prompts"]
9741009
for common_dir in common_dirs:
9751010
common_path = Path(common_dir) / prompt_file
976-
if common_path.exists() and not common_path.is_symlink():
1011+
if common_path.exists():
9771012
return common_path
9781013

9791014
# If not found locally, search in dependency modules
9801015
apm_modules_dir = Path("apm_modules")
9811016
if apm_modules_dir.exists():
1017+
# Search all dependency directories for the prompt file
1018+
# Handle org/repo directory structure (e.g., apm_modules/microsoft/apm-sample-package/)
9821019
for org_dir in apm_modules_dir.iterdir():
9831020
if org_dir.is_dir() and not org_dir.name.startswith("."):
1021+
# Iterate through repos within the org
9841022
for repo_dir in org_dir.iterdir():
9851023
if repo_dir.is_dir() and not repo_dir.name.startswith("."):
1024+
# Check in the root of the repository
9861025
dep_prompt_path = repo_dir / prompt_file
987-
if dep_prompt_path.exists() and not dep_prompt_path.is_symlink():
1026+
if dep_prompt_path.exists():
9881027
return dep_prompt_path
9891028

1029+
# Also check in common subdirectories
9901030
for subdir in ["prompts", ".", "workflows"]:
9911031
sub_prompt_path = repo_dir / subdir / prompt_file
992-
if sub_prompt_path.exists() and not sub_prompt_path.is_symlink():
1032+
if sub_prompt_path.exists():
9931033
return sub_prompt_path
9941034

9951035
# If still not found, raise an error with helpful message

0 commit comments

Comments
 (0)