Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions scripts/runtime/setup-codex.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ function Install-Codex {
}

Write-Info "Using Codex release: $latestTag"
$Version = $latestTag
$downloadUrl = "https://github.com/$CodexRepo/releases/download/$latestTag/codex-$codexPlatform.exe.tar.gz"
} else {
$downloadUrl = "https://github.com/$CodexRepo/releases/download/$Version/codex-$codexPlatform.exe.tar.gz"
Expand Down Expand Up @@ -168,6 +169,21 @@ wire_api = "responses"

Write-Success "Codex configuration created at $codexConfig"
Write-Info "Using Codex $Version."

# Version compatibility check
if ($Version -match '^rust-v0\.(\d+)') {
$codexMinor = [int]$Matches[1]
if ($codexMinor -ge 116) {
Write-Host ""
Write-Host "[!] WARNING: Codex >= v0.116 requires the OpenAI Responses API (wire_api=responses)." -ForegroundColor Yellow
Write-Host " GitHub Models does not expose the /responses endpoint and will return 404." -ForegroundColor Yellow
Write-Host " If you are using GitHub Models as your provider, you have two options:" -ForegroundColor Yellow
Write-Host " 1. Use an OpenAI API key instead of GitHub Models" -ForegroundColor Yellow
Write-Host " 2. Install a compatible older version: apm runtime setup codex --version rust-v0.115.0" -ForegroundColor Yellow
Comment thread
sergio-sisternes-epam marked this conversation as resolved.
Outdated
Write-Host ""
}
}

Write-Info "Override with: apm runtime setup codex --version <version> (e.g. 'latest')"
} else {
Write-Info "Vanilla mode: Skipping APM configuration"
Expand Down
14 changes: 14 additions & 0 deletions scripts/runtime/setup-codex.sh
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ setup_codex() {
fi

log_info "Using Codex release: $latest_tag"
CODEX_VERSION="$latest_tag"
download_url="https://github.com/$CODEX_REPO/releases/download/$latest_tag/codex-$codex_platform.tar.gz"
else
download_url="https://github.com/$CODEX_REPO/releases/download/$CODEX_VERSION/codex-$codex_platform.tar.gz"
Expand Down Expand Up @@ -211,6 +212,19 @@ EOF

log_success "Codex configuration created at $codex_config"
log_info "Using Codex $CODEX_VERSION."

# Version compatibility check
codex_minor=$(echo "$CODEX_VERSION" | sed -n 's/^rust-v0\.\([0-9]*\).*/\1/p')
if [ -n "$codex_minor" ] && [ "$codex_minor" -ge 116 ] 2>/dev/null; then
echo ""
echo "[!] WARNING: Codex >= v0.116 requires the OpenAI Responses API (wire_api=responses)."
echo " GitHub Models does not expose the /responses endpoint and will return 404."
echo " If you are using GitHub Models as your provider, you have two options:"
echo " 1. Use an OpenAI API key instead of GitHub Models"
echo " 2. Install a compatible older version: apm runtime setup codex --version rust-v0.115.0"
Comment thread
sergio-sisternes-epam marked this conversation as resolved.
Outdated
Comment thread
sergio-sisternes-epam marked this conversation as resolved.
Outdated
echo ""
fi

log_info "Override with: apm runtime setup codex --version <version> (e.g. 'latest')"
log_info "APM configured Codex with GitHub Models as default provider"
log_info "Use 'apm install' to configure MCP servers for your projects"
Expand Down
22 changes: 11 additions & 11 deletions src/apm_cli/core/script_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import os
import re
import shutil
import subprocess
import sys
import time
Expand All @@ -12,6 +11,7 @@
import yaml # noqa: F401

from ..output.script_formatters import ScriptExecutionFormatter
from ..runtime.utils import find_runtime_binary
from .token_manager import setup_runtime_environment


Expand Down Expand Up @@ -571,11 +571,13 @@ def _execute_runtime_command(
for line in env_lines:
print(line)

# Execute using argument list (no shell interpretation) with updated environment
# On Windows, resolve the executable via shutil.which() so that shell
# wrappers like copilot.cmd / copilot.ps1 are found without shell=True.
if sys.platform == "win32" and actual_command_args:
resolved = shutil.which(actual_command_args[0])
# Resolve the runtime binary to the APM-managed path (or fallback to PATH).
# This must happen here, not in _generate_runtime_command, so the command
# string stays parseable (bare names) by _detect_runtime / _transform_runtime_command.
# find_runtime_binary checks ~/.apm/runtimes/<name> first, then shutil.which(),
# which also covers Windows shell wrappers (.cmd / .ps1) via PATHEXT.
if actual_command_args:
resolved = find_runtime_binary(actual_command_args[0])
if resolved:
actual_command_args[0] = resolved
return subprocess.run(actual_command_args, check=True, env=env_vars)
Expand Down Expand Up @@ -921,13 +923,11 @@ def _detect_installed_runtime(self) -> str:
Raises:
RuntimeError: If no compatible runtime is found
"""
import shutil

if shutil.which("copilot"):
if find_runtime_binary("copilot"):
return "copilot"
elif shutil.which("codex"):
elif find_runtime_binary("codex"):
return "codex"
elif shutil.which("gemini"):
elif find_runtime_binary("gemini"):
Comment thread
sergio-sisternes-epam marked this conversation as resolved.
return "gemini"
else:
raise RuntimeError(
Expand Down
5 changes: 3 additions & 2 deletions src/apm_cli/integration/mcp_integrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

from apm_cli.core.null_logger import NullCommandLogger
from apm_cli.deps.lockfile import LockFile, get_lockfile_path
from apm_cli.runtime.utils import find_runtime_binary
from apm_cli.utils.console import (
_get_console, # noqa: F401 -- module attribute; patched by tests and used via re-export
_rich_error,
Expand Down Expand Up @@ -836,7 +837,7 @@ def _filter_runtimes(detected_runtimes: list[str]) -> list[str]:
except ImportError:
available = []
for rt in mcp_compatible:
if shutil.which(rt):
if find_runtime_binary(rt):
available.append(rt)
return available

Expand All @@ -847,7 +848,7 @@ def _filter_runtimes(detected_runtimes: list[str]) -> list[str]:
mcp_compatible = [
rt for rt in detected_runtimes if rt in ClientFactory.supported_clients()
]
return [rt for rt in mcp_compatible if shutil.which(rt)]
return [rt for rt in mcp_compatible if find_runtime_binary(rt)]

# ------------------------------------------------------------------
# Per-runtime installation
Expand Down
12 changes: 8 additions & 4 deletions src/apm_cli/integration/mcp_integrator_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@
from __future__ import annotations

import builtins
import shutil
from pathlib import Path
from typing import TYPE_CHECKING

from apm_cli.core.null_logger import NullCommandLogger
from apm_cli.runtime.utils import find_runtime_binary
from apm_cli.utils.console import STATUS_SYMBOLS

if TYPE_CHECKING:
Expand Down Expand Up @@ -188,7 +188,7 @@ def run_mcp_install( # noqa: PLR0915
# adapter from writing to ~/.claude.json on hosts
# where Claude Code was never installed.
if (project_root_path / ".claude").is_dir() or (
shutil.which("claude") is not None
find_runtime_binary("claude") is not None
):
ClientFactory.create_client(runtime_name)
installed_runtimes.append(runtime_name)
Expand All @@ -199,7 +199,9 @@ def run_mcp_install( # noqa: PLR0915
except (ValueError, ImportError):
continue
except ImportError:
installed_runtimes = [rt for rt in ["copilot", "codex"] if shutil.which(rt) is not None]
installed_runtimes = [
rt for rt in ["copilot", "codex"] if find_runtime_binary(rt) is not None
]
# VS Code: check binary on PATH or .vscode/ directory presence
if _is_vscode_available(project_root=project_root_path):
installed_runtimes.append("vscode")
Expand All @@ -216,7 +218,9 @@ def run_mcp_install( # noqa: PLR0915
if (project_root_path / ".windsurf").is_dir():
installed_runtimes.append("windsurf")
# Claude Code: directory-presence OR binary-on-PATH
if (project_root_path / ".claude").is_dir() or (shutil.which("claude") is not None):
if (project_root_path / ".claude").is_dir() or (
find_runtime_binary("claude") is not None
):
installed_runtimes.append("claude")

# Step 2: Get runtimes referenced in apm.yml scripts
Expand Down
7 changes: 4 additions & 3 deletions src/apm_cli/runtime/codex_runtime.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
"""Codex runtime adapter for APM."""

import shutil
import subprocess
from typing import Any, Dict, Optional # noqa: F401, UP035

from .base import RuntimeAdapter
from .utils import find_runtime_binary


class CodexRuntime(RuntimeAdapter):
Expand Down Expand Up @@ -39,8 +39,9 @@ def execute_prompt(self, prompt_content: str, **kwargs) -> str:
try:
# Use codex exec to execute the prompt with real-time streaming
# Always skip git repo check when running from APM
codex_binary = find_runtime_binary("codex") or "codex"
process = subprocess.Popen(
["codex", "exec", "--skip-git-repo-check", prompt_content],
[codex_binary, "exec", "--skip-git-repo-check", prompt_content],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, # Merge stderr into stdout for streaming
text=True,
Expand Down Expand Up @@ -136,7 +137,7 @@ def is_available() -> bool:
Returns:
bool: True if runtime is available, False otherwise
"""
return shutil.which("codex") is not None
return find_runtime_binary("codex") is not None

@staticmethod
def get_runtime_name() -> str:
Expand Down
4 changes: 2 additions & 2 deletions src/apm_cli/runtime/copilot_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@

import json
import os # noqa: F401
import shutil
import subprocess
from pathlib import Path
from typing import Any, Dict, Optional # noqa: F401, UP035

from .base import RuntimeAdapter
from .utils import find_runtime_binary


class CopilotRuntime(RuntimeAdapter):
Expand Down Expand Up @@ -169,7 +169,7 @@ def is_available() -> bool:
Returns:
bool: True if runtime is available, False otherwise
"""
return shutil.which("copilot") is not None
return find_runtime_binary("copilot") is not None

@staticmethod
def get_runtime_name() -> str:
Expand Down
32 changes: 32 additions & 0 deletions src/apm_cli/runtime/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Runtime binary resolution utilities."""

from __future__ import annotations

import os
import shutil
import sys
from pathlib import Path


def find_runtime_binary(name: str) -> str | None:
"""Return the resolved path to a runtime binary.

Priority:
1. ~/.apm/runtimes/<name> (APM-managed, executable)
2. shutil.which(name) (system PATH fallback)

On Windows the APM-managed binary may carry a ``.exe`` suffix, so both
``name`` and ``name.exe`` are checked under ``~/.apm/runtimes/``.
"""
apm_runtimes = Path.home() / ".apm" / "runtimes"

if sys.platform == "win32":
apm_path_exe = apm_runtimes / f"{name}.exe"
if apm_path_exe.is_file() and os.access(apm_path_exe, os.X_OK):
return str(apm_path_exe)

apm_path = apm_runtimes / name
if apm_path.is_file() and os.access(apm_path, os.X_OK):
return str(apm_path)

return shutil.which(name)
1 change: 1 addition & 0 deletions tests/unit/runtime/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Unit tests for runtime modules."""
Loading
Loading