@@ -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"\n To 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"\n To 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"\n Then run again — APM will auto-discover it.\n "
114+ error_msg += f"\n Or 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"\n Or 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