1 Commits
Author SHA1 Message Date
the_ogandClaude Opus 4.8 10834a343a feat: "Move to environment variable" — convert a plaintext secret to ${VAR} (#83)
CI / Lint (ruff) (pull_request) Successful in 10s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 23s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 19s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 17s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 18s
CI / Catalog signature (pull_request) Successful in 9s
Follow-up to #76/#82: BCC warns when a config holds a raw credential and
points at ${VAR}, but gave no way to make the change. This adds the
one-click conversion, right-click a secret row in the env or headers table.

The value is about to leave the file, so the action's real job is handing
the secret back before it does:

- Core (pure, tested): sanitize_env_var_name (key -> legal upper-case shell
  name; 'api-key' -> API_KEY, '2fa' -> _2FA, non-ASCII/empty handled),
  shell_export_lines (the exact export/setx line, POSIX single-quoted
  safely), move_value_to_env_ref (data in -> new data out, replaces one
  env/header/args value with ${VAR}, returns the removed secret; never
  mutates the input; None if the target is missing, non-string, or already a
  reference), can_move_value_to_env_ref (offer only a real stored secret, not
  already a ref, AND only on a client that expands references -- offering it
  on Claude Desktop would author a config that reaches the server as literal
  ${VAR}, the exact failure #76 exists to prevent), and is_env_var_set (skip
  the ceremony when the variable already looks set).

- GUI: KeyValueTable gains a context menu gated on can_move_value_to_env_ref
  (so it never appears on a non-secret row or a Claude Desktop profile).
  MoveToEnvDialog lets the user name the variable (defaulting to the
  sanitised key), shows the platform-appropriate shell line live, notes when
  the variable already looks set, and on accept copies the secret to the
  clipboard before the cell is replaced with the reference. Wired through
  ServerEditor.set_profile_provider so the tables know which client is loaded.

Scope note: env and headers rows for now. The core already handles args by
index; wiring the args editor (a free-text widget, not a table) is a small
follow-up, deliberately not bundled here.

Tests: +15 core (name sanitisation incl. non-ASCII/leading-digit/empty,
POSIX quote safety, the gate across secret/non-secret/already-ref/
non-expanding-client, env+headers+args rewrite, input-not-mutated,
missing/non-string/already-ref -> None, is_env_var_set). 478 passed, ruff
clean. GUI is untestable in CI (no PySide6); the decision logic all lives in
bcc_core and is tested there.

Closes #83

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKwBecy6N83jnqQmw8ezwE
2026-08-04 03:37:00 +00:00
7 changed files with 34 additions and 3520 deletions
-9
View File
@@ -3,16 +3,13 @@ name: CI
on:
push:
branches: [main]
paths-ignore: ["**/*.md"]
pull_request:
paths-ignore: ["**/*.md"]
workflow_dispatch:
jobs:
lint:
runs-on: ubuntu-latest
name: Lint (ruff)
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -34,11 +31,6 @@ jobs:
test:
runs-on: ${{ matrix.os }}
name: Tests (py${{ matrix.python }} / ${{ matrix.os }})
# Guards against a job that hangs mid-run (e.g. a wedged test). Note: this
# counts from when a runner PICKS UP the job, so it does not rescue a job
# stuck "Waiting to run" because the self-hosted Windows runner is offline —
# for that, bring the runner back or skip via paths-ignore (docs).
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
@@ -93,7 +85,6 @@ jobs:
catalog-signature:
name: Catalog signature
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
-91
View File
@@ -1,91 +0,0 @@
# Changelog
All notable changes to **BetterClaudeConfig** are recorded here. The format is
based on [Keep a Changelog](https://keepachangelog.com/), and this project
follows [Semantic Versioning](https://semver.org/): breaking changes bump the
major, new features bump the minor, fixes bump the patch.
**When you open a PR, add a line under `[Unreleased]`.** At release time, that
section is renamed to the new version + date and a fresh `[Unreleased]` is
started.
## [Unreleased]
## [1.4.0] — 2026-08-13
> ⚠️ **Using the `ssh-mcp` server?** Upstream `ssh-mcp` shipped a **breaking
> v2**: it removed the `--password`, `--sudoPassword`, `--suPassword` and
> `--disableSudo` command-line flags, and it now reads a `config.toml` sidecar
> file that overrides your command-line arguments. A config written for v1
> crashes on v2's startup. This release adds tools to detect and fix that.
> **BetterClaudeConfig itself has no breaking changes** — your existing configs
> keep working; nothing is changed without your click.
### Added
- **ssh-mcp v2 flag migration.** Detects the credential flags v2 removed and
one-click-moves each into the environment variable ssh-mcp now reads
(`--password` → `SSH_MCP_PASSWORD`; `--sudoPassword` / `--suPassword` →
`SSH_MCP_SUDO_PASSWORD`). Also flags `--maxChars=none`, whose meaning changed
between versions.
- **Sidecar-config awareness.** When a server reads a separate config file
(ssh-mcp's `config.toml`), BCC shows where that file actually lives on your
platform and warns when your command-line args are **inert** because the file
takes precedence — including when a file sits at the wrong, documented-but-
unused path.
- **In-app sidecar editor.** Edit that external config from inside BCC —
pick-lists for known fields, a raw-text fallback — written atomically with a
timestamped backup and locked to `0600`. No dropping to a terminal.
- **Live hot-reload.** External changes to a sidecar (or to your Claude config)
now surface without restarting BCC.
- **Version pinning for `npx` servers.** Spots servers launched with `-y` /
`@latest` that resolve a fresh version every run, shows the version currently
resolved, and offers a one-click **Pin to this version** — with a drift note
when a pin has fallen behind.
- **Permission pre-flight.** Warns when a credential-bearing config is readable
by other users on the machine and offers a one-click fix to `0600`/`0700`.
- **Light theme + system-following** (the dark theme is preserved exactly).
- **"Move to environment variable."** Convert a plaintext secret in a config
into a `${VAR}` reference in place — offered only on clients that actually
expand references, so it can't silently break a Claude Desktop config.
- **Cross-client foundation.** Claude Desktop and Claude Code now flow through a
single adapter — groundwork for supporting more clients.
### Changed
- The update checker is now **visible** — a persistent banner plus a Help-menu
item — instead of being buried in the About dialog.
- Config writes share one atomic-write path; the temp file is created `0600`, so
a secret is never briefly world-readable mid-write.
### Fixed
- Loading a config with a non-object server value no longer crashes, and named
server sets survive an external-change merge.
- Project profiles that share a directory basename are disambiguated, so you
can't accidentally edit the wrong `.mcp.json`.
### Internal / maintainer
- Signed-catalog core and a maintainer-only **Catalog Console** (review + sign),
with hardening of the review gate and a split of the signing keys. No
user-facing catalog browser ships yet.
## [1.3.0] — 2026-07-12
Named server sets, Claude Code project `.mcp.json` discovery, structural schema
lint, and UX polish (Ctrl+S to save, enable-all / disable-all).
## [1.2.1] — 2026-07-12
First shipped binaries: app-icon fix, a batch of audit fixes, and Windows
process-tree cleanup on spawn-tests.
## [1.2.0] — 2026-07-08
Server log viewer, duplicate-name conflict handling, a Restart-Claude button,
stale-file protection, an About dialog, and a notify-only update checker.
## [1.1.0] — 2026-07-04
Backup / restore UI and secret masking.
## [1.0.1] — 2026-06-29
## [1.0.0] — 2026-06-29
Initial releases: the core `mcpServers` editor with the lenient paste/repair
pipeline that tolerates malformed JSON.
<!-- Backfill for 1.0.0–1.3.0 is summarised from release notes; the Unreleased
section onward is maintained per-PR. -->
-50
View File
@@ -1,50 +0,0 @@
# PR #87 — "Move to environment variable" (#83): manual test checklist
The logic is covered by 15 unit tests in CI; what CI **can't** exercise is the GUI (no PySide6). This checklist is only the parts a human needs to click. Should take ~10 minutes.
## Setup
```bash
cd ~/Documents/Claude/Projects/BetterClaudeConfig/better-claude-config
git fetch origin
git checkout feat/83-move-to-env-var
git pull # ensure you're on 8fdbe90 or later
source .venv/bin/activate # or recreate: python3 -m venv .venv && source .venv/bin/activate && pip install -r requirements.txt
python bcc.py
```
Pick a **Claude Code** profile (e.g. `~/.claude.json`) that has, or add, a server with an env value that looks like a secret — e.g. `env: { "API_KEY": "ghp_test123" }`. (You can use a throwaway value; nothing is sent anywhere.)
## The checklist
### Gating — where the action appears
- [ ] Right-click the **value cell** of a secret env row (`API_KEY`) on a **Claude Code** profile → a **"Move to environment variable…"** item appears.
- [ ] Right-click a **non-secret** row (e.g. `REGION` = `us-east-1`) → the item does **not** appear.
- [ ] Right-click a row whose value is already a reference (`${API_KEY}`) → the item does **not** appear.
- [ ] Switch to a **Claude Desktop** profile (a `claude_desktop_config.json`), right-click the same kind of secret row → the item does **not** appear. (Desktop doesn't expand `${VAR}`, so offering it would break the config — this is the important gate.)
### The dialog
- [ ] Trigger the action → dialog opens with **Variable** pre-filled from the key, sanitized to a legal shell name (e.g. `api-key` → `API_KEY`).
- [ ] Edit the variable name → the shown **shell line updates live** and matches your platform (`export VAR='…'` on macOS/Linux, `setx VAR "…"` on Windows), with the other platform shown in parentheses.
- [ ] If you type a variable name that **is already set** in your shell environment, the green "already looks set" note appears; if not, it's hidden.
- [ ] **Cancel** → nothing changes (value still the raw secret, no dirty state).
### The conversion
- [ ] **Move && copy secret** → the cell now shows the reference `${VAR}` (visible, **not** masked to dots), and the window goes dirty (Save enabled).
- [ ] Paste from your clipboard somewhere → it's the **original secret value** (handed back before removal).
- [ ] The reference value is **not** flagged as a secret warning anymore (it's the recommended state).
### Headers + persistence
- [ ] Repeat on a **remote server's Headers** table (e.g. an `Authorization` header) → same behavior.
- [ ] **Save**, then open the config file on disk in a text editor → the servers block holds `${VAR}`, and the **plaintext secret is gone** from the file.
- [ ] Re-open the profile in BCC → the row still shows `${VAR}` (round-trips).
### Undo (nice-to-have)
- [ ] After a conversion, **Ctrl+Z / Cmd+Z** restores the previous value.
## Known scope (not bugs)
- **Args rows** are out of scope for this PR — the core supports them, but the args editor is a free-text widget, so wiring that UI is a deliberate follow-up. Right-clicking args won't offer the action yet.
- The "already set" check reads **BCC's** environment, which may differ from the client's — it's advisory, worded that way.
## If anything's off
Tell me which checkbox failed and what you saw; I'll fix on the branch and re-push. If everything passes, approve/merge #87 (or tell me to merge it).
+9 -814
View File
@@ -16,18 +16,7 @@ import time
from pathlib import Path
from typing import ClassVar
from PySide6.QtCore import (
QEvent,
QFileSystemWatcher,
QRect,
QSettings,
QSize,
Qt,
QThread,
QTimer,
QUrl,
Signal,
)
from PySide6.QtCore import QRect, QSettings, QSize, Qt, QThread, QTimer, QUrl, Signal
from PySide6.QtGui import (
QAction,
QActionGroup,
@@ -49,10 +38,8 @@ from PySide6.QtWidgets import (
QDialog,
QDialogButtonBox,
QFileDialog,
QFormLayout,
QFrame,
QGridLayout,
QGroupBox,
QHBoxLayout,
QHeaderView,
QInputDialog,
@@ -65,7 +52,6 @@ from PySide6.QtWidgets import (
QMessageBox,
QPlainTextEdit,
QPushButton,
QSpinBox,
QSplitter,
QStackedWidget,
QStyledItemDelegate,
@@ -305,10 +291,9 @@ class MoveToEnvDialog(QDialog):
v.setSpacing(10)
intro = QLabel(
"This replaces the value in place with a ${VAR} reference. The secret "
"moves to your shell/OS environment — not this config file, and not the "
"Environment variables table below. Run the line below to set it there, "
"or the server won't authenticate."
"The value will be removed from the config and replaced with a "
"reference. Set the variable in your environment first, or the "
"server won't authenticate."
)
intro.setWordWrap(True)
v.addWidget(intro)
@@ -371,141 +356,6 @@ class MoveToEnvDialog(QDialog):
self._already.hide()
class MoveArgToEnvDialog(QDialog):
"""Confirm relocating a secret arg into the env block (#83, kept in file).
Unlike the reference move, this keeps the value in the config -- it just
moves it out of the argument list (visible in process listings) and into
the Environment variables table, where the user can see and edit it. It
changes how the server is launched, so it says so plainly.
"""
def __init__(self, parent, key: str, value: str):
super().__init__(parent)
self.setWindowTitle("Move into environment variables")
self.setMinimumWidth(460)
v = QVBoxLayout(self)
v.setSpacing(10)
intro = QLabel(
"This moves the secret out of the arguments and into the Environment "
"variables table below, where you can see and edit its value. The value "
"stays in this config file."
)
intro.setWordWrap(True)
v.addWidget(intro)
warn = QLabel(
"⚠ This changes how the server is launched: the flag is dropped and the "
"value is set as an environment variable instead. It only works if the "
"server reads this secret from that variable."
)
warn.setWordWrap(True)
warn.setStyleSheet(f"color: {WARN};")
v.addWidget(warn)
grid = QGridLayout()
grid.setSpacing(8)
lbl = QLabel("Variable:")
lbl.setObjectName("muted")
grid.addWidget(lbl, 0, 0)
self._name_edit = QLineEdit(core.sanitize_env_var_name(key))
grid.addWidget(self._name_edit, 0, 1)
v.addLayout(grid)
btns = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
)
ok = btns.button(QDialogButtonBox.StandardButton.Ok)
ok.setText("Move into env")
ok.setObjectName("primary")
btns.accepted.connect(self.accept)
btns.rejected.connect(self.reject)
v.addWidget(btns)
def var_name(self) -> str:
return core.sanitize_env_var_name(self._name_edit.text())
class ReferencedVarsDialog(QDialog):
"""Show every ${VAR} the loaded server references and whether it's set (#83).
After a secret becomes a reference, the variable lives in the user's
environment, not the config -- so this is where they confirm it exists and
get the command to set it. Read-only; BCC can't (and shouldn't) store the
value.
"""
def __init__(self, parent, data: dict):
super().__init__(parent)
self.setWindowTitle("Referenced variables")
self.setMinimumWidth(560)
v = QVBoxLayout(self)
v.setSpacing(10)
self._usages = core.referenced_env_vars(data)
if not self._usages:
v.addWidget(QLabel("This server references no ${VAR} variables."))
btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
btns.rejected.connect(self.reject)
btns.accepted.connect(self.accept)
v.addWidget(btns)
return
intro = QLabel(
"These references are read from your shell/OS environment when the client "
"runs. ✓ means it's set in BCC's environment (which may differ from the "
"client's) or has a default; ✗ means nothing would fill it."
)
intro.setWordWrap(True)
v.addWidget(intro)
self._table = QTableWidget(len(self._usages), 3)
self._table.setHorizontalHeaderLabels(["Variable", "Status", "Used in"])
self._table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
self._table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
self._table.verticalHeader().setVisible(False)
self._table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self._table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
for r, u in enumerate(self._usages):
is_set = core.is_env_var_set(u.name)
status = "✓ set" if is_set else ("✓ default" if u.has_default else "✗ not set")
self._table.setItem(r, 0, QTableWidgetItem(u.name))
self._table.setItem(r, 1, QTableWidgetItem(status))
self._table.setItem(r, 2, QTableWidgetItem(", ".join(u.fields)))
self._table.selectionModel().selectionChanged.connect(self._refresh_cmd)
v.addWidget(self._table, 1)
set_lbl = QLabel("Set the selected variable with:")
set_lbl.setObjectName("muted")
v.addWidget(set_lbl)
self._cmd = QLabel("")
self._cmd.setWordWrap(True)
self._cmd.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
self._cmd.setStyleSheet("font-family: monospace;")
v.addWidget(self._cmd)
btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
btns.rejected.connect(self.reject)
btns.accepted.connect(self.accept)
v.addWidget(btns)
self._table.selectRow(0)
def _refresh_cmd(self, *_):
rows = self._table.selectionModel().selectedRows()
if not rows:
self._cmd.setText("")
return
name = self._usages[rows[0].row()].name
# A placeholder value -- BCC doesn't hold the secret, this shows the shape.
lines = core.shell_export_lines(name, "<value>")
if sys.platform == "win32":
self._cmd.setText(f"{lines['windows']}\n\n(macOS/Linux: {lines['posix']})")
else:
self._cmd.setText(f"{lines['posix']}\n\n(Windows: {lines['windows']})")
class KeyValueTable(QWidget):
def __init__(self, key_label="Key", val_label="Value", on_change=None, before_change=None):
super().__init__()
@@ -568,23 +418,12 @@ class KeyValueTable(QWidget):
return
row = item.row()
key, value = self._row_key_value(row)
# Only a real stored secret is worth moving; nothing to offer otherwise.
if not core.should_mask_value(key, value):
return
profile = self.profile_provider() if self.profile_provider else None
if not core.can_move_value_to_env_ref(key, value, profile):
return
menu = QMenu(self)
act = QAction("Replace with a ${VAR} reference (out of file)…", self)
if core.can_move_value_to_env_ref(key, value, profile):
act = QAction("Move to environment variable…", self)
act.triggered.connect(lambda: self._move_row_to_env(row))
else:
# Show it disabled with the reason rather than an empty menu, so the
# feature is discoverable and Claude Desktop's gating is explained.
act.setEnabled(False)
act.setText("Replace with ${VAR} reference — unavailable for Claude Desktop")
act.setToolTip(
"Claude Desktop doesn't expand ${VAR}, so a reference would reach "
"the server as literal text."
)
menu.addAction(act)
menu.exec(self.table.viewport().mapToGlobal(pos))
@@ -811,12 +650,6 @@ class ServerEditor(QFrame):
self.logs_btn = QPushButton("View logs")
self.logs_btn.setToolTip("Open this server's MCP log in a read-only, auto-tailing viewer")
self.logs_btn.clicked.connect(self._view_logs)
self.vars_btn = QPushButton("Variables…")
self.vars_btn.setToolTip(
"Show the ${VAR} references this server uses and whether each is set in "
"your environment"
)
self.vars_btn.clicked.connect(self._show_referenced_vars)
self.details_btn = QPushButton("Details ▸")
self.details_btn.setCheckable(True)
self.details_btn.toggled.connect(self._toggle_diag)
@@ -828,32 +661,10 @@ class ServerEditor(QFrame):
dep.addWidget(self.test_btn)
dep.addWidget(self.spawn_btn)
dep.addWidget(self.logs_btn)
dep.addWidget(self.vars_btn)
dep.addWidget(self.details_btn)
dep.addWidget(recheck)
outer.addLayout(dep)
# Version status row (#92): for an npx-style server, show the currently
# resolved version and, when the spec is unpinned, a one-click "pin".
# Mirrors the dependency-status surface above. Hidden for everything else.
ver = QHBoxLayout()
self.ver_dot = QLabel("○")
self.ver_label = QLabel("—")
self.ver_label.setObjectName("muted")
self.ver_label.setWordWrap(True)
self.pin_btn = QPushButton("Pin")
self.pin_btn.setToolTip(
"Pin the package spec to the currently-resolved version so it can't "
"change under you on the next launch"
)
self.pin_btn.clicked.connect(self._pin_version)
self.pin_btn.setVisible(False)
ver.addWidget(self.ver_dot)
ver.addWidget(self.ver_label, 1)
ver.addWidget(self.pin_btn)
self.ver_row_widgets = (self.ver_dot, self.ver_label, self.pin_btn)
outer.addLayout(ver)
# Collapsible diagnostics panel
self.diag_card = QFrame()
self.diag_card.setObjectName("diagCard")
@@ -921,53 +732,6 @@ class ServerEditor(QFrame):
self.secret_warn.setWordWrap(True)
self.secret_warn.hide()
v.addWidget(self.secret_warn)
# Shown when args carry a CLI flag a package removed in a major version
# (e.g. ssh-mcp v2's --password), with a one-click move into env.
self.removed_flag_warn = QLabel("")
self.removed_flag_warn.setStyleSheet(f"color: {WARN};")
self.removed_flag_warn.setWordWrap(True)
self.removed_flag_warn.hide()
self.removed_flag_fix_btn = QPushButton("Fix: move to environment variables")
self.removed_flag_fix_btn.clicked.connect(self._fix_removed_flags)
self.removed_flag_fix_btn.hide()
rf_row = QHBoxLayout()
rf_row.addWidget(self.removed_flag_warn, 1)
rf_row.addWidget(self.removed_flag_fix_btn)
v.addLayout(rf_row)
# Shown when a server reads a config sidecar (ssh-mcp's TOML): the args
# may be inert, or a file may sit at the README path the server never
# reads. Advisory (#91); #102 adds an "Edit config…" button that opens
# the in-app sidecar editor whenever the server has a resolvable sidecar.
self.sidecar_warn = QLabel("")
self.sidecar_warn.setStyleSheet(f"color: {WARN};")
self.sidecar_warn.setWordWrap(True)
self.sidecar_warn.hide()
self.sidecar_edit_btn = QPushButton("Edit config…")
self.sidecar_edit_btn.setToolTip(
"Open this server's external config file in BCC (edit → atomic save "
"with backup → the file is tightened to 0600)"
)
self.sidecar_edit_btn.clicked.connect(self._edit_sidecar)
self.sidecar_edit_btn.hide()
sc_row = QHBoxLayout()
sc_row.addWidget(self.sidecar_warn, 1)
sc_row.addWidget(self.sidecar_edit_btn, 0, Qt.AlignmentFlag.AlignTop)
v.addLayout(sc_row)
# Shown when the sidecar config is group/other-accessible (#93): ssh-mcp
# refuses to start unless it's 0600 / its dir 0700. One-click chmod fix.
# POSIX only — hidden on Windows where modes don't apply.
self.perm_warn = QLabel("")
self.perm_warn.setStyleSheet(f"color: {WARN};")
self.perm_warn.setWordWrap(True)
self.perm_warn.hide()
self.perm_fix_btn = QPushButton("Fix permissions")
self.perm_fix_btn.setToolTip("chmod the config file to 0600 and its directory to 0700")
self.perm_fix_btn.clicked.connect(self._fix_permissions)
self.perm_fix_btn.hide()
perm_row = QHBoxLayout()
perm_row.addWidget(self.perm_warn, 1)
perm_row.addWidget(self.perm_fix_btn)
v.addLayout(perm_row)
v.addWidget(self._lbl("Environment variables"))
self.env = KeyValueTable(
"Variable", "Value", on_change=self._emit, before_change=self._before_change
@@ -998,56 +762,10 @@ class ServerEditor(QFrame):
return w
def set_profile_provider(self, provider):
"""Let the env/headers tables and the args editor gate the secret-move
actions on which client the loaded profile targets (#83), and wire the
args editor's two move actions back to this editor (which owns the whole
form, since moving an arg into env touches both fields)."""
"""Let the env/headers tables gate "move to environment variable" on
which client the loaded profile targets (#83)."""
self.env.profile_provider = provider
self.headers.profile_provider = provider
self.args.profile_provider = provider
self.args.on_move_to_ref = self.move_arg_to_reference
self.args.on_move_to_env = self.move_arg_into_env
# --- secret moves from args (#83) ------------------------------------ #
def _reload_from_data(self, new_data: dict):
"""Repopulate the form from a transformed data dict and mark dirty."""
self.load_entry(core.ServerEntry(self.current_name(), new_data, True))
self._emit()
def move_arg_to_reference(self, index: int):
"""Args secret -> ${VAR} reference in place (secret leaves the file)."""
data = self.dump_data()
args = data.get("args") or []
if not (0 <= index < len(args)):
return
dlg = MoveToEnvDialog(
self.window(), core.suggested_env_var_for_arg(args, index), args[index]
)
if not dlg.exec():
return
conv = core.move_value_to_env_ref(data, field="args", index=index, var_name=dlg.var_name())
if conv is None:
return
QGuiApplication.clipboard().setText(conv.secret)
self._reload_from_data(conv.data)
def move_arg_into_env(self, index: int):
"""Args secret -> env block, kept in this config (visible/editable)."""
data = self.dump_data()
args = data.get("args") or []
if not (0 <= index < len(args)):
return
default_name = core.suggested_env_var_for_arg(args, index)
dlg = MoveArgToEnvDialog(self.window(), default_name, args[index])
if not dlg.exec():
return
new = core.move_arg_to_env_block(data, index, var_name=dlg.var_name())
if new is None:
return
self._reload_from_data(new)
def _show_referenced_vars(self):
ReferencedVarsDialog(self.window(), self.dump_data()).exec()
# --- model <-> form -------------------------------------------------- #
def load_entry(self, entry: core.ServerEntry | None):
@@ -1067,11 +785,6 @@ class ServerEditor(QFrame):
self.args_warn.hide()
self.args_fix_btn.hide()
self.secret_warn.hide()
self.removed_flag_warn.hide()
self.removed_flag_fix_btn.hide()
self.sidecar_warn.hide()
self.perm_warn.hide()
self.perm_fix_btn.hide()
self._loading = False
return
self.setEnabled(True)
@@ -1149,17 +862,6 @@ class ServerEditor(QFrame):
self.refresh_dependency(auto_open=False)
self._check_args()
def recheck_advisories(self):
"""Re-run the read-only sidecar / permission advisories (#101 hot-reload).
Public entry point for the file-watcher and focus-in fallback: it only
recomputes the warning labels from the current form + the filesystem — it
never touches the form's field values, so it is safe to call regardless of
unsaved edits (it can't clobber them). Delegates to the same recompute the
editor runs on every field change.
"""
self._check_args()
# --- args sanity check ------------------------------------------------ #
def _current_arg_lines(self) -> list[str]:
return [ln for ln in self.args.toPlainText().splitlines() if ln.strip() != ""]
@@ -1170,12 +872,6 @@ class ServerEditor(QFrame):
self.args_warn.hide()
self.args_fix_btn.hide()
self.secret_warn.hide()
self.removed_flag_warn.hide()
self.removed_flag_fix_btn.hide()
self.sidecar_warn.hide()
self.sidecar_edit_btn.hide()
self.perm_warn.hide()
self.perm_fix_btn.hide()
return
_, notes = core.split_suspicious_args(self._current_arg_lines())
if notes:
@@ -1191,43 +887,6 @@ class ServerEditor(QFrame):
self.secret_warn.show()
else:
self.secret_warn.hide()
# Flags a package removed across a major version (e.g. ssh-mcp v2's
# --password). Needs command + env too, so build from the live form.
stdio = {
"command": self.command.text().strip(),
"args": self._current_arg_lines(),
"env": self.env.dump(),
}
rf_warnings = core.removed_flag_warnings(stdio)
if rf_warnings:
self.removed_flag_warn.setText("⚠ " + "\n".join(rf_warnings))
self.removed_flag_warn.show()
# Only offer the button when something is actually auto-migratable.
migrated, _ = core.migrate_removed_flags(stdio)
self.removed_flag_fix_btn.setVisible(migrated is not stdio)
else:
self.removed_flag_warn.hide()
self.removed_flag_fix_btn.hide()
# Sidecar precedence / wrong-path / credential-scoping (#91). Uses the
# real platform + environment + filesystem so it reflects this machine.
sc_warnings = core.sidecar_warnings(stdio)
if sc_warnings:
self.sidecar_warn.setText("⚠ " + "\n\n".join(sc_warnings))
self.sidecar_warn.show()
else:
self.sidecar_warn.hide()
# Offer the in-app editor (#102) whenever this server has a resolvable
# sidecar — even before the file exists, so it can be created from BCC.
self.sidecar_edit_btn.setVisible(core.sidecar_status(stdio) is not None)
# Sidecar filesystem permissions (#93). Real platform/fs; no-op on Windows.
perm_warnings = core.sidecar_permission_warnings(stdio)
if perm_warnings:
self.perm_warn.setText("⚠ " + "\n".join(perm_warnings))
self.perm_warn.show()
self.perm_fix_btn.show()
else:
self.perm_warn.hide()
self.perm_fix_btn.hide()
def _fix_args(self):
if self._before_change:
@@ -1235,72 +894,11 @@ class ServerEditor(QFrame):
fixed, _ = core.split_suspicious_args(self._current_arg_lines())
self.args.setPlainText("\n".join(fixed)) # triggers _emit -> recheck
def _fix_removed_flags(self):
if self._before_change:
self._before_change()
stdio = {
"command": self.command.text().strip(),
"args": self._current_arg_lines(),
"env": self.env.dump(),
}
migrated, _ = core.migrate_removed_flags(stdio)
# Reload env first, then args; setting args text triggers _emit -> recheck.
self.env.load(migrated.get("env", {}))
self.args.setPlainText("\n".join(migrated.get("args", [])))
def _fix_permissions(self):
"""chmod the sidecar config to 0600 / its dir to 0700 (#93)."""
stdio = {
"command": self.command.text().strip(),
"args": self._current_arg_lines(),
"env": self.env.dump(),
}
target = core.sidecar_permission_fix_target(stdio)
if target is None:
return
changed, note = core.fix_permissions(target)
if not changed:
# Surface the failure in-place rather than silently doing nothing.
self.perm_warn.setText("⚠ " + (note or "could not change permissions"))
return
self._check_args() # re-check; the warning clears when perms are now tight
def _edit_sidecar(self):
"""Open the in-app sidecar editor for this server's external config (#102)."""
stdio = {
"command": self.command.text().strip(),
"args": self._current_arg_lines(),
"env": self.env.dump(),
}
status = core.sidecar_status(stdio)
spec = core.resolve_server_spec(stdio)
if status is None or spec is None:
return
path = status["path"]
try:
text = path.read_text(encoding="utf-8") if status["exists"] else ""
except OSError as e:
QMessageBox.warning(self.window(), "Can't open config", f"{path}\n\n{e}")
return
dlg = SidecarEditorDialog(self.window(), spec.package, path, spec.schema, text)
if dlg.exec() and dlg.saved:
# Re-run detection through the same read-only path the #101 watcher
# uses, so "args inert" / permission advisories update live.
self._check_args()
bnote = f" · backup: {dlg.backup_path.name}" if dlg.backup_path else " · (new file)"
QMessageBox.information(
self.window(),
"Config saved",
f"Saved {path}{bnote}\nThe file was tightened to 0600.",
)
# --- dependency ------------------------------------------------------ #
def refresh_dependency(self, auto_open=False):
if not self.isEnabled():
self._set_dep({"status": "unknown", "label": "—"})
self.diag_text.clear()
for wdg in self.ver_row_widgets:
wdg.hide()
return
data = self.dump_data()
res = core.check_dependency(data)
@@ -1314,49 +912,6 @@ class ServerEditor(QFrame):
self.details_btn.setChecked(True) # opens panel (fills text via _toggle_diag)
if self.diag_card.isVisible():
self.diag_text.setPlainText(self._full_diag_text())
self._refresh_version_badge(data)
def _refresh_version_badge(self, data: dict):
"""Show the resolved version / pin state for an npx-style server (#92)."""
st = core.version_status(data) if self.type.currentIndex() == 0 else None
if st is None:
for wdg in self.ver_row_widgets:
wdg.hide()
return
for wdg in self.ver_row_widgets:
wdg.show()
resolved = st["resolved_version"] or "unknown"
if st["drift"]:
glyph, color, text = "●", WARN, f"{st['package']} {st['drift']}"
elif st["unpinned"]:
glyph, color = "●", WARN
text = f"{st['package']} · resolved {resolved} · unpinned (resolves latest each launch)"
else:
glyph, color = "●", GOOD
text = f"{st['package']} · pinned {st['pinned_version']}"
self.ver_dot.setText(glyph)
self.ver_dot.setStyleSheet(f"color: {color}; font-size: 14px;")
self.ver_label.setText(text)
self.ver_label.setStyleSheet(f"color: {color};")
# Offer the pin only when unpinned AND we know what to pin to.
self.pin_btn.setVisible(st["can_pin"])
if st["can_pin"]:
self.pin_btn.setText(f"Pin to {resolved}")
self._version_resolved = st["resolved_version"]
def _pin_version(self):
if self._before_change:
self._before_change()
resolved = getattr(self, "_version_resolved", None)
if not resolved:
return
new_data, note = core.pin_spec_transform(self.dump_data(), resolved)
if not note:
return
self._loading = True
self.args.setPlainText("\n".join(str(a) for a in (new_data.get("args") or [])))
self._loading = False
self._emit() # writes back to the model and re-checks (badge now "pinned")
def _set_dep(self, res: dict):
status = res.get("status", "unknown")
@@ -1489,52 +1044,6 @@ class ArgsEdit(QPlainTextEdit):
self.blockCountChanged.connect(self._update_gutter_width)
self.updateRequest.connect(self._on_update_request)
self._update_gutter_width()
# Wired by ServerEditor: gate on the loaded client, and the two move
# actions (which the editor performs, since moving an arg into env
# touches both the args and the env table). Indices are into the
# non-blank arg list, matching dump_data()'s args.
self.profile_provider = None
self.on_move_to_ref = None
self.on_move_to_env = None
def contextMenuEvent(self, event):
menu = self.createStandardContextMenu() # keep cut/copy/paste
lines = self.toPlainText().splitlines()
block = self.cursorForPosition(event.pos()).blockNumber()
if 0 <= block < len(lines) and lines[block].strip():
# This editor is one arg per line; map the clicked block to its
# index among the non-blank args the model actually sees.
cleaned = [ln for ln in lines if ln.strip() != ""]
idx = sum(1 for ln in lines[:block] if ln.strip() != "")
if idx in set(core.secret_arg_indices(cleaned)):
profile = self.profile_provider() if self.profile_provider else None
expands = profile is None or core.client_expands_env_refs(profile)
first = menu.actions()[0] if menu.actions() else None
# Reference (secret leaves the file) -- needs an expanding client.
ref_act = QAction("Replace with a ${VAR} reference (out of file)…", self)
if expands and self.on_move_to_ref:
ref_act.triggered.connect(lambda: self.on_move_to_ref(idx))
else:
ref_act.setEnabled(False)
ref_act.setText(
"Replace with ${VAR} reference — unavailable for Claude Desktop"
)
ref_act.setToolTip(
"Claude Desktop doesn't expand ${VAR}, so a reference would "
"reach the server as literal text."
)
# Move into the env block (kept in file) -- works on any client.
env_act = QAction("Move into Environment variables (kept in this config)…", self)
if self.on_move_to_env:
env_act.triggered.connect(lambda: self.on_move_to_env(idx))
menu.insertAction(first, ref_act)
menu.insertAction(first, env_act)
if first is not None:
menu.insertSeparator(first)
menu.exec(event.globalPos())
def gutter_width(self) -> int:
digits = max(1, len(str(self.blockCount())))
@@ -2208,176 +1717,6 @@ class NoticeBanner(QFrame):
self.show()
_UNSET_CHOICE = "— (unset) —" # sentinel entry for an enum pick-list
class SidecarEditorDialog(QDialog):
"""Edit an external sidecar config (ssh-mcp's config.toml) from inside BCC (#102).
A minimal, reviewable first pass: pick-lists for the schema enum fields and a
numeric spinner for the port, plus a raw-text editor that is the full,
always-available fallback. On Save the changed managed fields are applied
surgically on top of the raw text (so comments/unknown keys survive), the
managed values are validated against the ServerSpec schema, and the file is
written through core.write_sidecar (atomic + timestamped backup + chmod
0600). All decision logic lives in bcc_core; this class is wiring.
"""
def __init__(self, parent, package: str, path: Path, schema: dict, initial_text: str):
super().__init__(parent)
self.setWindowTitle(f"Edit {package} config")
self.resize(620, 620)
self._path = Path(path)
self._schema = schema or {}
self.backup_path: Path | None = None # set on a successful save
self.saved = False
v = QVBoxLayout(self)
info = QLabel(
f"Editing <code>{path}</code><br>"
"Pick-lists cover the known settings; the raw editor below is the full "
"file. Saving writes atomically, keeps a timestamped backup, and tightens "
"the file to <code>0600</code>."
)
info.setObjectName("muted")
info.setWordWrap(True)
info.setTextFormat(Qt.TextFormat.RichText)
v.addWidget(info)
# Which section the pick-lists target. ssh-mcp configs are section-based
# ([server] / per-profile); default to the first section, else top-level.
sec_row = QHBoxLayout()
sec_row.addWidget(QLabel("Section:"))
self.section_combo = QComboBox()
sections = core.toml_sections(initial_text) or [""]
for s in sections:
self.section_combo.addItem("(top level)" if s == "" else s, s)
# Prefer a section literally called "server" if present.
if "server" in sections:
self.section_combo.setCurrentIndex(sections.index("server"))
self.section_combo.currentIndexChanged.connect(self._reload_fields_from_raw)
sec_row.addWidget(self.section_combo, 1)
v.addLayout(sec_row)
# Managed schema fields.
self._fields_box = QGroupBox("Known settings")
self._form = QFormLayout(self._fields_box)
self._enum_widgets: dict[str, QComboBox] = {}
self._port_widget: QSpinBox | None = None
self._port_present = QCheckBox("set") # gate for whether port is written
self._build_fields()
v.addWidget(self._fields_box)
v.addWidget(QLabel("Raw TOML (full file — edit anything here):"))
self.raw = QPlainTextEdit()
self.raw.setPlainText(initial_text)
mono = self.raw.font()
mono.setFamily("Menlo, Consolas, monospace")
self.raw.setFont(mono)
v.addWidget(self.raw, 1)
self.err = QLabel("")
self.err.setStyleSheet(f"color: {BAD};")
self.err.setWordWrap(True)
self.err.hide()
v.addWidget(self.err)
btns = QDialogButtonBox()
self.save_btn = btns.addButton("Save", QDialogButtonBox.ButtonRole.AcceptRole)
self.save_btn.setObjectName("primary")
btns.addButton(QDialogButtonBox.StandardButton.Cancel)
btns.accepted.connect(self._save)
btns.rejected.connect(self.reject)
v.addWidget(btns)
self._reload_fields_from_raw()
def _current_section(self) -> str | None:
s = self.section_combo.currentData()
return None if s == "" else s
def _build_fields(self):
"""Create a widget per schema key (enums -> combo, port range -> spin)."""
for key, rule in self._schema.items():
if isinstance(rule, list): # enum
combo = QComboBox()
combo.addItem(_UNSET_CHOICE, None)
for opt in rule:
combo.addItem(str(opt), opt)
self._enum_widgets[key] = combo
self._form.addRow(f"{key}:", combo)
elif isinstance(rule, dict) and "min" in rule and "max" in rule: # numeric range
row = QHBoxLayout()
spin = QSpinBox()
spin.setRange(int(rule["min"]), int(rule["max"]))
spin.setEnabled(False)
self._port_present.toggled.connect(spin.setEnabled)
row.addWidget(self._port_present)
row.addWidget(spin, 1)
holder = QWidget()
holder.setLayout(row)
self._port_widget = spin
self._form.addRow(f"{key}:", holder)
def _reload_fields_from_raw(self):
"""Populate the pick-lists from the raw text for the chosen section, and
remember the loaded state so Save only applies fields the user changed."""
values = core.read_toml_section(self.raw.toPlainText(), self._current_section())
self._loaded: dict = {}
for key, combo in self._enum_widgets.items():
val = values.get(key)
idx = combo.findData(val) if val is not None else 0
combo.setCurrentIndex(idx if idx >= 0 else 0)
# An out-of-enum current value can't be shown; leave it at (unset) but
# DON'T record it as loaded so we never silently overwrite it on save.
self._loaded[key] = combo.currentData()
if self._port_widget is not None:
pv = values.get("port")
has = isinstance(pv, int) and not isinstance(pv, bool)
self._port_present.setChecked(has)
if has:
self._port_widget.setValue(pv)
self._loaded["port"] = pv if has else None
def _managed_updates(self) -> dict:
"""The managed keys the user actually changed -> new value (None = delete).
Only changed fields are applied, so untouched keys (including any the
pick-list can't represent) are left exactly as the raw text has them.
"""
updates: dict = {}
for key, combo in self._enum_widgets.items():
cur = combo.currentData()
if cur != self._loaded.get(key):
updates[key] = cur # None here means "delete the key"
if self._port_widget is not None:
cur = self._port_widget.value() if self._port_present.isChecked() else None
if cur != self._loaded.get("port"):
updates["port"] = cur
return updates
def _save(self):
section = self._current_section()
updates = self._managed_updates()
# Validate only the managed values the user is actually setting (a None =
# delete needs no enum check).
to_check = {k: v for k, v in updates.items() if v is not None}
problems = core.validate_sidecar_values(to_check, self._schema)
if problems:
self.err.setText("⚠ " + "\n".join(problems))
self.err.show()
return
try:
new_text = core.update_toml(self.raw.toPlainText(), updates, section=section)
self.backup_path = core.write_sidecar(self._path, new_text)
except Exception as e: # surface any write/permission failure in-place
self.err.setText(f"⚠ Could not save: {e}")
self.err.show()
return
self.saved = True
self.accept()
# --------------------------------------------------------------------------- #
# Restart worker: core.restart_claude_desktop() blocks up to ~5 s on macOS
# waiting for the old instance to exit, so it must run off the UI thread.
@@ -2418,18 +1757,6 @@ class MainWindow(QMainWindow):
self._test_all_done = 0
self._health_tester: SpawnTester | None = None
# Hot-reload (#101): watch the selected server's external sidecar path(s)
# and BCC's own loaded config, so changes made outside BCC surface without
# a restart. The watcher fires the debounce timer; the timer re-checks.
self._fs_watcher = QFileSystemWatcher(self)
self._fs_watcher.fileChanged.connect(self._on_fs_signal)
self._fs_watcher.directoryChanged.connect(self._on_fs_signal)
self._fs_debounce = QTimer(self)
self._fs_debounce.setSingleShot(True)
self._fs_debounce.setInterval(300) # coalesce a burst of writes
self._fs_debounce.timeout.connect(self._recheck_external_state)
self._sidecar_fp: tuple | None = None # last observed sidecar state
central = QWidget()
self.setCentralWidget(central)
root = QVBoxLayout(central)
@@ -2452,12 +1779,6 @@ class MainWindow(QMainWindow):
self.update_banner = NoticeBanner(self)
root.addWidget(self.update_banner)
# External-change notice (#101): when BCC's own loaded config is edited
# outside BCC, surface it here with a one-click Reload rather than
# silently overwriting — a live reload must never clobber unsaved edits.
self.reload_banner = NoticeBanner(self)
root.addWidget(self.reload_banner)
# User-draggable divider between the server list and the editor.
split = QSplitter(Qt.Orientation.Horizontal)
split.setChildrenCollapsible(False)
@@ -2859,11 +2180,6 @@ class MainWindow(QMainWindow):
for p in self.profiles:
tag = "" if p.config_exists else " (no config yet)"
self.profile_combo.addItem(f"{p.label}{tag}")
# Full path in the tooltip so a profile is always verifiable even
# when two labels look alike (e.g. two repos both named "app").
self.profile_combo.setItemData(
self.profile_combo.count() - 1, str(p.path), Qt.ItemDataRole.ToolTipRole
)
self.profile_combo.blockSignals(False)
if self.profiles:
self.profile_combo.setCurrentIndex(0)
@@ -2959,8 +2275,6 @@ class MainWindow(QMainWindow):
self._health.clear() # health results are per-profile; a fresh load invalidates them
self._refresh_sets_combo() # sets are per-config; repopulate from the loaded file
self._refresh_tables(select_index=0 if self.servers else -1)
self.reload_banner.hide() # loaded fresh: any external-change notice is now stale
self._rewatch_paths() # watch this profile's config + the selection's sidecar (#101)
self._update_status(saved=False)
if repaired:
self._mark_dirty()
@@ -3251,120 +2565,6 @@ class MainWindow(QMainWindow):
self.copy_btn.setEnabled(entry is not None and len(self.profiles) > 1)
self.dup_btn.setEnabled(entry is not None)
self.del_btn.setEnabled(entry is not None)
self._rewatch_paths()
# --- hot-reload (#101) ------------------------------------------------- #
def _watch_targets(self) -> list[Path]:
"""Every external path worth watching for the current view.
BCC's own loaded config (+ its directory), plus the selected server's
sidecar path(s) resolved from its ServerSpec. Directories are watched too
so a file appearing/disappearing — which a watch on a not-yet-existent
file would miss, and which an atomic-rename replace looks like — is still
observed. All entries must exist for QFileSystemWatcher to accept them.
"""
targets: list[Path] = []
if self.current_profile is not None:
cfg = Path(self.current_profile.path)
targets.append(cfg)
targets.append(cfg.parent)
if self.editor.isEnabled():
data = self.editor.dump_data()
targets.extend(core.sidecar_watch_paths(data))
# De-dup, keep only paths that currently exist (the watcher rejects the
# rest; the parent dir covers a not-yet-created file).
seen: set[str] = set()
out: list[Path] = []
for p in targets:
key = str(p)
if key in seen:
continue
seen.add(key)
if p.exists():
out.append(p)
return out
def _rewatch_paths(self):
"""Point the watcher at the current targets and snapshot sidecar state.
Re-applied whenever the selection or the edited command/args change (which
server is selected decides which sidecar to watch) and after a reload.
"""
watcher = self._fs_watcher
existing = watcher.files() + watcher.directories()
if existing:
watcher.removePaths(existing)
wanted = [str(p) for p in self._watch_targets()]
if wanted:
watcher.addPaths(wanted)
self._sidecar_fp = self._current_sidecar_fp()
def _current_sidecar_fp(self) -> tuple | None:
if not self.editor.isEnabled():
return None
return core.sidecar_state_fingerprint(self.editor.dump_data())
def _on_fs_signal(self, _path=None):
"""A watched path changed — coalesce a burst of writes via the debounce."""
self._fs_debounce.start()
def _recheck_external_state(self):
"""Debounced re-check: re-arm the watcher, refresh advisories if state
actually changed, and surface an external edit to BCC's own config.
Runs from both the file-watcher and the focus-in fallback. Never mutates
the editor form or the loaded config — the only content reload is the
user pressing Reload on the banner, so unsaved edits are safe.
"""
# Re-arm: an atomic-rename replace drops the old inode from the watcher,
# so paths must be re-added or the next change goes unseen.
self._rewatch_after_event()
# Sidecar/permission advisories for the selected server (#91/#93). Only
# refresh when the observable state changed, so an unrelated write in the
# watched directory doesn't thrash the panel.
new_fp = self._current_sidecar_fp()
if core.sidecar_state_changed(self._sidecar_fp, new_fp):
self._sidecar_fp = new_fp
self.editor.recheck_advisories()
# BCC's own loaded config edited outside BCC: surface it (non-destructive).
self._check_config_changed_on_disk()
def _rewatch_after_event(self):
"""Re-add any wanted paths the watcher dropped, without disturbing the
sidecar fingerprint (which _recheck_external_state compares itself)."""
watcher = self._fs_watcher
current = set(watcher.files()) | set(watcher.directories())
readd = [str(p) for p in self._watch_targets() if str(p) not in current]
if readd:
watcher.addPaths(readd)
def _check_config_changed_on_disk(self):
if self.current_profile is None:
return
disk = core.config_fingerprint(self.current_profile.path)
if disk is not None and self._loaded_stat is not None and disk != self._loaded_stat:
self.reload_banner.show_notice(
f"{self.current_profile.path} changed on disk (edited outside BCC).",
action_label="Reload from disk",
on_action=lambda: self.load_profile(self.current_profile, confirm=True),
)
else:
# Back in sync (e.g. the user reloaded, or the change was reverted).
self.reload_banner.hide()
def changeEvent(self, event):
"""Re-check external state when the window regains focus (#101).
A cheap, always-works fallback: QFileSystemWatcher can miss changes
(notably atomic-rename replaces, and files that didn't exist when the
watch was set), so a re-check on activation covers the gap.
"""
if event.type() == QEvent.Type.ActivationChange and self.isActiveWindow():
# Go through the debounce so activation + a watcher signal coalesce.
self._fs_debounce.start()
super().changeEvent(event)
def _table_item_changed(self, item: QTableWidgetItem):
if self._suppress_table or item.column() != 0:
@@ -3419,9 +2619,6 @@ class MainWindow(QMainWindow):
health_item.setToolTip("Not tested since last edit.")
self._suppress_table = False
self._refresh_badges()
# The edited command/args may change which package (and thus which
# sidecar) this server resolves to — re-point the watcher (#101).
self._rewatch_paths()
self._mark_dirty()
# --- server actions -------------------------------------------------- #
@@ -3568,8 +2765,6 @@ class MainWindow(QMainWindow):
for entry in self.servers:
for warning in core.env_ref_warnings(entry.data, self.current_profile):
lint_warnings.append(f"'{entry.name}': {warning}")
for warning in core.removed_flag_warnings(entry.data):
lint_warnings.append(f"'{entry.name}': {warning}")
if lint_warnings:
self.validation_lbl.setText(f"⚠ {lint_warnings[0]}")
self.validation_lbl.setStyleSheet(f"color: {WARN};")
+23 -1548
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "better-claude-config"
version = "1.4.0"
version = "1.3.0"
description = "Cross-platform GUI for editing the mcpServers block of Claude Desktop and Claude Code configs"
readme = "README.md"
license = { file = "LICENSE" }
-1006
View File
File diff suppressed because it is too large Load Diff