64 Commits

Author SHA1 Message Date
the_og 4c6fe7c5aa release: v1.2.0 (#26) — merge #6/#8/#9/#17/#18/#19
CI / Lint (ruff) (push) Successful in 6s
CI / Tests (py3.10) (push) Successful in 7s
CI / Tests (py3.12) (push) Successful in 8s
Build & Release / Build (Linux) (push) Successful in 59s
Build & Release / Build (Windows) (push) Successful in 1m9s
Build & Release / Build (macOS) (push) Has been cancelled
Build & Release / Publish Release (push) Has been cancelled
2026-07-08 11:34:34 -04:00
Cowork Supervisor 8c718387c0 chore: bump version to 1.2.0 for release
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10) (pull_request) Successful in 8s
CI / Tests (py3.12) (pull_request) Successful in 8s
2026-07-07 22:14:49 -04:00
Cowork Supervisor 15a30fb986 Merge branch 'feat/18-19-about-update-checker' into integration/v1.2.0
# Conflicts:
#	bcc.py
#	bcc_core.py
#	tests/test_core.py
2026-07-07 22:13:29 -04:00
Cowork Supervisor c56dec8051 Merge branch 'feat/9-restart-claude-desktop' into integration/v1.2.0
# Conflicts:
#	bcc_core.py
#	tests/test_core.py
2026-07-07 22:12:35 -04:00
Cowork Supervisor 70b865be8f Merge branch 'feat/6-log-viewer' into integration/v1.2.0
# Conflicts:
#	bcc_core.py
#	tests/test_core.py
2026-07-07 22:12:17 -04:00
Cowork Supervisor 8fdcbda681 Merge branch 'feat/17-stale-size-hardening' into integration/v1.2.0 2026-07-07 22:10:59 -04:00
the_og 2d9fb083dc Fix: correct smart-quote/nbsp characters mangled in previous push
CI / Lint (ruff) (pull_request) Successful in 6s
CI / Tests (py3.10) (pull_request) Successful in 8s
CI / Tests (py3.12) (pull_request) Successful in 8s
2026-07-07 20:56:14 -04:00
the_og 4ab3c3b00a Fix: rebuild from clean main (previous push included unrelated contaminated content)
CI / Lint (ruff) (pull_request) Failing after 8s
CI / Tests (py3.10) (pull_request) Failing after 8s
CI / Tests (py3.12) (pull_request) Failing after 7s
2026-07-07 20:51:26 -04:00
the_og 3e07b51134 Fix: rebuild from clean main (previous push included unrelated contaminated content)
CI / Lint (ruff) (pull_request) Failing after 6s
CI / Tests (py3.10) (pull_request) Successful in 7s
CI / Tests (py3.12) (pull_request) Successful in 7s
2026-07-07 20:48:52 -04:00
the_og a811e323e6 Fix: rebuild from clean main (previous push included unrelated contaminated content)
CI / Lint (ruff) (pull_request) Failing after 6s
CI / Tests (py3.10) (pull_request) Successful in 7s
CI / Tests (py3.12) (pull_request) Successful in 8s
2026-07-07 20:44:39 -04:00
the_og 3cd18392c9 test: cover __version__, parse_version/is_newer_version, and fetch_latest_release (#19)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10) (pull_request) Successful in 9s
CI / Tests (py3.12) (pull_request) Successful in 7s
All network calls are monkeypatched (urllib.request.urlopen) — no live
network in tests. Covers older/newer/equal comparisons, v-prefix,
differing-length tuples, malformed input on both sides, and
fetch_latest_release success/timeout/network-failure/malformed-response
paths.
2026-07-07 20:44:00 -04:00
the_og 67c898cd35 feat: About dialog with menu bar + notify-only update checker UI (#18, #19)
- Add a real QMenuBar (Help -> About...).
- AboutDialog: app icon, name, version (core.__version__), and links to the
  repo/issues/license opened via QDesktopServices.openUrl (system browser,
  never in-app navigation). macOS unsigned-app note.
- "Check for updates" button + UpdateCheckWorker (QThread) runs
  core.fetch_latest_release() off the UI thread; shows "up to date" or
  "vX.Y.Z available" with a button to open the releases page. No binary
  download, ever.
- Optional quiet startup auto-check, throttled to once/day via a QSettings
  timestamp, off-thread, silent on failure, toggle lives in the About dialog.
2026-07-07 20:42:27 -04:00
the_og 16961a5cc8 test: cover restart_claude_desktop() and profile_targets_claude_desktop() (#9)
CI / Lint (ruff) (pull_request) Successful in 6s
CI / Tests (py3.10) (pull_request) Successful in 8s
CI / Tests (py3.12) (pull_request) Successful in 8s
Mocks subprocess.run/Popen and patches sys.platform per-OS branch (darwin,
win32, linux) -- never actually kills or launches anything. Covers: correct
command sequence per platform, pkill/taskkill exiting non-zero (nothing to
kill) is NOT treated as failure, a failed relaunch IS reported as failure,
and profile_targets_claude_desktop() correctly distinguishes Claude Desktop
configs from Claude Code / legacy settings.json profiles.
2026-07-07 20:40:57 -04:00
the_og 2b3843a714 feat: add View logs button + LogViewerDialog for in-app MCP log viewer (#6)
CI / Lint (ruff) (pull_request) Successful in 6s
CI / Tests (py3.10) (pull_request) Successful in 7s
CI / Tests (py3.12) (pull_request) Successful in 8s
2026-07-07 20:40:32 -04:00
the_og 256827eaf3 fix: use \xa0 escape instead of a literal NBSP byte (transmission-safe, same behavior); drop stray trailing blank line 2026-07-07 20:38:35 -04:00
the_og 85d47aea97 test: add server_log_path coverage (macOS, Windows, missing, unsupported) (#6) 2026-07-07 20:36:58 -04:00
the_og f9752211a2 test: add coverage for config_fingerprint mtime+size hardening (#17)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10) (pull_request) Successful in 11s
CI / Tests (py3.12) (pull_request) Successful in 7s
Includes the required regression case: rewrite a file with different
content, force the original mtime back via os.utime, and assert the
fingerprint still differs (because size changed).
2026-07-07 20:35:03 -04:00
the_og 7c8fd6d0bb fix: use explicit backslash-u-0-0-a-0 escape for non-breaking space literal 2026-07-07 20:34:53 -04:00
the_og f1935fe320 feat: use mtime+size fingerprint for stale-file check (#17)
MainWindow._loaded_mtime -> _loaded_stat (core.ConfigStat), populated
via core.config_fingerprint() at load/save time. The save-path stale
check now compares the full fingerprint (mtime AND size) instead of
bare mtime equality.
2026-07-07 20:33:38 -04:00
the_og 0ffc6a1fb6 chore: restore trailing newline in bcc_core.py (attempt 2) 2026-07-07 20:32:36 -04:00
the_og fd2c3567a0 feat: Restart Claude Desktop button after save (#9)
After a successful save, shows a 'Restart Claude Desktop' button next to
the status line. Only shown when the saved profile targets Claude Desktop
(core.profile_targets_claude_desktop) -- never for Claude Code, which has
no GUI process to bounce. Clicking it calls core.restart_claude_desktop()
and reports success/failure. The button hides again on further edits or
when switching/reloading profiles.
2026-07-07 20:32:14 -04:00
the_og 346d0aabb6 fix: use   escape for non-breaking space (byte-exact transcription fix) 2026-07-07 20:30:35 -04:00
the_og 5d59c1c423 fix: restore actual file content (previous commit had placeholder text by mistake) 2026-07-07 20:30:03 -04:00
the_og cffdee8a40 chore: restore trailing newline in bcc_core.py 2026-07-07 20:29:33 -04:00
the_og 0843c51c7d tests/test_core.py: add resolve_name_collision unit tests
CI / Lint (ruff) (pull_request) Failing after 8s
CI / Tests (py3.10) (pull_request) Failing after 8s
CI / Tests (py3.12) (pull_request) Failing after 8s
2026-07-07 20:27:24 -04:00
the_og 82cec27c11 fix: use chr(0xA0) instead of a literal/escaped NBSP
Two prior attempts to fix this line via a literal or  -escaped
non-breaking space both silently reverted back to a no-op regular-space
replace during transcription. Using chr(0xA0) instead removes any
non-ASCII or backslash-escape character from the source line entirely.
2026-07-07 20:27:17 -04:00
the_og 6c51bac1e0 feat: restart_claude_desktop() core logic (#9)
Platform-abstracted restart: macOS uses pkill -x Claude + open -a Claude,
Windows uses taskkill + relaunch via the Start-menu shortcut, Linux uses
pkill claude + a detached Popen relaunch. Not finding a running process is
not an error -- only a failed relaunch is reported as failure.

Also adds profile_targets_claude_desktop() to distinguish Claude Desktop
profiles (claude_desktop_config.json) from Claude Code profiles, used to
gate the restart button in the GUI.
2026-07-07 20:26:53 -04:00
the_og 4b45251682 fix: actually apply the non-breaking-space escape fix
The previous commit message said this was fixed but the content still
had the literal (unescaped) line. Apply the   escape for real
this time.
2026-07-07 20:26:37 -04:00
the_og 2a0802b22f bcc.py: unify paste/drop collision handling via shared _import_server() prompt 2026-07-07 20:26:00 -04:00
the_og fe66d53e9f feat: __version__ constant + notify-only update checker logic (#19), version source for About dialog (#18)
- Add `__version__ = "1.1.0"` as the single source of truth (matches pyproject.toml).
- Add parse_version()/is_newer_version() for numeric (never lexical) version
  comparison, handling v-prefix, pre-release suffixes, and malformed input.
- Add fetch_latest_release(): reads the public Gitea releases API
  (anonymous, no token) and returns {version, url} or None on any failure.
  Never downloads or touches a binary — metadata only.
2026-07-07 20:25:33 -04:00
the_og 8d90ab449d feat: add server_log_path() for in-app MCP log viewer (#6) 2026-07-07 20:24:10 -04:00
the_og 9760b1537e fix: restore non-breaking-space literal lost in transcription
The previous commit accidentally normalized the non-breaking space
(U+00A0) in _normalize_unicode's replace() call to a regular space
during a copy/paste, turning that replace() into a no-op. Use an
explicit   escape instead of the literal character so it can't
be silently corrupted again.
2026-07-07 20:23:44 -04:00
the_og 0f1cdbef3c bcc_core.py: fix non-breaking-space byte lost in transcription 2026-07-07 20:22:45 -04:00
the_og 165c65be5f bcc_core.py: add resolve_name_collision() for paste/import name-collision handling 2026-07-07 20:19:25 -04:00
the_og 3c65657d2f feat: harden stale-file detection with mtime+size fingerprint (#17)
Bare mtime equality can miss a concurrent external write that lands
within the filesystem's mtime resolution (same-second writes), or
where the writer restores the original mtime. Add config_fingerprint()
returning a (mtime, size) ConfigStat pair; the stale-file check in
bcc.py now compares both fields instead of mtime alone. config_mtime()
is kept as-is (still used/tested independently).
2026-07-07 20:17:57 -04:00
the_og 832e5fa048 fix: bump spawn-test timeouts to avoid CI flakiness (closes #12)
Build & Release / Build (macOS) (push) Successful in 1m29s
Build & Release / Build (Linux) (push) Successful in 1m1s
Build & Release / Build (Windows) (push) Successful in 1m12s
Build & Release / Publish Release (push) Successful in 10s
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.10) (push) Successful in 10s
CI / Tests (py3.12) (push) Successful in 8s
The crashed/exited/stderr spawn tests used 0.3-0.5s timeouts. On a slow CI
runner, interpreter startup can exceed that, so the process is still starting
when the timeout fires, gets killed, and is misclassified "ok" (still running)
instead of "crashed"/"exited". Bump those three to 2.0s. The two "ok" paths
(sleep-60, stdin-block) stay at 0.3s since timing out there means success.
2026-07-04 13:12:04 -04:00
AJ c6c6dfa5bc docs: update HANDOFF.md — #1r done, next is #8 duplicate-name conflict
CI / Lint (ruff) (push) Successful in 8s
CI / Tests (py3.10) (push) Successful in 9s
CI / Tests (py3.12) (push) Successful in 8s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 12:00:44 -04:00
the_og ece049c993 Merge pull request 'feat: secret-in-args warning badge (#1r)' (#16) from feat/1r-secret-args-badge into main
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.10) (push) Successful in 8s
CI / Tests (py3.12) (push) Successful in 8s
feat: secret-in-args warning badge (#1r) — Closes #1
2026-07-02 12:00:16 -04:00
AJ f4648b3c06 feat: warn when a secret-shaped value is found in args instead of env (#1r)
CI / Lint (ruff) (pull_request) Successful in 8s
CI / Tests (py3.10) (pull_request) Successful in 10s
CI / Tests (py3.12) (pull_request) Successful in 9s
Adds `args_secret_warning(data)` to bcc_core — returns a warning string
when any arg positional value looks like a raw credential (token prefix,
value following a secret-named flag, or URL with embedded user:pass like
postgres://user:pass@host).  `--flag=value` inline forms are intentionally
skipped (the flag name already labels the value).

Adds `secret_warn` QLabel in ServerEditor's stdio page; shown/hidden by
`_check_args()` on every field change, and cleared on deselect or
stdio→remote type switch.  Non-blocking — save path is not touched.

Closes #1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 11:59:17 -04:00
AJ cda7d72e44 docs: update HANDOFF.md — #4 done, next is #1r secret-args badge
CI / Lint (ruff) (push) Successful in 10s
CI / Tests (py3.10) (push) Failing after 2m47s
CI / Tests (py3.12) (push) Successful in 9s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 02:09:35 -04:00
the_og 13d6b917bb Merge pull request 'feat: stale-file protection — detect concurrent edits on save' (#15) from feat/4-stale-file-protection into main
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.10) (push) Successful in 8s
CI / Tests (py3.12) (push) Successful in 8s
Merge feat/4-stale-file-protection: stale-file protection (closes #4)
2026-07-02 02:08:13 -04:00
AJ 9fa502c500 feat: detect stale config and prompt merge-or-overwrite on save
CI / Lint (ruff) (pull_request) Successful in 6s
CI / Tests (py3.10) (pull_request) Successful in 7s
CI / Tests (py3.12) (pull_request) Successful in 8s
Records the file mtime at load time; before write_config() fires, re-checks
it. If it changed (e.g. `claude mcp add`, a second BCC window, or Claude
itself writing ~/.claude.json), StaleDialog prompts with the changed top-level
key names and a masked server-section diff. "Merge & save" applies the user's
in-memory server edits on top of the current on-disk file (preserving external
non-server changes); "Overwrite anyway" proceeds as before.

- bcc_core: config_mtime(), external_change_summary(), _server_sections()
  helper extracted from backup_diff for reuse
- bcc.py: StaleDialog, MainWindow._loaded_mtime tracked through load/save
- tests: 7 new tests (config_mtime, external_change_summary variants, AC merge test)

Closes #4

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 01:57:32 -04:00
the_og be45be9eab Merge pull request 'feat: backup restore UI (#3)' (#14) from feat/3-backup-restore-ui into main
CI / Lint (ruff) (push) Successful in 16s
CI / Tests (py3.10) (push) Successful in 16s
CI / Tests (py3.12) (push) Successful in 15s
feat: backup restore UI (#3) — closes #3
2026-07-02 01:44:44 -04:00
AJ 6ab4789a70 fix: scope backup_diff to server sections and mask secrets
CI / Lint (ruff) (pull_request) Successful in 16s
CI / Tests (py3.10) (pull_request) Successful in 17s
CI / Tests (py3.12) (pull_request) Successful in 14s
The diff preview in RestoreDialog was serializing the full config dict,
exposing secret env values and token args in cleartext on a pasteable surface.

- Add _redact_server_data / _redact_servers_block helpers that apply
  redact_args to args and mask env values for is_secret_key() keys
- Rewrite backup_diff to compare only {mcpServers, _disabledMcpServers}
  sections (sanitized), not the whole file — also avoids double-serializing
  multi-MB ~/.claude.json for a servers-only diff
- Add clarifying comment in _restore_from_backup about why full_config
  is the right base after confirm-discard
- Add test: backup_diff with secret args/env → MASK in output, raw values absent
- Add test: restore_backup restores _disabledMcpServers correctly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 01:40:24 -04:00
AJ df332a06ba feat: backup restore UI (#3)
CI / Lint (ruff) (pull_request) Successful in 10s
CI / Tests (py3.10) (pull_request) Successful in 10s
CI / Tests (py3.12) (pull_request) Successful in 10s
Add list_backups / backup_label / backup_diff / restore_backup to bcc_core,
RestoreDialog to bcc.py, and a "Restore…" button in the profile top bar.

Restore is selective: only mcpServers and _disabledMcpServers are replaced;
all other keys in the config (history, project state) are preserved verbatim.
Goes through write_config() so a pre-restore backup is always created first.

Closes #3

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 01:22:52 -04:00
the_og 111fa8e367 Merge pull request 'feat: stdio server spawn-test (#2)' (#11) from feat/2-stdio-spawn-test into main
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.10) (push) Successful in 8s
CI / Tests (py3.12) (push) Successful in 7s
Merge feat/2-stdio-spawn-test into main
2026-07-02 01:11:48 -04:00
AJ 231403c1d5 fix: spawn-test stderr persistence and drain-cap deadlock (PR #11 review)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10) (pull_request) Successful in 8s
CI / Tests (py3.12) (pull_request) Successful in 10s
Two bugs caught in supervisor review:

1. Stderr was silently dropped when the Details panel was closed during a
   crash. _on_spawn_done appended to diag_text only when the panel was
   already open, and refresh_dependency() clobbered that text on the next
   field change anyway.
   Fix: stash the result in self._last_spawn; _full_diag_text() appends
   the stderr section whenever diag text is generated; _on_spawn_done
   auto-opens the panel on non-ok outcomes (same pattern as the existing
   auto_open for missing commands).

2. _drain stopped reading once _STDERR_CAP (4 KB) was reached. A process
   that writes more than 4 KB then blocked on a full pipe buffer, never
   exited, and was misclassified as "ok" instead of "crashed".
   Fix: drain to EOF unconditionally; keep only the first _STDERR_CAP bytes.
   Regression test: 64 KB stderr + exit(3) → outcome "crashed".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 01:05:34 -04:00
AJ e98cb7abd7 feat: stdio server spawn-test (issue #2)
CI / Lint (ruff) (pull_request) Successful in 8s
CI / Tests (py3.10) (pull_request) Successful in 8s
CI / Tests (py3.12) (pull_request) Successful in 7s
Add spawn_test() to bcc_core — spawns a stdio server for up to 3 s,
captures stderr, and reports ok/exited/crashed/not_found. Key design
decisions driven by real MCP server behaviour:

- stdin=PIPE (never written): servers block on JSON-RPC input and stay
  alive, so "still running after timeout" reliably signals a healthy
  start. stdin=DEVNULL would send EOF, causing well-behaved servers to
  exit 0 and be misclassified as "exited".
- Command resolved via shutil.which(augmented_path()) before Popen so
  subprocess PATH resolution is unambiguous across platforms.
- start_new_session=True on POSIX + os.killpg on timeout: kills the
  whole process group, not just the launcher (npx, uvx), which would
  otherwise orphan the actual node/python grandchild process.
- stdout=DEVNULL: draining a PIPE we don't read would deadlock at ~64 KB.
- stderr drained in a daemon thread, capped at 4 KB.

GUI: SpawnTester(QThread) wraps spawn_test; "Test launch" button in
ServerEditor dep row (stdio only, visible when command resolves ok/warn).
Result colours match the existing dep-status palette (green/amber/red).
Stderr appended to the diagnostics panel if it is open.

7 new unit tests cover all outcomes and the stdin-open regression guard.
Ran python bcc.py locally: button appears for stdio servers whose command
resolves, is hidden for remote servers and missing-command servers.

Closes #2

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 00:57:46 -04:00
AJ e0eb3c2d1b docs: add HANDOFF.md — backlog workflow for local agent + Gitea supervision
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.10) (push) Successful in 7s
CI / Tests (py3.12) (push) Successful in 7s
2026-07-02 00:35:46 -04:00
AJ 6dee318976 feat: mask secret values in the UI and redact them from diagnostics
- Env/header values whose key looks secret (TOKEN, API_KEY, PASSWORD,
  AUTH, ...) render as •••••••• via a display-only delegate; a
  'Show secrets' toggle reveals them. Underlying data, editing, and
  save are untouched.
- The Add dialog switches the value field to password echo when the
  key name looks secret.
- Copy-diagnostics now redacts secrets from args (--token <v>,
  --api-key=<v>, and well-known token prefixes like ghp_/sk-/xoxb-),
  since those reports get pasted into public bug reports. Env and
  header values were already omitted from diagnostics.
2026-07-02 00:31:34 -04:00
AJ b16c0fd526 fix: point Claude Code profile at ~/.claude.json, not settings.json
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.10) (push) Successful in 7s
CI / Tests (py3.12) (push) Successful in 7s
Claude Code stores user-scope MCP servers in ~/.claude.json (what
'claude mcp add' writes); ~/.claude/settings.json is for permissions
and hooks and rejects an mcpServers key with a schema error, so BCC
was reading (and writing) servers where Claude Code never looks.

If servers are found parked in settings.json, that file is still
listed as 'Claude Code (legacy settings.json)' so they can be copied
into the real config via Copy to. Docs updated; verified against
docs.claude.com/en/docs/claude-code/settings.
2026-07-02 00:27:01 -04:00
AJ 3c99ff547b fix: drop web-CSS font aliases from the Qt stylesheet
'-apple-system' is a web convention, not a real font family — Qt scans
every installed font trying to resolve it (the 'Populating font family
aliases' warning at startup). Qt already defaults to the native system
UI font on each platform, so don't name UI fonts at all. The diag
panel's 'SF Mono' (not system-installed on macOS) becomes Menlo.
2026-07-02 00:21:33 -04:00
AJ e11886bde9 feat: clearer Arguments editor with typed-together detection and one-click fix
- Label now explains the model with an example: a flag and its value go
  on separate lines. Placeholder shows the common uv pattern.
- split_suspicious_args() flags lines that contain whitespace plus a
  dash-prefixed token ('--directory /path') — legit single args with
  spaces ('My Documents') are never touched. Quotes are respected.
- The editor shows a warning under the args box with a 'Fix: split onto
  separate lines' button; the fix goes through the undo stack.
2026-07-02 00:13:06 -04:00
AJ 77f469c1dd feat: offer guided repair when loading a broken config file
If a config file on disk fails strict JSON parsing, BCC now runs it
through the same repair pipeline as pasted snippets and shows a dialog
listing the parse error, each fix it would apply, and a preview of the
resulting file. The user chooses: Repair & load (marks the profile
dirty; the file is only rewritten on Save, after the broken original
is backed up) or Cancel. Unsalvageable files keep the old error path.

repair_config_file() in bcc_core never writes to disk itself.
2026-07-02 00:10:27 -04:00
AJ d6ce4a0fb0 feat: numbered gutter in the Arguments editor
The one-arg-per-line model read as odd text wrapping — a path on its
own line looked like a wrapped continuation of the previous argument.
A line-number gutter makes each argument visibly its own item.
ArgsEdit also owns the NoWrap setting now.
2026-07-02 00:05:26 -04:00
AJ d2f7ce112c fix: legible inline cell editing + no soft-wrap in args box
- Cell editors in env/header tables got the global QLineEdit style
  (6px vertical padding, 7px radius) crammed into a short row, clipping
  the text to an unreadable sliver. Give table editors a compact flat
  style and bump default row height to 34px.
- Editor select-all now uses dim-orange/white selection colors inside
  cells for contrast against the dark field.
- Arguments box no longer soft-wraps: one arg per line means a wrapped
  path looks like two args. Long lines scroll horizontally instead.
2026-07-01 23:58:06 -04:00
AJ a02d2abe49 fix: readable text selection in inputs (selection-color on orange highlight)
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.10) (push) Successful in 7s
CI / Tests (py3.12) (push) Successful in 6s
Editing a table cell select-alls its text; without an explicit
selection-color the highlighted text rendered near-invisible against
the accent-orange selection background. Applies to all line edits,
text areas, and combo boxes.
2026-07-01 23:55:18 -04:00
AJ d5c89e9602 feat: user-resizable panes with persisted layout
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.10) (push) Successful in 6s
CI / Tests (py3.12) (push) Successful in 6s
- Horizontal splitter between the server list and the editor panel
- Vertical splitter between the Active and Disabled tables (drops the
  fixed 170px cap on the disabled list)
- Editor fields (args, env/header tables) now grow with the window
  instead of being pinned to fixed max heights
- Splitter positions and window geometry persist across launches via
  QSettings; handles highlight on hover/drag
2026-07-01 23:51:40 -04:00
AJ 6205c85b61 feat: lenient JSON repair for pasted MCP snippets
CI / Lint (ruff) (push) Successful in 10s
CI / Tests (py3.10) (push) Successful in 23s
CI / Tests (py3.12) (push) Successful in 10s
Pasted config snippets no longer have to be valid JSON. repair_json_text()
auto-fixes markdown fences, surrounding prose, // /* */ # comments,
trailing and missing commas, smart quotes, single quotes, unquoted keys,
Python/JS literals, and unclosed braces. parse_pasted_json_verbose()
reports every repair applied; the paste dialog now parses as you type
and previews exactly what will be added and what was fixed.

Also includes ruff lint fixes and formatting across bcc.py/bcc_core.py.
2026-07-01 23:43:39 -04:00
AJ 157dad9192 style: apply ruff lint fixes and formatting 2026-07-01 23:43:39 -04:00
AJ 8124150e34 ci: add lint + test workflow (ruff check/format, pytest on py3.10 & 3.12) 2026-07-01 23:43:30 -04:00
AJ 78595be2a2 test: migrate test_core.py script to a proper pytest suite under tests/ 2026-07-01 23:43:30 -04:00
AJ 5ec53b87bc chore: add pyproject.toml, ruff/pytest config, pre-commit hooks, dev deps 2026-07-01 23:43:30 -04:00
AJ Avezzano f5749947e1 fix: write BMP DIB entries in ICO so Windows exe shows correct icon
Pillow's ICO saver stores all sizes as PNG-compressed ("Vista icon"
format).  PyInstaller's Windows resource-updater cannot embed
PNG-compressed entries for small sizes and silently falls back to its
default gear icon.

Fix build_icons.py to write the ICO manually: BMP DIB for sizes ≤ 128 px,
PNG only for the 256 px entry (where Windows Explorer expects PNG).
Regenerate icons/app.ico with the new code.

Also set upx=False in bcc.spec for the Windows/Linux EXE; UPX is another
known cause of icon resources being stripped from PE files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 22:42:02 -04:00
16 changed files with 3972 additions and 285 deletions
+53
View File
@@ -0,0 +1,53 @@
name: CI
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
lint:
runs-on: ubuntu-latest
name: Lint (ruff)
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install ruff
run: pip install ruff
- name: ruff check
run: ruff check .
- name: ruff format --check
run: ruff format --check .
test:
runs-on: ubuntu-latest
name: Tests (py${{ matrix.python }})
strategy:
fail-fast: false
matrix:
python: ["3.10", "3.12"]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
# bcc_core has no GUI imports, so the test suite needs no PySide6 —
# keeps CI fast and avoids Qt system-library headaches on the runner.
- name: Install test dependencies
run: pip install pytest
- name: Run tests
run: python -m pytest -v
+6
View File
@@ -5,6 +5,12 @@ __pycache__/
.venv/
venv/
# Test / lint caches
.pytest_cache/
.ruff_cache/
.coverage
htmlcov/
# PyInstaller / build
build/
dist/
+15
View File
@@ -0,0 +1,15 @@
# Install once with: pip install pre-commit && pre-commit install
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.4
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-json
+9 -5
View File
@@ -8,10 +8,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
# Install runtime dependency
pip install -r requirements.txt
python bcc.py # run the GUI
python test_core.py # run unit tests (23 tests, no GUI needed)
# Test & lint (no GUI / PySide6 needed — tests only exercise bcc_core)
pip install -r requirements-dev.txt
python -m pytest # unit tests in tests/
ruff check . # lint
ruff format . # format (CI enforces ruff format --check)
# Build a self-contained binary
pip install -r requirements-dev.txt
python scripts/build_icons.py # regenerate icons/app.icns + icons/app.ico if needed
pyinstaller bcc.spec
# macOS → dist/BetterClaudeConfig.app
@@ -19,7 +23,7 @@ pyinstaller bcc.spec
# Linux → dist/BetterClaudeConfig
```
Requires Python 3.10+. Runtime dependency: `PySide6>=6.6`. Build-time: `pyinstaller>=6.0`, `pillow>=10.0`.
Requires Python 3.10+. Runtime dependency: `PySide6>=6.6`. Tooling config (ruff, pytest, project metadata) lives in `pyproject.toml`. CI (`.github/workflows/ci.yml`) runs lint + tests on every push/PR; releases build on tag push (`release.yml`).
## Architecture
@@ -27,9 +31,9 @@ The codebase is split into two layers:
**`bcc_core.py`** — All logic with no GUI imports. Contains:
- `Profile` / `ServerEntry` dataclasses (the data model)
- `discover_profiles()` — scans the platform's app-support directory for `Claude*` folders (Claude Desktop) **and** always adds `~/.claude/settings.json` (Claude Code, cross-platform)
- `discover_profiles()` — scans the platform's app-support directory for `Claude*` folders (Claude Desktop) **and** always adds `~/.claude.json` (Claude Code user scope — what `claude mcp add` writes). `~/.claude/settings.json` is NOT a server config (it rejects `mcpServers` with a schema error) and is only surfaced, labelled legacy, if servers are found parked in it. Project-scope `.mcp.json` files can be opened via Add config…
- `load_config` / `extract_servers` / `apply_servers` / `write_config` — the read/write pipeline; writes are atomic with rotating timestamped backups in `.bcc_backups/`
- `parse_pasted_json()` — accepts three JSON shapes (full config, inner map, or bare server object)
- `parse_pasted_json()` / `parse_pasted_json_verbose()` — accepts three JSON shapes (full config, inner map, or bare server object). Input does not have to be valid JSON: `repair_json_text()` auto-fixes markdown fences, surrounding prose, `//` `/* */` `#` comments, trailing/missing commas, smart quotes, single quotes, unquoted keys, Python/JS literals, and unclosed braces. The verbose variant also returns human-readable notes describing every repair applied (shown live in the paste dialog)
- `check_dependency()` / `diagnostics_text()` / `pin_command_path()` — PATH resolution logic; distinguishes "found on normal PATH" (ok) vs "found only on augmented PATH" (warn) vs "not found" (missing). Uses an `lru_cache`-memoized `augmented_path()` that extends the inherited PATH with common runtime locations (nvm, homebrew, cargo, volta, etc.)
- `test_remote()` — synchronous HTTP reachability check, intended to run off the UI thread
+95
View File
@@ -0,0 +1,95 @@
# HANDOFF — BCC feature backlog implementation
For the Claude Code agent working in this repo. Read this whole file, then
CLAUDE.md, before touching code. A supervising Claude session watches this
project through the Gitea MCP (issues, PRs, CI runs on git.avezzano.io), so
keep all state in Gitea where it can be seen: issues for work items, PRs for
changes, comments for decisions.
## Step 0 — sync state
`main` may contain local commits that aren't on origin yet (the previous
session couldn't push). Run `git status` / `git log origin/main..main`; if
there are unpushed commits, **push them first** and confirm CI goes green
before starting anything.
## Step 1 — file the backlog as issues
Create one Gitea issue per item in the backlog table below (`tea` CLI or the
API — you have full credentials). Prefix titles with the ID (e.g.
"[#2] Stdio server spawn-test"). Create labels P0/P1/P2 first if they don't
exist. Copy each item's full section from `../bcc-feature-requests.md`
(sibling of this repo folder) into the issue body — it has problem statements,
evidence, proposed fixes, and acceptance criteria.
**Corrections to that document — do not re-implement:**
- "Doc correction: README Claude Code path" — DONE (commit `b16c0fd`).
- Item 1 (secret masking) — MOSTLY DONE (commit `6dee318`): env/header values
masked via delegate + Show-secrets toggle, Add-dialog password echo,
diagnostics args redaction (`redact_args`). **Only remainder:** the warning
badge when a secret-shaped value sits in `args` instead of `env`. File the
issue scoped to just that.
## Step 2 — work the backlog
| Order | ID | Item | Priority | Notes |
|---|---|---|---|---|
| 1 | 2 | Stdio server spawn-test | P0 | Spawn with timeout, capture stderr; core logic in bcc_core (GUI-free, testable) |
| 2 | 3 | Backup restore UI | P0 | List `.bcc_backups/`, preview/diff, restore through normal atomic-write path |
| 3 | 4 | Stale-file protection | P0 | mtime/hash at load, re-check before write, prompt on conflict |
| 4 | 1r | Secret-in-args warning badge | P0 | Remainder of item 1; `is_secret_key`/`_is_secret_value` already exist in bcc_core |
| 5 | 8 | Duplicate-name conflict on paste/import | P1 | VERIFY FIRST: check `paste_json`/`dropEvent` in bcc.py for silent overwrite; file findings on the issue before coding |
| 6 | 5 | Cross-client support (Cursor/Windsurf/VS Code) | P1 | VS Code uses `servers` not `mcpServers` — needs a read/write adapter, not just paths |
| 7 | 6 | In-app MCP log viewer | P1 | Platform log paths in the doc |
| 8 | 7 | Windows MSIX path detection | P1 | Can't test locally on macOS — unit-test the detection logic, note that in the PR |
| 9 | 9 | Restart Claude Desktop button | P1 | Platform-specific process handling; scope to Desktop only |
| 10 | 10 | Server catalog / one-click add | P2 | Design pass first — post a proposal as an issue comment before building |
## Workflow rules
- **Branch per issue** off `main`: `feat/<issue-number>-short-slug` (or
`fix/`). One issue per PR. Reference the issue in the PR description
(`Closes #N`).
- **Before every commit:** `python -m pytest` (all green), `ruff check .`,
`ruff format .`. CI enforces all three.
- **Conventional commits** (`feat:`, `fix:`, `test:`, `docs:`, `chore:`) —
match the existing history's style: body explains the why.
- **Merge only with green CI.** If the supervising session has flagged
something on the PR, resolve it before merging.
## Architecture guardrails (violating these fails review)
1. **The cardinal rule:** config writes only ever touch `mcpServers` and
`_disabledMcpServers`. Every other key is preserved verbatim, in order.
This matters extra for `~/.claude.json`, which holds conversation history
and project state.
2. **Core/GUI split:** all logic in `bcc_core.py` (no GUI imports, unit-
testable); `bcc.py` stays a thin PySide6 shell. New logic gets tests in
`tests/` — they run without PySide6, keep it that way.
3. **No new runtime dependencies** without posting the justification on the
issue first. PySide6 is currently the only one.
4. **Writes are atomic + backed up** — route any new disk writes through
`write_config()`.
5. Secrets never appear in diagnostics output or logs — use `redact_args`/
`is_secret_key` from bcc_core.
## Current state (as of this handoff)
- **92 tests** in `tests/test_core.py`, ruff clean, CI = lint + pytest (py3.10/3.12).
- `main` = `ece049c` (PR #16 merged). No unpushed commits.
- **Completed items:** #2 (spawn-test, PR #11), #3 (backup restore UI, PR #14),
#4 (stale-file protection, PR #15), #1r (secret-in-args warning, PR #16).
- **Next item:** #8 — duplicate-name conflict handling. **VERIFY FIRST:** read
`paste_json` and `dropEvent` in `bcc.py` to check whether pasting/dropping a
server with a name that already exists silently overwrites it; file your
findings as a comment on issue #8 before writing any code.
- Recent features you should know exist: lenient JSON repair
(`repair_json_text`, `parse_pasted_json_verbose`, `repair_config_file` +
RepairDialog), ArgsEdit numbered gutter + `split_suspicious_args`,
resizable splitters with QSettings persistence, secret masking, Claude Code
profile at `~/.claude.json` (settings.json only as "legacy" when it holds
parked servers), backup restore UI (RestoreDialog, `list_backups`,
`backup_diff`, `restore_backup`), stale-file protection (StaleDialog,
`config_mtime`, `external_change_summary`).
- GUI can't be smoke-tested in CI; note in each PR whether you ran
`python bcc.py` locally and what you checked.
+12 -5
View File
@@ -32,12 +32,17 @@ python bcc.py
- **Auto-discovers installs** — scans the platform's app-support folder for any
`Claude*` directory (so `Claude` and `Claude-Work` both show up) **and** finds
Claude Code at `~/.claude/settings.json`. Use **Add config…** to point at any
other file manually.
Claude Code's user-scope config at `~/.claude.json` (the file `claude mcp add`
writes). Use **Add config…** to point at any other file manually — e.g. a
project's `.mcp.json`.
- **Form-based editing** — name, command, args (one per line), env vars, or for
remote servers: URL, transport, and headers. No raw JSON.
- **Paste JSON** — drop in any snippet from an MCP doc (full `mcpServers` block,
inner map, or a single bare server object); it's parsed and merged.
- **Paste JSON — even broken JSON** — drop in any snippet from an MCP doc (full
`mcpServers` block, inner map, or a single bare server object); it's parsed
and merged. The paste box parses as you type and auto-repairs the stuff docs
and chat windows love to break: markdown fences, surrounding prose, comments,
trailing or missing commas, smart quotes, single quotes, unquoted keys, and
unclosed braces — and tells you exactly what it fixed before you commit.
- **Drag & drop** a `.json` file onto the window to import servers from it.
- **Copy to ▸** — copy the selected server straight into your *other* install.
- **Active / Disabled sections** — servers are shown in two labelled lists with
@@ -76,7 +81,9 @@ After saving, **restart that Claude install** for changes to take effect.
| Windows | `%APPDATA%` |
| Linux | `~/.config` |
**Claude Code** (all platforms): `~/.claude/settings.json`
**Claude Code** (all platforms): `~/.claude.json` (user scope). If servers are
found parked in `~/.claude/settings.json` — where Claude Code ignores them — that
file is also listed, marked *legacy*, so you can copy them over.
## Files
+1169 -102
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -98,7 +98,7 @@ else:
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx=False, # UPX can strip icon resources from the PE on Windows
upx_exclude=[],
runtime_tmpdir=None,
console=False,
+1156 -53
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 113 KiB

+57
View File
@@ -0,0 +1,57 @@
[project]
name = "better-claude-config"
version = "1.2.0"
description = "Cross-platform GUI for editing the mcpServers block of Claude Desktop and Claude Code configs"
readme = "README.md"
license = { file = "LICENSE" }
requires-python = ">=3.10"
dependencies = [
"PySide6>=6.6",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"ruff>=0.6",
"pre-commit>=3.5",
]
build = [
"pyinstaller>=6.0",
"pillow>=10.0",
]
[project.urls]
Repository = "https://git.avezzano.io/the_og/better-claude-config"
# ---------------------------------------------------------------------------
# Tooling
# ---------------------------------------------------------------------------
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q"
[tool.ruff]
line-length = 100
target-version = "py310"
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"UP", # pyupgrade
"B", # bugbear
"SIM", # simplify
"RUF", # ruff-specific
]
ignore = [
"E501", # line length handled pragmatically; GUI strings run long
"SIM108", # ternary rewrites hurt readability in places
"RUF001", # UI strings intentionally use typographic glyphs (, →, ↳)
"RUF002",
"RUF003",
]
[tool.ruff.lint.per-file-ignores]
"bcc.py" = ["F405", "F403"] # Qt star-import style if ever used
+4
View File
@@ -4,3 +4,7 @@ PySide6>=6.6
# Build / packaging
pyinstaller>=6.0
pillow>=10.0 # generates icons/app.ico during CI (Windows build)
# Test / lint
pytest>=8.0
ruff>=0.6
+68 -9
View File
@@ -10,13 +10,11 @@ Requirements:
pip install pillow # for .ico
iconutil # built into macOS; for .icns
"""
from __future__ import annotations
import os
import platform
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
@@ -32,22 +30,83 @@ def build_ico():
print("Pillow not installed — skipping .ico generation (pip install pillow)")
return
sizes = [16, 32, 48, 64, 128, 256]
imgs = []
import io
import struct
sizes = [16, 24, 32, 48, 64, 128, 256]
images: list[tuple[int, object]] = []
for s in sizes:
p = SRC / f"icon-{s}.png"
if not p.exists():
print(f" Missing {p.name}, skipping")
continue
imgs.append(Image.open(p).convert("RGBA"))
images.append((s, Image.open(p).convert("RGBA")))
if not imgs:
if not images:
print(" No source PNGs found — cannot build .ico")
return
# Write the ICO manually so that entries ≤ 128 px use uncompressed BMP DIB
# and the 256 px entry uses PNG. Pillow's ICO saver stores all sizes as
# PNG-compressed ("Vista icon" format), which PyInstaller's Windows
# resource-updater cannot embed — it silently falls back to its default icon.
def bmp_dib(img: object) -> bytes:
"""Return a BMP DIB (BITMAPINFOHEADER + BGRA rows + AND mask)."""
w, h = img.size
# biHeight is doubled: top half = XOR mask (color), bottom = AND mask
hdr = struct.pack(
"<IiiHHIIiiII",
40, # biSize
w,
h * 2, # biHeight (doubled per ICO convention)
1, # biPlanes
32, # biBitCount
0, # biCompression (BI_RGB)
0, # biSizeImage (0 ok for BI_RGB)
0,
0,
0,
0,
)
pix = img.load()
rows = bytearray()
for y in range(h - 1, -1, -1): # bottom-up
for x in range(w):
r, g, b, a = pix[x, y]
rows += bytes([b, g, r, a])
# AND mask: 1 bit/pixel, rows padded to 32-bit boundary, all 0 (use alpha)
mask_row = ((w + 31) // 32) * 4
and_mask = bytes(mask_row * h)
return hdr + bytes(rows) + and_mask
def png_bytes(img: object) -> bytes:
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
blobs: list[tuple[int, bytes]] = []
for s, img in images:
blobs.append((s, bmp_dib(img) if s < 256 else png_bytes(img)))
n = len(blobs)
dir_offset = 6 + n * 16 # ICONDIR (6) + n × ICONDIRENTRY (16)
out = bytearray()
out += struct.pack("<HHH", 0, 1, n) # ICONDIR
cur = dir_offset
for s, blob in blobs:
w = h = s % 256 # 256 is stored as 0 in the byte field
out += struct.pack("<BBBBHHII", w, h, 0, 0, 1, 32, len(blob), cur)
cur += len(blob)
for _, blob in blobs:
out += blob
dest = OUT / "app.ico"
imgs[-1].save(dest, format="ICO", append_images=imgs[:-1])
print(f" Generated {dest.relative_to(ROOT)} ({dest.stat().st_size // 1024 + 1} KB)")
dest.write_bytes(out)
print(f" Generated {dest.relative_to(ROOT)} ({len(out) // 1024 + 1} KB)")
def build_icns():
-92
View File
@@ -1,92 +0,0 @@
import json, tempfile, shutil
from pathlib import Path
import bcc_core as c
tmp = Path(tempfile.mkdtemp())
ok = []
def check(name, cond):
ok.append(cond)
print(("PASS" if cond else "FAIL"), "-", name)
# ---- 1. discovery (monkeypatch the base dir) ----
base = tmp / "AppSupport"
for d in ("Claude", "Claude-Work", "NotClaude"):
(base / d).mkdir(parents=True)
(base / "Claude" / c.CONFIG_FILENAME).write_text("{}")
# Claude-Work has no config yet; NotClaude shouldn't match
c.app_support_base = lambda: base
profs = c.discover_profiles()
labels = sorted(p.label for p in profs)
check("discovers Claude + Claude-Work, not NotClaude",
"Claude" in labels and "Claude-Work" in labels and "NotClaude" not in labels)
check("flags missing config file", any(p.label=="Claude-Work" and not p.config_exists for p in profs))
# ---- 2. write preserves OTHER keys + order, only touches mcpServers ----
cfgpath = tmp / "real.json"
original = {
"globalShortcut": "Cmd+Shift+Space",
"mcpServers": {"old": {"command": "node", "args": ["x.js"]}},
"someOtherTool": {"keep": True},
}
cfgpath.write_text(json.dumps(original, indent=2))
cfg = c.load_config(cfgpath)
servers = c.extract_servers(cfg)
# add a new one, disable the old one
servers.append(c.ServerEntry("brave", {"command": "npx", "args": ["-y", "@x/brave"]}, True))
servers[0].enabled = False
c.apply_servers(cfg, servers)
c.write_config(cfgpath, cfg)
written = json.loads(cfgpath.read_text())
keys = list(written.keys())
check("preserved globalShortcut", written.get("globalShortcut") == "Cmd+Shift+Space")
check("preserved someOtherTool", written.get("someOtherTool") == {"keep": True})
check("disabled server parked in _disabledMcpServers", "old" in written.get("_disabledMcpServers", {}))
check("enabled server in mcpServers", "brave" in written.get("mcpServers", {}))
check("old not in active mcpServers", "old" not in written.get("mcpServers", {}))
check("key order kept (globalShortcut before mcpServers)", keys.index("globalShortcut") < keys.index("mcpServers"))
check("backup created", (cfgpath.parent / c.BACKUP_DIRNAME).is_dir() and any((cfgpath.parent / c.BACKUP_DIRNAME).iterdir()))
# ---- 3. paste parser, all shapes ----
full = '{"mcpServers": {"a": {"command": "node", "args": ["a.js"]}}}'
check("parse full config shape", list(c.parse_pasted_json(full)) == ["a"])
inner = '{"b": {"command": "uvx", "args": ["mcp-server-git"]}}'
check("parse inner block shape", list(c.parse_pasted_json(inner)) == ["b"])
bare = '{"command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]}'
parsed = c.parse_pasted_json(bare)
check("parse bare server + suggests name 'filesystem'", "filesystem" in parsed)
remote = '{"url": "https://mcp.asana.com/sse"}'
check("parse remote, suggests host name", "mcp" in c.parse_pasted_json(remote))
try:
c.parse_pasted_json('{"junk": 5}')
check("rejects junk", False)
except ValueError:
check("rejects junk", True)
# ---- 4. validation ----
bad = [c.ServerEntry("", {"command": "x"}, True),
c.ServerEntry("dup", {"command": "x"}, True),
c.ServerEntry("dup", {"command": "x"}, True),
c.ServerEntry("r", {"url": ""}, True),
c.ServerEntry("nocmd", {"command": ""}, True)]
probs = c.validate_servers(bad)
check("validation catches empty name", any("empty name" in p for p in probs))
check("validation catches duplicate", any("Duplicate" in p for p in probs))
check("validation catches missing url", any("no URL" in p for p in probs))
check("validation catches missing command", any("no command" in p for p in probs))
check("valid set passes clean", c.validate_servers([c.ServerEntry("good", {"command":"node"}, True)]) == [])
# ---- 5. dependency check (python3 exists; bogusxyz does not) ----
r_ok = c.check_dependency({"command": "python3"})
check("dep check finds python3", r_ok["status"] == "ok")
r_missing = c.check_dependency({"command": "definitely-not-real-binary-xyz"})
check("dep check flags missing", r_missing["status"] == "missing")
r_remote = c.check_dependency({"url": "https://example.com/mcp"})
check("dep check marks remote", r_remote["status"] == "remote")
# windows-style wrapper: cmd /c npx ... -> should look past cmd to npx
r_wrap = c.check_dependency({"command": "cmd", "args": ["/c", "definitely-not-real-xyz", "foo"]})
check("dep check sees through shell wrapper", "definitely-not-real-xyz" in r_wrap["label"])
print()
print(f"{sum(ok)}/{len(ok)} passed")
shutil.rmtree(tmp)
+1056
View File
File diff suppressed because it is too large Load Diff
+253
View File
@@ -0,0 +1,253 @@
"""Tests for the lenient JSON repair pipeline (repair_json_text +
parse_pasted_json_verbose). Every case here is a shape people actually paste
from MCP docs, blog posts, and chat windows."""
import json
import pytest
import bcc_core as c
GOOD = {"command": "npx", "args": ["-y", "@x/brave"]}
def assert_brave(servers):
assert "brave" in servers
assert servers["brave"]["command"] == "npx"
# --------------------------------------------------------------------------- #
# Valid JSON passes through untouched
# --------------------------------------------------------------------------- #
def test_strict_json_no_notes():
servers, notes = c.parse_pasted_json_verbose('{"brave": {"command": "npx", "args": []}}')
assert_brave(servers)
assert notes == []
# --------------------------------------------------------------------------- #
# Markdown / prose wrappers
# --------------------------------------------------------------------------- #
def test_markdown_fence():
text = 'Add this to your config:\n```json\n{"brave": {"command": "npx"}}\n```\nThen restart.'
servers, notes = c.parse_pasted_json_verbose(text)
assert_brave(servers)
assert any("fence" in n for n in notes)
def test_fence_without_language_tag():
text = '```\n{"brave": {"command": "npx"}}\n```'
servers, _ = c.parse_pasted_json_verbose(text)
assert_brave(servers)
def test_prose_around_bare_json():
text = 'Paste the following: {"brave": {"command": "npx"}} and restart Claude.'
servers, notes = c.parse_pasted_json_verbose(text)
assert_brave(servers)
assert any("before" in n or "after" in n for n in notes)
# --------------------------------------------------------------------------- #
# Comments
# --------------------------------------------------------------------------- #
def test_line_comments():
text = '{\n // the search server\n "brave": {"command": "npx"}\n}'
servers, notes = c.parse_pasted_json_verbose(text)
assert_brave(servers)
assert any("//" in n for n in notes)
def test_block_comments():
text = '{ /* config */ "brave": {"command": "npx"} }'
servers, _ = c.parse_pasted_json_verbose(text)
assert_brave(servers)
def test_url_with_double_slash_is_not_a_comment():
text = '{"remote": {"url": "https://mcp.example.com/sse"}}'
servers, notes = c.parse_pasted_json_verbose(text)
assert servers["remote"]["url"] == "https://mcp.example.com/sse"
assert notes == []
# --------------------------------------------------------------------------- #
# Commas
# --------------------------------------------------------------------------- #
def test_trailing_comma_object():
text = '{"brave": {"command": "npx", "args": ["-y"],},}'
servers, notes = c.parse_pasted_json_verbose(text)
assert_brave(servers)
assert any("trailing" in n for n in notes)
def test_missing_comma_between_servers():
text = '{"brave": {"command": "npx"}\n"git": {"command": "uvx"}}'
servers, notes = c.parse_pasted_json_verbose(text)
assert "brave" in servers and "git" in servers
assert any("missing comma" in n for n in notes)
def test_missing_comma_between_string_fields():
text = '{"brave": {"command": "npx"\n"args": ["-y"]}}'
servers, _ = c.parse_pasted_json_verbose(text)
assert servers["brave"]["args"] == ["-y"]
# --------------------------------------------------------------------------- #
# Quotes
# --------------------------------------------------------------------------- #
def test_smart_quotes():
text = "{“brave”: {“command”: “npx”}}"
servers, notes = c.parse_pasted_json_verbose(text)
assert_brave(servers)
assert any("smart quotes" in n for n in notes)
def test_single_quotes():
text = "{'brave': {'command': 'npx', 'args': ['-y']}}"
servers, notes = c.parse_pasted_json_verbose(text)
assert_brave(servers)
assert any("single-quoted" in n for n in notes)
def test_single_quotes_with_inner_double():
text = """{'brave': {'command': 'npx', 'env': {'NOTE': 'say "hi"'}}}"""
servers, _ = c.parse_pasted_json_verbose(text)
assert servers["brave"]["env"]["NOTE"] == 'say "hi"'
# --------------------------------------------------------------------------- #
# JS-style laxness
# --------------------------------------------------------------------------- #
def test_unquoted_keys():
text = '{brave: {command: "npx", args: ["-y"]}}'
servers, notes = c.parse_pasted_json_verbose(text)
assert_brave(servers)
assert any("bare object keys" in n for n in notes)
def test_python_literals():
text = '{"brave": {"command": "npx", "enabled": True, "extra": None}}'
servers, notes = c.parse_pasted_json_verbose(text)
assert servers["brave"]["enabled"] is True
assert servers["brave"]["extra"] is None
assert any("literals" in n for n in notes)
# --------------------------------------------------------------------------- #
# Structure damage
# --------------------------------------------------------------------------- #
def test_braceless_fragment():
text = '"brave": {"command": "npx", "args": ["-y"]}'
servers, notes = c.parse_pasted_json_verbose(text)
assert_brave(servers)
assert any("wrapped" in n for n in notes)
def test_missing_closing_braces():
text = '{"mcpServers": {"brave": {"command": "npx", "args": ["-y"]'
servers, notes = c.parse_pasted_json_verbose(text)
assert_brave(servers)
assert any("unclosed" in n for n in notes)
def test_kitchen_sink():
# fence + comment + single quotes + unquoted key + trailing comma + prose
text = (
"Here's the config you need:\n"
"```json\n"
"{\n"
" // brave search\n"
" brave: {\n"
" 'command': 'npx',\n"
" 'args': ['-y', '@x/brave',],\n"
" },\n"
"}\n"
"```\n"
)
servers, notes = c.parse_pasted_json_verbose(text)
assert_brave(servers)
assert servers["brave"]["args"] == ["-y", "@x/brave"]
assert len(notes) >= 4
# --------------------------------------------------------------------------- #
# Suspicious-args detection (several argv tokens typed on one line)
# --------------------------------------------------------------------------- #
def test_split_flag_and_value_on_one_line():
fixed, notes = c.split_suspicious_args(["--directory /path/to/server", "run", "main.py"])
assert fixed == ["--directory", "/path/to/server", "run", "main.py"]
assert len(notes) == 1
def test_split_full_option_string():
fixed, notes = c.split_suspicious_args(["-y @pkg/server --port 8080"])
assert fixed == ["-y", "@pkg/server", "--port", "8080"]
assert notes
def test_split_respects_quotes():
fixed, _ = c.split_suspicious_args(['--name "My Server"'])
assert fixed == ["--name", "My Server"]
def test_split_leaves_legit_spaces_alone():
args = ["/Users/me/My Documents", "hello world", "run"]
fixed, notes = c.split_suspicious_args(args)
assert fixed == args
assert notes == []
def test_split_leaves_clean_args_alone():
args = ["--directory", "/path/to/server", "run", "main.py"]
fixed, notes = c.split_suspicious_args(args)
assert fixed == args
assert notes == []
# --------------------------------------------------------------------------- #
# Config-file repair (load-time)
# --------------------------------------------------------------------------- #
def test_repair_config_file_fixes_and_reports(tmp_path):
p = tmp_path / "claude_desktop_config.json"
p.write_text(
'{\n "globalShortcut": "Cmd+Space",\n'
" // my servers\n"
' "mcpServers": {\n'
' "brave": {"command": "npx", "args": ["-y"],},\n'
" },\n}"
)
cfg, notes, preview = c.repair_config_file(p)
assert cfg["mcpServers"]["brave"]["command"] == "npx"
assert cfg["globalShortcut"] == "Cmd+Space" # unrelated keys survive
assert any("comment" in n for n in notes)
assert any("trailing" in n for n in notes)
assert json.loads(preview) == cfg
# nothing was written to disk
assert "//" in p.read_text()
def test_repair_config_file_hopeless_raises(tmp_path):
p = tmp_path / "claude_desktop_config.json"
p.write_text("total nonsense, no braces at all")
with pytest.raises(ValueError):
c.repair_config_file(p)
# --------------------------------------------------------------------------- #
# Still fails cleanly on hopeless input
# --------------------------------------------------------------------------- #
def test_hopeless_input_raises_value_error():
with pytest.raises(ValueError):
c.parse_pasted_json_verbose("this is just prose with no json at all")
def test_junk_object_still_rejected():
with pytest.raises(ValueError):
c.parse_pasted_json_verbose('{"junk": 5}')
def test_empty_raises():
with pytest.raises(ValueError):
c.parse_pasted_json_verbose(" ")