Fix PATH check, add undo, and popup dialog for env/header vars
PATH check: - Add system_path() that reads /etc/paths + /etc/paths.d/* + well-known package manager dirs (/opt/homebrew/bin, /usr/local/bin, /opt/local/bin) so the ok/warn distinction is consistent whether BCC runs from terminal or as a .app — /opt/homebrew/bin/npx now correctly shows as ok - augmented_path() now uses system_path() as its base and also includes the current process PATH (handles CI / container setups) - diagnostics_text() uses system_path() to mark user-specific dirs with + Undo: - _push_undo() / _undo() snapshot-based undo with 50-entry stack - Undo button in action bar + Ctrl+Z shortcut - Hooked into: add server, duplicate, delete, paste JSON, enable/disable toggle, drag-drop import, and env/header row add/remove (via before_change callback chain through ServerEditor → KeyValueTable) - Undo stack resets on profile load Variable/header entry: - KeyValueTable.+ Add now opens a popup dialog with labeled fields (key name + OK disabled until non-empty) instead of inserting an invisible blank row — applies consistently to both env and headers tables - Pressing Enter in key field moves to value; Enter in value submits Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+100
-21
@@ -289,35 +289,109 @@ def validate_servers(servers: list[ServerEntry]) -> list[str]:
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Dependency / PATH checking
|
||||
# --------------------------------------------------------------------------- #
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def system_path() -> str:
|
||||
"""
|
||||
The PATH that a GUI app (e.g., Claude Desktop launched from Finder/dock)
|
||||
reliably inherits, independent of how BCC itself was started.
|
||||
|
||||
This is used to determine the 'ok' vs 'warn' distinction:
|
||||
ok = found in system_path() → works however Claude is launched
|
||||
warn = found only in augmented_path() → works from a terminal but may
|
||||
not work when Claude is opened from the desktop
|
||||
|
||||
macOS : /etc/paths + /etc/paths.d/* (what launchd provides) plus
|
||||
well-known package-manager prefixes (/opt/homebrew, /opt/local).
|
||||
Windows: system + user PATH from the registry.
|
||||
Linux : /etc/environment + standard FHS dirs + /snap/bin.
|
||||
"""
|
||||
dirs: list[str] = []
|
||||
|
||||
if sys.platform == "darwin":
|
||||
for p in ["/etc/paths"] + sorted(glob.glob("/etc/paths.d/*")):
|
||||
try:
|
||||
dirs.extend(ln.strip() for ln in Path(p).read_text().splitlines() if ln.strip())
|
||||
except OSError:
|
||||
pass
|
||||
# Package-manager install prefixes: present on disk once installed,
|
||||
# accessible to all processes regardless of shell configuration.
|
||||
for d in (
|
||||
"/opt/homebrew/bin", "/opt/homebrew/sbin", # Homebrew (Apple Silicon)
|
||||
"/usr/local/bin", "/usr/local/sbin", # Homebrew (Intel) / manual installs
|
||||
"/opt/local/bin", "/opt/local/sbin", # MacPorts
|
||||
):
|
||||
dirs.append(d)
|
||||
|
||||
elif os.name == "nt":
|
||||
try:
|
||||
import winreg
|
||||
for hive, sub in [
|
||||
(winreg.HKEY_LOCAL_MACHINE,
|
||||
r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment"),
|
||||
(winreg.HKEY_CURRENT_USER, r"Environment"),
|
||||
]:
|
||||
try:
|
||||
with winreg.OpenKey(hive, sub) as k:
|
||||
val, _ = winreg.QueryValueEx(k, "PATH")
|
||||
dirs.extend(os.path.expandvars(val).split(os.pathsep))
|
||||
except OSError:
|
||||
pass
|
||||
except ImportError:
|
||||
pass
|
||||
dirs.extend(["C:\\Windows\\System32", "C:\\Windows",
|
||||
"C:\\Windows\\System32\\Wbem",
|
||||
os.path.expandvars(r"%ProgramFiles%\nodejs")])
|
||||
|
||||
else: # Linux / other
|
||||
try:
|
||||
for line in Path("/etc/environment").read_text().splitlines():
|
||||
if line.upper().startswith("PATH="):
|
||||
dirs.extend(line[5:].strip('"\'').split(os.pathsep))
|
||||
except OSError:
|
||||
pass
|
||||
for d in ("/usr/local/bin", "/usr/bin", "/bin",
|
||||
"/usr/sbin", "/sbin", "/snap/bin"):
|
||||
dirs.append(d)
|
||||
|
||||
seen, out = set(), []
|
||||
for d in dirs:
|
||||
d = d.strip()
|
||||
if d and d not in seen:
|
||||
seen.add(d)
|
||||
out.append(d)
|
||||
return os.pathsep.join(out)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def augmented_path() -> str:
|
||||
"""
|
||||
PATH + the usual runtime install locations. GUI apps (especially bundled
|
||||
.app launches) often don't inherit the shell PATH, so npx/uvx can appear
|
||||
'missing' when they're actually fine. This widens the net.
|
||||
The widest PATH BCC searches when looking for a command — system_path()
|
||||
plus user-specific runtime locations (nvm, cargo, volta, bun, etc.) that
|
||||
require shell configuration to be active.
|
||||
|
||||
Memoized for the session (PATH rarely changes mid-run). Call
|
||||
refresh_path_cache() after the user installs something to re-scan.
|
||||
Memoized for the session. Call refresh_path_cache() to re-scan, e.g.
|
||||
after the user installs a new runtime.
|
||||
"""
|
||||
parts = os.environ.get("PATH", "").split(os.pathsep)
|
||||
base = system_path().split(os.pathsep)
|
||||
# Also include the current process PATH (catches unusual CI / container setups)
|
||||
env_parts = os.environ.get("PATH", "").split(os.pathsep)
|
||||
home = Path.home()
|
||||
extra = [
|
||||
"/usr/local/bin", "/opt/homebrew/bin", "/usr/bin", "/bin",
|
||||
user_dirs = [
|
||||
str(home / ".local" / "bin"),
|
||||
str(home / ".cargo" / "bin"),
|
||||
str(home / ".deno" / "bin"),
|
||||
str(home / ".bun" / "bin"),
|
||||
str(home / ".volta" / "bin"),
|
||||
]
|
||||
extra += glob.glob(str(home / ".nvm" / "versions" / "node" / "*" / "bin"))
|
||||
extra += glob.glob(str(home / ".local" / "share" / "fnm" / "node-versions" / "*" / "installation" / "bin"))
|
||||
user_dirs += glob.glob(str(home / ".nvm" / "versions" / "node" / "*" / "bin"))
|
||||
user_dirs += glob.glob(str(home / ".local" / "share" / "fnm" / "node-versions" / "*" / "installation" / "bin"))
|
||||
if os.name == "nt":
|
||||
appdata = os.environ.get("APPDATA", "")
|
||||
if appdata:
|
||||
extra.append(str(Path(appdata) / "npm"))
|
||||
user_dirs.append(str(Path(appdata) / "npm"))
|
||||
|
||||
seen, ordered = set(), []
|
||||
for p in parts + extra:
|
||||
for p in base + env_parts + user_dirs:
|
||||
if p and p not in seen and Path(p).is_dir():
|
||||
seen.add(p)
|
||||
ordered.append(p)
|
||||
@@ -325,7 +399,8 @@ def augmented_path() -> str:
|
||||
|
||||
|
||||
def refresh_path_cache():
|
||||
"""Forget the memoized PATH so the next check re-scans (e.g. after an install)."""
|
||||
"""Forget memoized PATHs so the next check re-scans (e.g. after an install)."""
|
||||
system_path.cache_clear()
|
||||
augmented_path.cache_clear()
|
||||
|
||||
|
||||
@@ -411,7 +486,10 @@ def check_dependency(data: dict, path: str | None = None) -> dict:
|
||||
in_base[c] = ok
|
||||
else:
|
||||
resolved[c] = shutil.which(c, path=aug)
|
||||
in_base[c] = bool(shutil.which(c)) # raw inherited PATH only
|
||||
# 'in_base' means: found in system_path() — the PATH that Claude
|
||||
# Desktop (or any GUI app) reliably inherits regardless of shell config.
|
||||
# This check is consistent whether BCC runs from a terminal or as a .app.
|
||||
in_base[c] = bool(shutil.which(c, path=system_path()))
|
||||
|
||||
missing = [c for c, r in resolved.items() if not r]
|
||||
hints: list[str] = []
|
||||
@@ -428,9 +506,10 @@ def check_dependency(data: dict, path: str | None = None) -> dict:
|
||||
label = "found, but only on a non-standard PATH"
|
||||
for c in nonbase:
|
||||
hints.append(
|
||||
f"'{c}' was found at {resolved[c]}, but only via a location this app added on "
|
||||
f"top of the inherited PATH. If you launch Claude Desktop as an app (not from a "
|
||||
f"terminal), it may not see this. Putting the full path in 'command' is the reliable fix."
|
||||
f"'{c}' was found at {resolved[c]}, but that location is not in the standard "
|
||||
f"system PATH. Claude Desktop launched from Finder or the dock may not see it. "
|
||||
f"Clicking 'Use full path ↳' rewrites the command to its absolute path, which "
|
||||
f"works regardless of how Claude is launched."
|
||||
)
|
||||
else:
|
||||
status = "ok"
|
||||
@@ -475,17 +554,17 @@ def diagnostics_text(name: str, data: dict) -> str:
|
||||
for h in res["hints"]:
|
||||
L.append(f" - {h}")
|
||||
|
||||
base_dirs = [p for p in os.environ.get("PATH", "").split(os.pathsep) if p]
|
||||
sys_dirs = set(system_path().split(os.pathsep))
|
||||
aug_dirs = res["searched_path"].split(os.pathsep) if res["searched_path"] else []
|
||||
extra = [d for d in aug_dirs if d not in base_dirs]
|
||||
extra = [d for d in aug_dirs if d not in sys_dirs]
|
||||
L.append("")
|
||||
L.append(f"PATH searched ({len(aug_dirs)} dirs):")
|
||||
for d in aug_dirs:
|
||||
L.append((" + " if d in extra else " ") + d)
|
||||
if extra:
|
||||
L.append("")
|
||||
L.append("(+ = added by this app on top of the PATH it inherited. Claude Desktop, "
|
||||
"launched separately, may NOT have these on its PATH.)")
|
||||
L.append("(+ = user-specific dirs not in the standard system PATH. "
|
||||
"Claude Desktop launched from Finder may NOT have these.)")
|
||||
return "\n".join(L)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user