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.

Date: 2026-05-31 Issue: gh-forgejo-shim-aux Scope: repository implementation

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

Executable discovery

find_program searches inherited PATH first, then stable user and package-manager locations such as ~/.local/bin, /opt/homebrew/bin, and /opt/local/bin.

macOS GUI PATH helper

Added install-gui-path and uninstall-gui-path commands that manage ~/Library/LaunchAgents/com.gh-forgejo-shim.user-gui-path.plist.

Doctor visibility

doctor reports whether the shim directory is visible to newly launched macOS GUI apps through launchd PATH.

Docs and tests

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

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.

src/gh_forgejo_shim/cli.py
+39
6 unmodified lines
7
8
9
10
11
12
35 unmodified lines
48
49
50
51
52
53
16 unmodified lines
70
71
72
73
74
75
6 unmodified lines
from . import __version__
from .config import add_host, load_config, remove_host
from .doctor import format_checks, run_checks
from .routing import run_gh
from .shim import install_shim, uninstall_shim
35 unmodified lines
print(format_checks(checks))
return 0 if all(check.ok for check in checks) else 1
if command == "config":
return run_config(namespace)
16 unmodified lines
uninstall = 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 lines
7
8
9
10
11
12
13
35 unmodified lines
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
16 unmodified lines
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
6 unmodified lines
from . import __version__
from .config import add_host, load_config, remove_host
from .doctor import format_checks, run_checks
from .gui_path import install_gui_path, uninstall_gui_path
from .routing import run_gh
from .shim import install_shim, uninstall_shim
35 unmodified lines
print(format_checks(checks))
return 0 if all(check.ok for check in checks) else 1
if command == "install-gui-path":
if sys.platform != "darwin":
print("gh-forgejo-shim: install-gui-path is only supported on macOS", file=sys.stderr)
return 1
result = 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 0
if command == "uninstall-gui-path":
if sys.platform != "darwin":
print("gh-forgejo-shim: uninstall-gui-path is only supported on macOS", file=sys.stderr)
return 1
path = 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 0
if command == "config":
return run_config(namespace)
16 unmodified lines
uninstall = 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")
src/gh_forgejo_shim/doctor.py
-3+33
1
2
3
4
5
6
7
8
9
10
11
12
13
10 unmodified lines
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38 unmodified lines
76
77
78
79
80
81
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Mapping
from .auth import discover_fj_token
from .config import Config, load_config
from .external import find_program
from .shim import default_bin_dir, is_managed_shim, shim_path
10 unmodified lines
env: 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.environ
cfg = 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 lines
ok = False
checks.append(Check("shim path", ok, detail))
return checks
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
10 unmodified lines
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
38 unmodified lines
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
from __future__ import annotations
import os
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Mapping, Sequence
from .auth import discover_fj_token
from .config import Config, load_config
from .external import find_program
from .gui_path import current_launchd_path, path_contains_dir
from .shim import default_bin_dir, is_managed_shim, shim_path
10 unmodified lines
env: 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.environ
cfg = 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 lines
ok = False
checks.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
src/gh_forgejo_shim/external.py
-4+33
2 unmodified lines
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
2 unmodified lines
21
22
23
24
25
26
27
28
29
3 unmodified lines
33
34
35
36
37
38
2 unmodified lines
import os
import subprocess
from pathlib import Path
from typing import Mapping
from .shim import is_managed_shim
def find_program(
name: str,
*,
configured: str | None = None,
env: Mapping[str, str] | None = None,
) -> str | None:
if configured:
path = Path(configured).expanduser()
2 unmodified lines
return None
values = env if env is not None else os.environ
for directory in values.get("PATH", "").split(os.pathsep):
if not directory:
continue
candidate = Path(directory).expanduser() / name
if not candidate.exists() or not os.access(candidate, os.X_OK):
continue
3 unmodified lines
return None
def run_program(path: str, argv: list[str]) -> int:
try:
return subprocess.call([path, *argv])
2 unmodified lines
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
2 unmodified lines
35
36
37
38
39
40
41
3 unmodified lines
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
2 unmodified lines
import os
import subprocess
from pathlib import Path
from typing import Mapping, Sequence
from .shim import is_managed_shim
DEFAULT_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 lines
return None
values = env if env is not None else os.environ
for directory in _candidate_dirs(values, fallback_dirs):
candidate = Path(directory).expanduser() / name
if not candidate.exists() or not os.access(candidate, os.X_OK):
continue
3 unmodified lines
return None
def _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:
continue
if directory.startswith("~/") and home:
directory = str(Path(home) / directory[2:])
normalized = str(Path(directory).expanduser())
if normalized in seen:
continue
seen.add(normalized)
result.append(normalized)
return result
def run_program(path: str, argv: list[str]) -> int:
try:
return subprocess.call([path, *argv])
tests/test_doctor.py
+36
16 unmodified lines
17
18
19
20
21
22
4 unmodified lines
27
28
29
30
31
32
76 unmodified lines
109
110
111
112
113
114
16 unmodified lines
config=Config(hosts=("git.example.com",)),
env={"PATH": ""},
home=Path(tmp),
)
self.assertFalse(self._check(checks, "real gh").ok)
4 unmodified lines
config=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 / name
path.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
16 unmodified lines
17
18
19
20
21
22
23
4 unmodified lines
28
29
30
31
32
33
34
76 unmodified lines
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
16 unmodified lines
config=Config(hosts=("git.example.com",)),
env={"PATH": ""},
home=Path(tmp),
fallback_dirs=(),
)
self.assertFalse(self._check(checks, "real gh").ok)
4 unmodified lines
config=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 / name
path.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

Issues, Limitations, and Mitigations

Follow-up Work