fix(clink): resolve codex CLI from nvm/npm global paths - #458
Conversation
When PAL MCP runs via uvx, PATH may omit nvm-injected directories so clink cannot find globally installed codex binaries. Augment PATH with common user-local install locations before resolving CLI executables. Fixes BeehiveInnovations#442
There was a problem hiding this comment.
Code Review
This pull request introduces a new path resolution utility (clink/path_resolution.py) to locate CLI executables in common user-local installation directories (such as NVM, NPM, and Cargo) when they are missing from the host PATH. The base agent is updated to leverage this utility, and corresponding unit and integration tests are added. Feedback on these changes highlights a potential bug in lexicographical version sorting for Node directories, an opportunity to simplify path deduplication using dict.fromkeys(), and a cross-platform issue in tests where mocking the HOME environment variable should be replaced by directly mocking Path.home.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| if versions_dir.is_dir(): | ||
| version_bins = sorted(versions_dir.glob("*/bin"), reverse=True) | ||
| candidates.extend(str(path) for path in version_bins) |
There was a problem hiding this comment.
Sorting version directories lexicographically with reverse=True can lead to incorrect version preference. For example, "v9.0.0" > "v18.0.0" evaluates to True in Python because '9' > '1'. This means an older single-digit major version of Node will be preferred over a newer double-digit major version.
To fix this, we should use a semver-aware sorting key that splits the version string into integers.
if versions_dir.is_dir():
def version_key(p: Path) -> tuple[int, ...]:
parts = p.parent.name.lstrip("v").split("-")[0].split(".")
return tuple(int(x) for x in parts if x.isdigit())
version_bins = sorted(versions_dir.glob("*/bin"), key=version_key, reverse=True)
candidates.extend(str(path) for path in version_bins)| seen: set[str] = set() | ||
| unique: list[str] = [] | ||
| for candidate in candidates: | ||
| if candidate not in seen: | ||
| seen.add(candidate) | ||
| unique.append(candidate) | ||
| return unique |
There was a problem hiding this comment.
In Python 3.7+, dictionaries maintain insertion order. We can simplify the deduplication of candidate paths using dict.fromkeys(), which is more concise and idiomatic.
| seen: set[str] = set() | |
| unique: list[str] = [] | |
| for candidate in candidates: | |
| if candidate not in seen: | |
| seen.add(candidate) | |
| unique.append(candidate) | |
| return unique | |
| return list(dict.fromkeys(candidates)) |
| def test_augment_path_prepends_candidate_dirs(tmp_path, monkeypatch): | ||
| local_bin = tmp_path / ".local" / "bin" | ||
| local_bin.mkdir(parents=True) | ||
| monkeypatch.setenv("HOME", str(tmp_path)) |
There was a problem hiding this comment.
Using monkeypatch.setenv("HOME", ...) to mock the home directory is not fully cross-platform because Path.home() on Windows resolves using the USERPROFILE environment variable (or other Windows-specific APIs) rather than HOME.
To ensure the tests are robust and cross-platform, we should mock Path.home directly using monkeypatch.setattr.
| monkeypatch.setenv("HOME", str(tmp_path)) | |
| import pathlib | |
| monkeypatch.setattr(pathlib.Path, "home", lambda: tmp_path) |
|
|
||
| monkeypatch.setenv("NVM_DIR", str(tmp_path / ".nvm")) | ||
| monkeypatch.setenv("PATH", "/usr/bin:/bin") | ||
| monkeypatch.setenv("HOME", str(tmp_path)) |
There was a problem hiding this comment.
Using monkeypatch.setenv("HOME", ...) to mock the home directory is not fully cross-platform because Path.home() on Windows resolves using the USERPROFILE environment variable (or other Windows-specific APIs) rather than HOME.
To ensure the tests are robust and cross-platform, we should mock Path.home directly using monkeypatch.setattr.
| monkeypatch.setenv("HOME", str(tmp_path)) | |
| monkeypatch.setattr(Path, "home", lambda: tmp_path) |
ba5b732 to
8728994
Compare
Summary
Fix
clinkfailing withcodex: command not foundwhen Codex CLI is installed globally via nvm + npm and PAL MCP is launched throughuvx, which does not inherit nvm-injected PATH entries.Motivation
Fixes #442. The MCP subprocess inherits a minimal PATH from
uvx, soshutil.which('codex')cannot find binaries installed under$NVM_DIR/versions/node/*/bin.Changes
clink/path_resolution.pywithresolve_cli_executable()andaugment_path()to search common user-local install directories (nvm node bins,~/.local/bin,~/.npm-global/bin, etc.)clink/agents/base.pyto resolve CLI executables via the augmented lookup and pass an augmented PATH to subprocessesfake_whichstubs to acceptpath=(required byshutil.which)Tests
pytest tests/test_clink_path_resolution.py tests/test_clink_codex_path_resolution.py tests/test_clink_codex_agent.py tests/test_clink_claude_agent.py tests/test_clink_gemini_agent.py -q # 12 passedNotes
run-server.shfor Claude/Codex integration.