Skip to content

Commit 262e480

Browse files
committed
fix: ensure codegraph is in PATH via ~/.pilot/bin symlink
Two-part fix for 'codegraph: not found' in hooks: 1. Installer: after npm install, symlink codegraph (and probe) into ~/.pilot/bin/ which is already in PATH via shell integration. This ensures the binary is findable regardless of nvm version or npm global bin PATH configuration. 2. Hooks: add 'command -v' guard so hooks silently skip if codegraph isn't installed yet (graceful degradation for fresh installs before the installer runs).
1 parent 9ba339c commit 262e480

3 files changed

Lines changed: 118 additions & 18 deletions

File tree

installer/steps/dependencies.py

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import json
66
import os
7+
import shutil
78
import subprocess
89
import time
910
from pathlib import Path
@@ -132,9 +133,12 @@ def _is_probe_installed() -> bool:
132133

133134
def install_probe() -> bool:
134135
"""Install Probe code search tool globally via npm."""
135-
if _is_probe_installed():
136-
return True
137-
return _run_bash_with_retry(npm_global_cmd("npm install -g @probelabs/probe"))
136+
if not _is_probe_installed():
137+
if not _run_bash_with_retry(npm_global_cmd("npm install -g @probelabs/probe")):
138+
return False
139+
140+
_symlink_to_pilot_bin("probe")
141+
return True
138142

139143

140144

@@ -182,15 +186,41 @@ def _is_codegraph_installed() -> bool:
182186
return False
183187

184188

189+
def _symlink_to_pilot_bin(binary_name: str) -> None:
190+
"""Create a symlink in ~/.pilot/bin/ pointing to the npm global binary.
191+
192+
This ensures the binary is in PATH even when the npm global bin directory
193+
(e.g. ~/.nvm/versions/node/vXX/bin/) is not in PATH during hook execution.
194+
~/.pilot/bin/ is added to PATH by the shell integration step.
195+
"""
196+
pilot_bin = Path.home() / ".pilot" / "bin"
197+
pilot_bin.mkdir(parents=True, exist_ok=True)
198+
link_path = pilot_bin / binary_name
199+
200+
source = shutil.which(binary_name)
201+
if not source:
202+
return
203+
204+
source_path = Path(source).resolve()
205+
try:
206+
if link_path.is_symlink() or link_path.exists():
207+
link_path.unlink()
208+
link_path.symlink_to(source_path)
209+
except OSError:
210+
pass
211+
212+
185213
def install_codegraph() -> bool:
186214
"""Install CodeGraph for code knowledge graph and structural analysis."""
187-
if _is_codegraph_installed():
188-
return True
215+
if not _is_codegraph_installed():
216+
if not _run_bash_with_retry(
217+
npm_global_cmd("npm install -g @colbymchenry/codegraph --force"),
218+
timeout=120,
219+
):
220+
return False
189221

190-
return _run_bash_with_retry(
191-
npm_global_cmd("npm install -g @colbymchenry/codegraph --force"),
192-
timeout=120,
193-
)
222+
_symlink_to_pilot_bin("codegraph")
223+
return True
194224

195225

196226
def _is_vtsls_installed() -> bool:

installer/tests/unit/steps/test_dependencies.py

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -283,20 +283,23 @@ def test_install_codegraph_exists(self):
283283

284284
assert callable(install_codegraph)
285285

286+
@patch("installer.steps.dependencies._symlink_to_pilot_bin")
286287
@patch("installer.steps.dependencies._is_codegraph_installed", return_value=True)
287-
def test_install_codegraph_skips_if_already_installed(self, _mock_check):
288-
"""install_codegraph skips installation when already installed."""
288+
def test_install_codegraph_skips_npm_if_already_installed(self, _mock_check, mock_symlink):
289+
"""install_codegraph skips npm but still creates symlink."""
289290
from installer.steps.dependencies import install_codegraph
290291

291292
with patch("installer.steps.dependencies._run_bash_with_retry") as mock_bash:
292293
result = install_codegraph()
293294

294295
assert result is True
295296
mock_bash.assert_not_called()
297+
mock_symlink.assert_called_once_with("codegraph")
296298

299+
@patch("installer.steps.dependencies._symlink_to_pilot_bin")
297300
@patch("installer.steps.dependencies._is_codegraph_installed", return_value=False)
298-
def test_install_codegraph_runs_npm_when_not_installed(self, _mock_check):
299-
"""install_codegraph runs npm install when not installed."""
301+
def test_install_codegraph_runs_npm_when_not_installed(self, _mock_check, mock_symlink):
302+
"""install_codegraph runs npm install and creates symlink."""
300303
from installer.steps.dependencies import install_codegraph
301304

302305
with patch("installer.steps.dependencies._run_bash_with_retry", return_value=True) as mock_bash:
@@ -307,9 +310,11 @@ def test_install_codegraph_runs_npm_when_not_installed(self, _mock_check):
307310
call_args = str(mock_bash.call_args)
308311
assert "@colbymchenry/codegraph" in call_args
309312
assert "--force" in call_args
313+
mock_symlink.assert_called_once_with("codegraph")
310314

