Robust GitHub CLI Discovery For GUI-Launched Apps
The package now handles the Codex.app failure mode where GUI-launched processes cannot see either the generated gh shim or Homebrew's real GitHub CLI because launchd provided a minimal PATH.
Summary
Added package-level fallbacks for locating real executables, a macOS GUI PATH installer backed by a LaunchAgent, doctor checks that expose launchd PATH problems, and documentation for users who see GitHub CLI (gh) is not installed in Codex.app even though gh works in a shell.
Changes Made
find_program searches inherited PATH first, then stable user and package-manager locations such as ~/.local/bin, /opt/homebrew/bin, and /opt/local/bin.
Added install-gui-path and uninstall-gui-path commands that manage ~/Library/LaunchAgents/com.gh-forgejo-shim.user-gui-path.plist.
doctor reports whether the shim directory is visible to newly launched macOS GUI apps through launchd PATH.
Updated README, configuration, rollback docs, and added tests for fallback discovery, LaunchAgent rendering, and GUI PATH doctor checks.
Context
Codex.app's main GUI process was observed with PATH=/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin, while the shell and app-server process could see ~/.local/bin and /opt/homebrew/bin. That explains why the Create Pull Request button could report the GitHub CLI as missing even though /Users/kell/.local/bin/gh and /opt/homebrew/bin/gh both existed.
The package fix covers both parts of that split: when the shim is invoked, it can now find real tools without a rich PATH; when a GUI app cannot find the shim, users can install a persistent launchd PATH with one package command.
Important Implementation Details
- The real
ghlookup still skips a managedgh-forgejo-shimwrapper to avoid recursion. - Explicit
FJ_SHIM_REAL_GH,FJ_SHIM_REAL_FJ, and config-file paths still take precedence over fallback discovery. install-gui-pathwrites the LaunchAgent and immediately runslaunchctl setenv PATH ...for newly launched GUI apps in the current login session.- Existing GUI apps still need to be restarted because processes keep the environment they inherited at launch.
Relevant Diff Snippets
Rendered with @pierre/diffs/ssr preloadPatchFile, following the Diffs documentation at diffs.com/docs. The markup below is static and saved in this file.
6 unmodified lines78910111235 unmodified lines48495051525316 unmodified lines7071727374756 unmodified linesfrom . import __version__from .config import add_host, load_config, remove_hostfrom .doctor import format_checks, run_checksfrom .routing import run_ghfrom .shim import install_shim, uninstall_shim35 unmodified linesprint(format_checks(checks))return 0 if all(check.ok for check in checks) else 1if command == "config":return run_config(namespace)16 unmodified linesuninstall = subparsers.add_parser("uninstall-shim", help="remove the user-local gh wrapper")uninstall.add_argument("--bin-dir")subparsers.add_parser("doctor", help="check shim configuration")subparsers.add_parser("version", help="print version")6 unmodified lines7891011121335 unmodified lines4950515253545556575859606162636465666768697071727374757677787916 unmodified lines969798991001011021031041051061071081091101111121131146 unmodified linesfrom . import __version__from .config import add_host, load_config, remove_hostfrom .doctor import format_checks, run_checksfrom .gui_path import install_gui_path, uninstall_gui_pathfrom .routing import run_ghfrom .shim import install_shim, uninstall_shim35 unmodified linesprint(format_checks(checks))return 0 if all(check.ok for check in checks) else 1if command == "install-gui-path":if sys.platform != "darwin":print("gh-forgejo-shim: install-gui-path is only supported on macOS", file=sys.stderr)return 1result = install_gui_path(path_value=namespace.path, apply_now=not namespace.no_apply)print(f"installed macOS GUI PATH LaunchAgent at {result.plist_path}")print(f"PATH={result.path_value}")if namespace.no_apply:print("restart your login session or load the LaunchAgent before reopening GUI apps")elif result.applied:print("applied PATH to the current launchd user session; restart GUI apps to inherit it")else:print(f"warning: could not apply PATH immediately: {result.apply_error}", file=sys.stderr)print("the LaunchAgent will apply PATH at the next login")return 0if command == "uninstall-gui-path":if sys.platform != "darwin":print("gh-forgejo-shim: uninstall-gui-path is only supported on macOS", file=sys.stderr)return 1path = uninstall_gui_path()print(f"removed macOS GUI PATH LaunchAgent at {path}")print("restart your login session to return GUI apps to the default launchd PATH")return 0if command == "config":return run_config(namespace)16 unmodified linesuninstall = subparsers.add_parser("uninstall-shim", help="remove the user-local gh wrapper")uninstall.add_argument("--bin-dir")gui_path = subparsers.add_parser("install-gui-path",help="make macOS GUI apps inherit a PATH that can find the shim and Homebrew tools",)gui_path.add_argument("--path", help="explicit PATH value to persist for GUI apps")gui_path.add_argument("--no-apply",action="store_true",help="write the LaunchAgent without applying PATH to the current launchd session",)subparsers.add_parser("uninstall-gui-path", help="remove the macOS GUI PATH LaunchAgent")subparsers.add_parser("doctor", help="check shim configuration")subparsers.add_parser("version", help="print version")
1234567891011121310 unmodified lines242526272829303132333435363738 unmodified lines767778798081from __future__ import annotationsimport osfrom dataclasses import dataclassfrom pathlib import Pathfrom typing import Mappingfrom .auth import discover_fj_tokenfrom .config import Config, load_configfrom .external import find_programfrom .shim import default_bin_dir, is_managed_shim, shim_path10 unmodified linesenv: Mapping[str, str] | None = None,bin_dir: Path | None = None,home: Path | None = None,) -> list[Check]:values = env if env is not None else os.environcfg = config or load_config(env=values)target_bin_dir = bin_dir or default_bin_dir()wrapper = shim_path(target_bin_dir)real_gh = find_program("gh", configured=cfg.paths.gh, env=values)real_fj = find_program("fj", configured=cfg.paths.fj, env=values)token = discover_fj_token(next(iter(cfg.hosts), None), env=values, home=home)checks = [38 unmodified linesok = Falsechecks.append(Check("shim path", ok, detail))return checks12345678910111213141510 unmodified lines26272829303132333435363738394041424344454638 unmodified lines858687888990919293949596979899100101102103104105106107108109110111from __future__ import annotationsimport osimport sysfrom dataclasses import dataclassfrom pathlib import Pathfrom typing import Mapping, Sequencefrom .auth import discover_fj_tokenfrom .config import Config, load_configfrom .external import find_programfrom .gui_path import current_launchd_path, path_contains_dirfrom .shim import default_bin_dir, is_managed_shim, shim_path10 unmodified linesenv: Mapping[str, str] | None = None,bin_dir: Path | None = None,home: Path | None = None,fallback_dirs: Sequence[str] | None = None,launchd_path: str | None = None,check_gui_path: bool | None = None,) -> list[Check]:values = env if env is not None else os.environcfg = config or load_config(env=values)target_bin_dir = bin_dir or default_bin_dir()wrapper = shim_path(target_bin_dir)if fallback_dirs is None:real_gh = find_program("gh", configured=cfg.paths.gh, env=values)real_fj = find_program("fj", configured=cfg.paths.fj, env=values)else:real_gh = find_program("gh", configured=cfg.paths.gh, env=values, fallback_dirs=fallback_dirs)real_fj = find_program("fj", configured=cfg.paths.fj, env=values, fallback_dirs=fallback_dirs)token = discover_fj_token(next(iter(cfg.hosts), None), env=values, home=home)checks = [38 unmodified linesok = Falsechecks.append(Check("shim path", ok, detail))if check_gui_path if check_gui_path is not None else sys.platform == "darwin":gui_path = launchd_path if launchd_path is not None else current_launchd_path()if gui_path and path_contains_dir(gui_path, target_bin_dir):checks.append(Check("macOS gui PATH", True, f"{target_bin_dir} is visible to new GUI apps"))elif gui_path:checks.append(Check("macOS gui PATH",False,f"{target_bin_dir} is not in launchd PATH; run gh-forgejo-shim install-gui-path",))else:checks.append(Check("macOS gui PATH",False,"launchd PATH is unset; run gh-forgejo-shim install-gui-path if GUI apps cannot find gh",))return checks
2 unmodified lines34567891011121314151617182 unmodified lines2122232425262728293 unmodified lines3334353637382 unmodified linesimport osimport subprocessfrom pathlib import Pathfrom typing import Mappingfrom .shim import is_managed_shimdef find_program(name: str,*,configured: str | None = None,env: Mapping[str, str] | None = None,) -> str | None:if configured:path = Path(configured).expanduser()2 unmodified linesreturn Nonevalues = env if env is not None else os.environfor directory in values.get("PATH", "").split(os.pathsep):if not directory:continuecandidate = Path(directory).expanduser() / nameif not candidate.exists() or not os.access(candidate, os.X_OK):continue3 unmodified linesreturn Nonedef run_program(path: str, argv: list[str]) -> int:try:return subprocess.call([path, *argv])2 unmodified lines345678910111213141516171819202122232425262728293031322 unmodified lines353637383940413 unmodified lines45464748495051525354555657585960616263646566672 unmodified linesimport osimport subprocessfrom pathlib import Pathfrom typing import Mapping, Sequencefrom .shim import is_managed_shimDEFAULT_FALLBACK_DIRS = ("~/.local/bin","/opt/homebrew/bin","/opt/homebrew/sbin","/usr/local/bin","/usr/local/sbin","/opt/local/bin","/usr/bin","/bin","/usr/sbin","/sbin",)def find_program(name: str,*,configured: str | None = None,env: Mapping[str, str] | None = None,fallback_dirs: Sequence[str] = DEFAULT_FALLBACK_DIRS,) -> str | None:if configured:path = Path(configured).expanduser()2 unmodified linesreturn Nonevalues = env if env is not None else os.environfor directory in _candidate_dirs(values, fallback_dirs):candidate = Path(directory).expanduser() / nameif not candidate.exists() or not os.access(candidate, os.X_OK):continue3 unmodified linesreturn Nonedef _candidate_dirs(values: Mapping[str, str], fallback_dirs: Sequence[str]) -> list[str]:home = values.get("HOME")seen: set[str] = set()result: list[str] = []for directory in [*values.get("PATH", "").split(os.pathsep), *fallback_dirs]:if not directory:continueif directory.startswith("~/") and home:directory = str(Path(home) / directory[2:])normalized = str(Path(directory).expanduser())if normalized in seen:continueseen.add(normalized)result.append(normalized)return resultdef run_program(path: str, argv: list[str]) -> int:try:return subprocess.call([path, *argv])
16 unmodified lines1718192021224 unmodified lines27282930313276 unmodified lines10911011111211311416 unmodified linesconfig=Config(hosts=("git.example.com",)),env={"PATH": ""},home=Path(tmp),)self.assertFalse(self._check(checks, "real gh").ok)4 unmodified linesconfig=Config(hosts=("git.example.com",), paths=PathsConfig(gh=str(gh))),env={"PATH": ""},home=Path(tmp),)self.assertFalse(self._check(checks, "fj").ok)76 unmodified lines)self.assertFalse(self._check(checks, "shim path").ok)def _fake_executable(self, root: Path, name: str) -> Path:path = root / namepath.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")16 unmodified lines171819202122234 unmodified lines2829303132333476 unmodified lines11111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915016 unmodified linesconfig=Config(hosts=("git.example.com",)),env={"PATH": ""},home=Path(tmp),fallback_dirs=(),)self.assertFalse(self._check(checks, "real gh").ok)4 unmodified linesconfig=Config(hosts=("git.example.com",), paths=PathsConfig(gh=str(gh))),env={"PATH": ""},home=Path(tmp),fallback_dirs=(),)self.assertFalse(self._check(checks, "fj").ok)76 unmodified lines)self.assertFalse(self._check(checks, "shim path").ok)def test_macos_gui_path_check_accepts_shim_dir(self) -> None:with tempfile.TemporaryDirectory() as tmp:root = Path(tmp)bin_dir = root / "bin"install_shim(bin_dir=bin_dir)checks = run_checks(config=Config(hosts=("git.example.com",)),env={"PATH": str(bin_dir)},bin_dir=bin_dir,home=root,launchd_path=os.pathsep.join(["/usr/bin", str(bin_dir)]),check_gui_path=True,)self.assertTrue(self._check(checks, "macOS gui PATH").ok)def test_macos_gui_path_check_warns_when_shim_dir_missing(self) -> None:with tempfile.TemporaryDirectory() as tmp:root = Path(tmp)bin_dir = root / "bin"install_shim(bin_dir=bin_dir)checks = run_checks(config=Config(hosts=("git.example.com",)),env={"PATH": str(bin_dir)},bin_dir=bin_dir,home=root,launchd_path="/usr/bin:/bin",check_gui_path=True,)self.assertFalse(self._check(checks, "macOS gui PATH").ok)def _fake_executable(self, root: Path, name: str) -> Path:path = root / namepath.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
Expected Impact for End-Users
Users who install the shim in a normal shell get more reliable delegation to the real GitHub CLI, even in constrained environments. macOS users can run gh-forgejo-shim install-gui-path once so Codex.app and similar GUI-launched tools can find both the shim and Homebrew-installed tools after restart.
Validation
- Passed:
python3 -m unittest, 53 tests. - Passed: CLI smoke check with
PYTHONPATH=src python3 -m gh_forgejo_shim install-gui-path --help. - Passed: Diffs SSR generation for this turn document using
@pierre/diffs@1.2.5.
Issues, Limitations, and Mitigations
- macOS only: The GUI PATH command is intentionally limited to macOS because it uses launchd and LaunchAgents.
- Restart required: Existing Codex.app windows will not inherit a newly set PATH until the app is restarted.
- No release automation yet: This change is in the package source, but shipping it to users still depends on the package release flow.
Follow-up Work
gh-forgejo-shim-xq1: add package release automation so fixes like this can be published predictably.gh-forgejo-shim-mte: add live Forgejo integration coverage once a stable test host or fixture flow exists.