3 Commits

Author SHA1 Message Date
AJ Avezzano 6f03b50a43 ci: disable generate_release_notes (unsupported on Gitea)
Build & Release / Build (Linux) (push) Successful in 46s
Build & Release / Build (Windows) (push) Successful in 1m8s
Build & Release / Build (macOS) (push) Successful in 1m35s
Build & Release / Publish Release (push) Failing after 5s
2026-06-29 20:38:00 -04:00
AJ Avezzano 335a26a2d6 Fix CI: platform-specific Python setup and artifact actions v3 for Gitea
- Use actions/setup-python only on Linux (container); macOS/Windows create
  a venv from pre-installed python3.12 to avoid privileged install failures
- Downgrade upload/download-artifact to v3 (v4 uses an API Gitea doesn't support)
- Remove merge-multiple (v4-only option) from download-artifact
- Change release files glob to artifacts/**/* (v3 nests each artifact in a subdir)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 19:14:28 -04:00
AJ Avezzano 2e33850fe3 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>
2026-06-29 15:34:18 -04:00
3 changed files with 241 additions and 49 deletions
+20 -6
View File
@@ -31,11 +31,26 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Python 3.12 - name: Set up Python 3.12 (Linux)
if: runner.os == 'Linux'
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: "3.12" python-version: "3.12"
- name: Set up Python venv (macOS)
if: runner.os == 'macOS'
run: |
PYBIN="$(command -v python3.12 || echo /opt/homebrew/bin/python3.12)"
"$PYBIN" -m venv .venv
echo "$PWD/.venv/bin" >> "$GITHUB_PATH"
- name: Set up Python venv (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
py -3.12 -m venv .venv
Add-Content -Path $env:GITHUB_PATH -Value "$env:GITHUB_WORKSPACE\.venv\Scripts"
- name: Install dependencies - name: Install dependencies
run: pip install -r requirements-dev.txt run: pip install -r requirements-dev.txt
@@ -75,7 +90,7 @@ jobs:
# ── Upload artifact for the release job ────────────────────────────── # ── Upload artifact for the release job ──────────────────────────────
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v3
with: with:
name: ${{ matrix.artifact }} name: ${{ matrix.artifact }}
path: ${{ matrix.artifact }} path: ${{ matrix.artifact }}
@@ -93,10 +108,9 @@ jobs:
steps: steps:
- name: Download all artifacts - name: Download all artifacts
uses: actions/download-artifact@v4 uses: actions/download-artifact@v3
with: with:
path: artifacts path: artifacts
merge-multiple: true
- name: Create GitHub Release - name: Create GitHub Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
@@ -104,8 +118,8 @@ jobs:
name: Better Claude Config ${{ github.ref_name }} name: Better Claude Config ${{ github.ref_name }}
draft: false draft: false
prerelease: false prerelease: false
generate_release_notes: true generate_release_notes: false
files: artifacts/* files: artifacts/**/*
body: | body: |
## Better Claude Config ${{ github.ref_name }} ## Better Claude Config ${{ github.ref_name }}
+121 -22
View File
@@ -103,9 +103,12 @@ class ConnTester(QThread):
# Small reusable: key/value editor (for env and headers) # Small reusable: key/value editor (for env and headers)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
class KeyValueTable(QWidget): class KeyValueTable(QWidget):
def __init__(self, key_label="Key", val_label="Value", on_change=None): def __init__(self, key_label="Key", val_label="Value", on_change=None, before_change=None):
super().__init__() super().__init__()
self._on_change = on_change self._on_change = on_change
self._before_change = before_change
self._key_label = key_label
self._val_label = val_label
lay = QVBoxLayout(self) lay = QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0) lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(6) lay.setSpacing(6)
@@ -133,17 +136,69 @@ class KeyValueTable(QWidget):
self._on_change() self._on_change()
def _add_row(self): def _add_row(self):
dlg = QDialog(self.window())
dlg.setWindowTitle(f"Add {self._key_label}")
dlg.setMinimumWidth(340)
v = QVBoxLayout(dlg)
v.setSpacing(10)
grid = QGridLayout()
grid.setSpacing(8)
lbl_k = QLabel(f"{self._key_label}:")
lbl_k.setObjectName("muted")
grid.addWidget(lbl_k, 0, 0)
key_edit = QLineEdit()
key_edit.setPlaceholderText(
"e.g. API_KEY" if self._key_label == "Variable" else f"{self._key_label.lower()}-name"
)
grid.addWidget(key_edit, 0, 1)
lbl_v = QLabel(f"{self._val_label}:")
lbl_v.setObjectName("muted")
grid.addWidget(lbl_v, 1, 0)
val_edit = QLineEdit()
val_edit.setPlaceholderText("value")
grid.addWidget(val_edit, 1, 1)
v.addLayout(grid)
btns = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
)
ok_btn = btns.button(QDialogButtonBox.StandardButton.Ok)
ok_btn.setObjectName("primary")
ok_btn.setEnabled(False)
key_edit.textChanged.connect(lambda t: ok_btn.setEnabled(bool(t.strip())))
btns.accepted.connect(dlg.accept)
btns.rejected.connect(dlg.reject)
v.addWidget(btns)
key_edit.setFocus()
# Enter in key field moves to value; Enter in value field submits
key_edit.returnPressed.connect(val_edit.setFocus)
val_edit.returnPressed.connect(lambda: ok_btn.click() if ok_btn.isEnabled() else None)
if dlg.exec() != QDialog.DialogCode.Accepted:
return
k = key_edit.text().strip()
val = val_edit.text()
if not k:
return
if self._before_change:
self._before_change()
r = self.table.rowCount() r = self.table.rowCount()
self.table.insertRow(r) self.table.insertRow(r)
self.table.setItem(r, 0, QTableWidgetItem("")) self.table.setItem(r, 0, QTableWidgetItem(k))
self.table.setItem(r, 1, QTableWidgetItem("")) self.table.setItem(r, 1, QTableWidgetItem(val))
self._changed() self._changed()
def _remove_row(self): def _remove_row(self):
r = self.table.currentRow() r = self.table.currentRow()
if r >= 0: if r < 0:
self.table.removeRow(r) return
self._changed() if self._before_change:
self._before_change()
self.table.removeRow(r)
self._changed()
def load(self, d: dict): def load(self, d: dict):
self.table.blockSignals(True) self.table.blockSignals(True)
@@ -171,10 +226,11 @@ class KeyValueTable(QWidget):
# The server editor panel (right side) # The server editor panel (right side)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
class ServerEditor(QFrame): class ServerEditor(QFrame):
def __init__(self, on_change): def __init__(self, on_change, before_change=None):
super().__init__() super().__init__()
self.setObjectName("card") self.setObjectName("card")
self._on_change = on_change self._on_change = on_change
self._before_change = before_change # called before any row add/remove in tables
self._extra = {} # preserve unknown server fields verbatim self._extra = {} # preserve unknown server fields verbatim
self._loading = False self._loading = False
@@ -279,7 +335,8 @@ class ServerEditor(QFrame):
self.args.textChanged.connect(self._emit) self.args.textChanged.connect(self._emit)
v.addWidget(self.args) v.addWidget(self.args)
v.addWidget(self._lbl("Environment variables")) v.addWidget(self._lbl("Environment variables"))
self.env = KeyValueTable("Variable", "Value", on_change=self._emit) self.env = KeyValueTable("Variable", "Value", on_change=self._emit,
before_change=self._before_change)
v.addWidget(self.env) v.addWidget(self.env)
return w return w
@@ -299,7 +356,8 @@ class ServerEditor(QFrame):
self.transport.currentIndexChanged.connect(self._emit) self.transport.currentIndexChanged.connect(self._emit)
v.addWidget(self.transport) v.addWidget(self.transport)
v.addWidget(self._lbl("Headers")) v.addWidget(self._lbl("Headers"))
self.headers = KeyValueTable("Header", "Value", on_change=self._emit) self.headers = KeyValueTable("Header", "Value", on_change=self._emit,
before_change=self._before_change)
v.addWidget(self.headers) v.addWidget(self.headers)
return w return w
@@ -447,10 +505,10 @@ class ServerEditor(QFrame):
if not url: if not url:
return return
self.test_btn.setEnabled(False) self.test_btn.setEnabled(False)
self.test_btn.setText("Testing…") self.test_btn.setText("Testing...")
self.dep_dot.setText("") self.dep_dot.setText("")
self.dep_dot.setStyleSheet(f"color: {MUTED}; font-size: 14px;") self.dep_dot.setStyleSheet(f"color: {MUTED}; font-size: 14px;")
self.dep_label.setText("testing reachability") self.dep_label.setText("testing reachability...")
self.dep_label.setStyleSheet(f"color: {MUTED};") self.dep_label.setStyleSheet(f"color: {MUTED};")
self._tester = ConnTester(url, timeout=6.0) self._tester = ConnTester(url, timeout=6.0)
self._tester.done.connect(self._on_test_done) self._tester.done.connect(self._on_test_done)
@@ -478,14 +536,14 @@ class PasteDialog(QDialog):
v = QVBoxLayout(self) v = QVBoxLayout(self)
info = QLabel( info = QLabel(
"Paste a config snippet from any MCP doc. Accepts a full " "Paste a config snippet from any MCP doc. Accepts a full "
"{\"mcpServers\": {}} block, just the inner {\"name\": {}} map, " "{\"mcpServers\": {...}} block, just the inner {\"name\": {...}} map, "
"or a single bare server object." "or a single bare server object."
) )
info.setObjectName("muted") info.setObjectName("muted")
info.setWordWrap(True) info.setWordWrap(True)
v.addWidget(info) v.addWidget(info)
self.box = QPlainTextEdit() self.box = QPlainTextEdit()
self.box.setPlaceholderText('{\n "mcpServers": {\n "brave-search": {\n "command": "npx",\n "args": ["-y", "@modelcontextprotocol/server-brave-search"],\n "env": {"BRAVE_API_KEY": ""}\n }\n }\n}') self.box.setPlaceholderText('{\n "mcpServers": {\n "brave-search": {\n "command": "npx",\n "args": ["-y", "@modelcontextprotocol/server-brave-search"],\n "env": {"BRAVE_API_KEY": "..."}\n }\n }\n}')
v.addWidget(self.box, 1) v.addWidget(self.box, 1)
self.err = QLabel("") self.err = QLabel("")
self.err.setStyleSheet(f"color: {BAD};") self.err.setStyleSheet(f"color: {BAD};")
@@ -526,6 +584,7 @@ class MainWindow(QMainWindow):
self._suppress_sel = False self._suppress_sel = False
self._focused_table = None self._focused_table = None
self._row_of_index: dict[int, tuple] = {} self._row_of_index: dict[int, tuple] = {}
self._undo_stack: list[list] = [] # each entry: snapshot of self.servers
central = QWidget() central = QWidget()
self.setCentralWidget(central) self.setCentralWidget(central)
@@ -538,7 +597,8 @@ class MainWindow(QMainWindow):
split = QHBoxLayout() split = QHBoxLayout()
split.setSpacing(12) split.setSpacing(12)
split.addWidget(self._build_left(), 3) split.addWidget(self._build_left(), 3)
self.editor = ServerEditor(on_change=self._editor_changed) self.editor = ServerEditor(on_change=self._editor_changed,
before_change=self._push_undo)
split.addWidget(self.editor, 4) split.addWidget(self.editor, 4)
root.addLayout(split, 1) root.addLayout(split, 1)
@@ -560,7 +620,7 @@ class MainWindow(QMainWindow):
self.profile_combo.setMinimumWidth(220) self.profile_combo.setMinimumWidth(220)
self.profile_combo.currentIndexChanged.connect(self._profile_selected) self.profile_combo.currentIndexChanged.connect(self._profile_selected)
bar.addWidget(self.profile_combo) bar.addWidget(self.profile_combo)
add_cfg = QPushButton("Add config") add_cfg = QPushButton("Add config...")
add_cfg.clicked.connect(self.add_custom_config) add_cfg.clicked.connect(self.add_custom_config)
bar.addWidget(add_cfg) bar.addWidget(add_cfg)
reload_btn = QPushButton("Reload") reload_btn = QPushButton("Reload")
@@ -632,20 +692,51 @@ class MainWindow(QMainWindow):
self.dup_btn = QPushButton("Duplicate") self.dup_btn = QPushButton("Duplicate")
self.del_btn = QPushButton("Delete") self.del_btn = QPushButton("Delete")
self.del_btn.setObjectName("danger") self.del_btn.setObjectName("danger")
self.paste_btn = QPushButton("Paste JSON") self.paste_btn = QPushButton("Paste JSON...")
self.copy_btn = QPushButton("Copy to ▸") self.copy_btn = QPushButton("Copy to ▸")
self.undo_btn = QPushButton("Undo")
self.undo_btn.setEnabled(False)
self.undo_btn.setToolTip("Undo last change (Ctrl+Z)")
self.add_btn.clicked.connect(self.add_server) self.add_btn.clicked.connect(self.add_server)
self.dup_btn.clicked.connect(self.duplicate_server) self.dup_btn.clicked.connect(self.duplicate_server)
self.del_btn.clicked.connect(self.delete_server) self.del_btn.clicked.connect(self.delete_server)
self.paste_btn.clicked.connect(self.paste_json) self.paste_btn.clicked.connect(self.paste_json)
self.copy_btn.clicked.connect(self.copy_to_menu) self.copy_btn.clicked.connect(self.copy_to_menu)
for b in (self.add_btn, self.dup_btn, self.del_btn, self.paste_btn, self.copy_btn): self.undo_btn.clicked.connect(self._undo)
for b in (self.add_btn, self.dup_btn, self.del_btn, self.paste_btn,
self.copy_btn, self.undo_btn):
bar.addWidget(b) bar.addWidget(b)
# Ctrl+Z shortcut
undo_action = QAction(self)
undo_action.setShortcut("Ctrl+Z")
undo_action.triggered.connect(self._undo)
self.addAction(undo_action)
bar.addStretch() bar.addStretch()
self.validation_lbl = QLabel("") self.validation_lbl = QLabel("")
bar.addWidget(self.validation_lbl) bar.addWidget(self.validation_lbl)
return bar return bar
# --- undo ------------------------------------------------------------ #
def _push_undo(self):
"""Snapshot the current server list onto the undo stack."""
self._undo_stack.append(
[core.ServerEntry(s.name, dict(s.data), s.enabled) for s in self.servers]
)
if len(self._undo_stack) > 50:
self._undo_stack.pop(0)
self.undo_btn.setEnabled(True)
def _undo(self):
if not self._undo_stack:
return
self.servers = self._undo_stack.pop()
self.undo_btn.setEnabled(bool(self._undo_stack))
cur = self._current_index()
sel = cur if 0 <= cur < len(self.servers) else (0 if self.servers else -1)
self._refresh_tables(select_index=sel)
self._mark_dirty()
self.status.setText("Undone.")
# --- profiles -------------------------------------------------------- # # --- profiles -------------------------------------------------------- #
def reload_profiles(self): def reload_profiles(self):
discovered = core.discover_profiles() discovered = core.discover_profiles()
@@ -664,7 +755,7 @@ class MainWindow(QMainWindow):
self.profile_combo.setCurrentIndex(0) self.profile_combo.setCurrentIndex(0)
self.load_profile(self.profiles[0]) self.load_profile(self.profiles[0])
else: else:
self.status.setText("No Claude installs found. Use Add config… to point at one.") self.status.setText('No Claude installs found. Use "Add config…" to point at one.')
def add_custom_config(self): def add_custom_config(self):
start = str(core.app_support_base()) start = str(core.app_support_base())
@@ -702,6 +793,8 @@ class MainWindow(QMainWindow):
self.current_profile = profile self.current_profile = profile
self.servers = core.extract_servers(self.full_config) self.servers = core.extract_servers(self.full_config)
self.dirty = False self.dirty = False
self._undo_stack.clear()
self.undo_btn.setEnabled(False)
self._refresh_tables(select_index=0 if self.servers else -1) self._refresh_tables(select_index=0 if self.servers else -1)
self._update_status(saved=False) self._update_status(saved=False)
@@ -841,6 +934,7 @@ class MainWindow(QMainWindow):
idx = name_item.data(Qt.ItemDataRole.UserRole) idx = name_item.data(Qt.ItemDataRole.UserRole)
if idx is None or not (0 <= idx < len(self.servers)): if idx is None or not (0 <= idx < len(self.servers)):
return return
self._push_undo()
self.servers[idx].enabled = item.checkState() == Qt.CheckState.Checked self.servers[idx].enabled = item.checkState() == Qt.CheckState.Checked
# Toggling moves the server to the other section; keep it selected there. # Toggling moves the server to the other section; keep it selected there.
self._refresh_tables(select_index=idx) self._refresh_tables(select_index=idx)
@@ -872,6 +966,7 @@ class MainWindow(QMainWindow):
# --- server actions -------------------------------------------------- # # --- server actions -------------------------------------------------- #
def add_server(self): def add_server(self):
self._push_undo()
base = "new-server" base = "new-server"
name = base name = base
n = 2 n = 2
@@ -889,6 +984,7 @@ class MainWindow(QMainWindow):
idx = self._current_index() idx = self._current_index()
if not (0 <= idx < len(self.servers)): if not (0 <= idx < len(self.servers)):
return return
self._push_undo()
src = self.servers[idx] src = self.servers[idx]
existing = {s.name for s in self.servers} existing = {s.name for s in self.servers}
name = f"{src.name}-copy" name = f"{src.name}-copy"
@@ -905,8 +1001,9 @@ class MainWindow(QMainWindow):
if not (0 <= idx < len(self.servers)): if not (0 <= idx < len(self.servers)):
return return
name = self.servers[idx].name name = self.servers[idx].name
if QMessageBox.question(self, "Delete server", f"Remove “{name}”?") != QMessageBox.StandardButton.Yes: if QMessageBox.question(self, "Delete server", f'Remove “{name}”?') != QMessageBox.StandardButton.Yes:
return return
self._push_undo()
del self.servers[idx] del self.servers[idx]
self._refresh_tables(select_index=min(idx, len(self.servers) - 1)) self._refresh_tables(select_index=min(idx, len(self.servers) - 1))
self._mark_dirty() self._mark_dirty()
@@ -915,13 +1012,14 @@ class MainWindow(QMainWindow):
dlg = PasteDialog(self) dlg = PasteDialog(self)
if dlg.exec() != QDialog.DialogCode.Accepted or not dlg.result_servers: if dlg.exec() != QDialog.DialogCode.Accepted or not dlg.result_servers:
return return
self._push_undo()
added, replaced = 0, 0 added, replaced = 0, 0
existing = {s.name: i for i, s in enumerate(self.servers)} existing = {s.name: i for i, s in enumerate(self.servers)}
for name, data in dlg.result_servers.items(): for name, data in dlg.result_servers.items():
if name in existing: if name in existing:
ans = QMessageBox.question( ans = QMessageBox.question(
self, "Server exists", self, "Server exists",
f"{name}” already exists. Replace it?\n\nYes = replace · No = keep both (renamed)", f'{name}” already exists. Replace it?\n\nYes = replace · No = keep both (renamed)',
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
) )
if ans == QMessageBox.StandardButton.Yes: if ans == QMessageBox.StandardButton.Yes:
@@ -969,7 +1067,7 @@ class MainWindow(QMainWindow):
if src.name in names: if src.name in names:
ans = QMessageBox.question( ans = QMessageBox.question(
self, "Exists in destination", self, "Exists in destination",
f"{src.name}” already exists in {dest.label}. Overwrite it there?" f'{src.name}” already exists in {dest.label}. Overwrite it there?'
) )
if ans != QMessageBox.StandardButton.Yes: if ans != QMessageBox.StandardButton.Yes:
return return
@@ -982,7 +1080,7 @@ class MainWindow(QMainWindow):
QMessageBox.critical(self, "Copy failed", f"Couldn't write {dest.label}:\n{e}") QMessageBox.critical(self, "Copy failed", f"Couldn't write {dest.label}:\n{e}")
return return
bnote = f" (backup: {backup.name})" if backup else "" bnote = f" (backup: {backup.name})" if backup else ""
self.status.setText(f"Copied “{src.name}” → {dest.label}{bnote}. Restart that Claude to apply.") self.status.setText(f'Copied “{src.name}” → {dest.label}{bnote}. Restart that Claude to apply.')
# --- save ------------------------------------------------------------ # # --- save ------------------------------------------------------------ #
def _validate(self) -> bool: def _validate(self) -> bool:
@@ -1052,6 +1150,7 @@ class MainWindow(QMainWindow):
except Exception as ex: except Exception as ex:
QMessageBox.warning(self, "Couldn't import", f"{Path(path).name}:\n{ex}") QMessageBox.warning(self, "Couldn't import", f"{Path(path).name}:\n{ex}")
continue continue
self._push_undo()
for name, data in servers.items(): for name, data in servers.items():
names = {s.name for s in self.servers} names = {s.name for s in self.servers}
if name in names: if name in names:
+100 -21
View File
@@ -289,35 +289,109 @@ def validate_servers(servers: list[ServerEntry]) -> list[str]:
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Dependency / PATH checking # 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) @functools.lru_cache(maxsize=1)
def augmented_path() -> str: def augmented_path() -> str:
""" """
PATH + the usual runtime install locations. GUI apps (especially bundled The widest PATH BCC searches when looking for a command — system_path()
.app launches) often don't inherit the shell PATH, so npx/uvx can appear plus user-specific runtime locations (nvm, cargo, volta, bun, etc.) that
'missing' when they're actually fine. This widens the net. require shell configuration to be active.
Memoized for the session (PATH rarely changes mid-run). Call Memoized for the session. Call refresh_path_cache() to re-scan, e.g.
refresh_path_cache() after the user installs something to re-scan. 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() home = Path.home()
extra = [ user_dirs = [
"/usr/local/bin", "/opt/homebrew/bin", "/usr/bin", "/bin",
str(home / ".local" / "bin"), str(home / ".local" / "bin"),
str(home / ".cargo" / "bin"), str(home / ".cargo" / "bin"),
str(home / ".deno" / "bin"), str(home / ".deno" / "bin"),
str(home / ".bun" / "bin"), str(home / ".bun" / "bin"),
str(home / ".volta" / "bin"), str(home / ".volta" / "bin"),
] ]
extra += glob.glob(str(home / ".nvm" / "versions" / "node" / "*" / "bin")) user_dirs += 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 / ".local" / "share" / "fnm" / "node-versions" / "*" / "installation" / "bin"))
if os.name == "nt": if os.name == "nt":
appdata = os.environ.get("APPDATA", "") appdata = os.environ.get("APPDATA", "")
if appdata: if appdata:
extra.append(str(Path(appdata) / "npm")) user_dirs.append(str(Path(appdata) / "npm"))
seen, ordered = set(), [] 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(): if p and p not in seen and Path(p).is_dir():
seen.add(p) seen.add(p)
ordered.append(p) ordered.append(p)
@@ -325,7 +399,8 @@ def augmented_path() -> str:
def refresh_path_cache(): 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() augmented_path.cache_clear()
@@ -411,7 +486,10 @@ def check_dependency(data: dict, path: str | None = None) -> dict:
in_base[c] = ok in_base[c] = ok
else: else:
resolved[c] = shutil.which(c, path=aug) 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] missing = [c for c, r in resolved.items() if not r]
hints: list[str] = [] 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" label = "found, but only on a non-standard PATH"
for c in nonbase: for c in nonbase:
hints.append( hints.append(
f"'{c}' was found at {resolved[c]}, but only via a location this app added on " f"'{c}' was found at {resolved[c]}, but that location is not in the standard "
f"top of the inherited PATH. If you launch Claude Desktop as an app (not from a " f"system PATH. Claude Desktop launched from Finder or the dock may not see it. "
f"terminal), it may not see this. Putting the full path in 'command' is the reliable fix." f"Clicking 'Use full path ↳' rewrites the command to its absolute path, which "
f"works regardless of how Claude is launched."
) )
else: else:
status = "ok" status = "ok"
@@ -475,17 +554,17 @@ def diagnostics_text(name: str, data: dict) -> str:
for h in res["hints"]: for h in res["hints"]:
L.append(f" - {h}") 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 [] 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("")
L.append(f"PATH searched ({len(aug_dirs)} dirs):") L.append(f"PATH searched ({len(aug_dirs)} dirs):")
for d in aug_dirs: for d in aug_dirs:
L.append((" + " if d in extra else " ") + d) L.append((" + " if d in extra else " ") + d)
if extra: if extra:
L.append("") L.append("")
L.append("(+ = added by this app on top of the PATH it inherited. Claude Desktop, " L.append("(+ = user-specific dirs not in the standard system PATH. "
"launched separately, may NOT have these on its PATH.)") "Claude Desktop launched from Finder may NOT have these.)")
return "\n".join(L) return "\n".join(L)