315+
@patch("installer.steps.dependencies._symlink_to_pilot_bin")
311316
@patch("installer.steps.dependencies._is_codegraph_installed", return_value=False)
312-
def test_install_codegraph_returns_false_when_npm_fails(self, _mock_check):
317+
def test_install_codegraph_returns_false_when_npm_fails(self, _mock_check, _mock_symlink):
313318
"""install_codegraph returns False when npm install fails."""
314319
from installer.steps.dependencies import install_codegraph
315320

@@ -319,6 +324,68 @@ def test_install_codegraph_returns_false_when_npm_fails(self, _mock_check):
319324
assert result is False
320325

321326

327+
class TestSymlinkToPilotBin:
328+
"""Tests for _symlink_to_pilot_bin() — creates symlinks in ~/.pilot/bin/."""
329+
330+
def test_creates_symlink_when_binary_exists(self, tmp_path: Path):
331+
"""Creates a symlink in pilot bin dir pointing to the real binary."""
332+
from installer.steps.dependencies import _symlink_to_pilot_bin
333+
334+
fake_bin = tmp_path / "src" / "codegraph"
335+
fake_bin.parent.mkdir(parents=True)
336+
fake_bin.write_text("#!/bin/sh\n")
337+
fake_bin.chmod(0o755)
338+
339+
pilot_bin = tmp_path / "pilot_bin"
340+
341+
with (
342+
patch("installer.steps.dependencies.shutil.which", return_value=str(fake_bin)),
343+
patch("installer.steps.dependencies.Path.home", return_value=tmp_path),
344+
):
345+
pilot_bin_dir = tmp_path / ".pilot" / "bin"
346+
pilot_bin_dir.mkdir(parents=True, exist_ok=True)
347+
_symlink_to_pilot_bin("codegraph")
348+
349+
link = tmp_path / ".pilot" / "bin" / "codegraph"
350+
assert link.is_symlink()
351+
assert link.resolve() == fake_bin.resolve()
352+
353+
def test_skips_when_binary_not_found(self, tmp_path: Path):
354+
"""Does nothing when the binary is not in PATH."""
355+
from installer.steps.dependencies import _symlink_to_pilot_bin
356+
357+
with (
358+
patch("installer.steps.dependencies.shutil.which", return_value=None),
359+
patch("installer.steps.dependencies.Path.home", return_value=tmp_path),
360+
):
361+
_symlink_to_pilot_bin("codegraph")
362+
363+
link = tmp_path / ".pilot" / "bin" / "codegraph"
364+
assert not link.exists()
365+
366+
def test_replaces_existing_symlink(self, tmp_path: Path):
367+
"""Replaces an existing symlink with the new target."""
368+
from installer.steps.dependencies import _symlink_to_pilot_bin
369+
370+
fake_bin = tmp_path / "new_codegraph"
371+
fake_bin.write_text("#!/bin/sh\n")
372+
fake_bin.chmod(0o755)
373+
374+
pilot_bin_dir = tmp_path / ".pilot" / "bin"
375+
pilot_bin_dir.mkdir(parents=True)
376+
old_link = pilot_bin_dir / "codegraph"
377+
old_link.symlink_to("/nonexistent/old/path")
378+
379+
with (
380+
patch("installer.steps.dependencies.shutil.which", return_value=str(fake_bin)),
381+
patch("installer.steps.dependencies.Path.home", return_value=tmp_path),
382+
):
383+
_symlink_to_pilot_bin("codegraph")
384+
385+
assert old_link.is_symlink()
386+
assert old_link.resolve() == fake_bin.resolve()
387+
388+
322389
class TestInstallPluginDependencies:
323390
"""Test plugin dependencies installation via bun/npm install."""
324391

pilot/hooks/hooks.json

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
"hooks": [
2424
{
2525
"type": "command",
26-
"command": "codegraph init -i && codegraph sync",
26+
"command": "command -v codegraph >/dev/null 2>&1 && codegraph init -i && codegraph sync || true",
2727
"async": true,
2828
"timeout": 120
2929
}
@@ -96,8 +96,9 @@
9696
"hooks": [
9797
{
9898
"type": "command",
99-
"command": "codegraph mark-dirty",
100-
"async": true
99+
"command": "command -v codegraph >/dev/null 2>&1 && codegraph mark-dirty || true",
100+
"async": true,
101+
"timeout": 10
101102
}
102103
]
103104
},
@@ -146,7 +147,9 @@
146147
},
147148
{
148149
"type": "command",
149-
"command": "codegraph sync-if-dirty"
150+
"command": "command -v codegraph >/dev/null 2>&1 && codegraph sync-if-dirty || true",
151+
"async": true,
152+
"timeout": 30
150153
}
151154
]
152155
}

0 commit comments

Comments
 (0)