@@ -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+
322389class TestInstallPluginDependencies :
323390 """Test plugin dependencies installation via bun/npm install."""
324391
0 commit comments