feat: lenient JSON repair for pasted MCP snippets
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.
This commit is contained in:
+386
-52
@@ -10,17 +10,20 @@ ever touch the `mcpServers` block (and our own `_disabledMcpServers` parking
|
||||
key). Every other top-level key in the user's config is preserved verbatim and
|
||||
in its original position.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import functools
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@@ -45,6 +48,7 @@ KNOWN_FIELDS = {"command", "args", "env", "url", "type", "headers"}
|
||||
@dataclass
|
||||
class Profile:
|
||||
"""A discovered (or manually added) Claude install location."""
|
||||
|
||||
label: str
|
||||
path: Path
|
||||
config_exists: bool
|
||||
@@ -171,10 +175,8 @@ def _make_backup(path: Path) -> Path:
|
||||
# Prune oldest beyond MAX_BACKUPS
|
||||
backups = sorted(bdir.glob(f"{path.stem}.*.json"))
|
||||
for old in backups[:-MAX_BACKUPS]:
|
||||
try:
|
||||
with contextlib.suppress(OSError):
|
||||
old.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return dest
|
||||
|
||||
|
||||
@@ -210,7 +212,7 @@ def looks_like_server(data) -> bool:
|
||||
def suggest_name(data: dict) -> str:
|
||||
"""Derive a friendly default name from a server definition."""
|
||||
if "url" in data and "command" not in data:
|
||||
host = (urlparse(data["url"]).hostname or "remote")
|
||||
host = urlparse(data["url"]).hostname or "remote"
|
||||
return host.split(".")[0] or "remote"
|
||||
for a in data.get("args", []) or []:
|
||||
if isinstance(a, str) and ("/" in a or a.startswith("@")):
|
||||
@@ -222,20 +224,295 @@ def suggest_name(data: dict) -> str:
|
||||
return Path(str(cmd)).stem or "server"
|
||||
|
||||
|
||||
def parse_pasted_json(text: str) -> dict[str, dict]:
|
||||
"""
|
||||
Accept any of the shapes MCP docs hand out and return {name: server_dict}:
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Lenient JSON repair
|
||||
#
|
||||
# Snippets pasted from MCP docs, blog posts, and chat windows are frequently
|
||||
# not valid JSON: markdown fences, surrounding prose, // comments, trailing
|
||||
# commas, smart quotes, single quotes, unquoted keys, missing braces. The
|
||||
# whole point of BCC is that nobody should have to hand-fix JSON, so the
|
||||
# paste pipeline repairs what it can and reports what it changed.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
1. Full config: {"mcpServers": {"name": {...}}}
|
||||
2. Inner block only: {"name": {"command": ...}}
|
||||
3. A bare server obj: {"command": ..., "args": [...]} (name is suggested)
|
||||
# Word-processor / web artifacts mapped back to ASCII.
|
||||
_QUOTE_MAP = {
|
||||
"“": '"',
|
||||
"”": '"',
|
||||
"„": '"',
|
||||
"«": '"',
|
||||
"»": '"',
|
||||
"‘": "'",
|
||||
"’": "'",
|
||||
"‚": "'",
|
||||
}
|
||||
_JUNK_CHARS = "" # zero-width chars / BOM
|
||||
|
||||
Raises ValueError with a human message on anything unrecognizable.
|
||||
|
||||
def _strip_fences(text: str, notes: list[str]) -> str:
|
||||
"""Pull the contents out of a ```json ... ``` fence (or drop stray fence lines)."""
|
||||
m = re.search(r"```(?:json[c5]?|javascript|js)?\s*\n(.*?)```", text, re.DOTALL | re.IGNORECASE)
|
||||
if m:
|
||||
notes.append("extracted code from markdown fence")
|
||||
return m.group(1)
|
||||
if "```" in text:
|
||||
notes.append("removed markdown fence markers")
|
||||
return text.replace("```json", "").replace("```", "")
|
||||
return text
|
||||
|
||||
|
||||
def _normalize_unicode(text: str, notes: list[str]) -> str:
|
||||
out = text
|
||||
for junk in _JUNK_CHARS:
|
||||
out = out.replace(junk, "")
|
||||
out = out.replace(" ", " ") # non-breaking space
|
||||
for smart, ascii_q in _QUOTE_MAP.items():
|
||||
out = out.replace(smart, ascii_q)
|
||||
if out != text:
|
||||
notes.append("normalized smart quotes / invisible characters")
|
||||
return out
|
||||
|
||||
|
||||
def _tokenize(text: str, notes: list[str]) -> list[tuple[bool, str]]:
|
||||
"""
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
One careful pass over the text producing (is_string, segment) pairs.
|
||||
While walking it also: converts single-quoted strings to double-quoted,
|
||||
and drops //, /* */ and # comments (never inside strings).
|
||||
"""
|
||||
segs: list[tuple[bool, str]] = []
|
||||
buf: list[str] = []
|
||||
i, n = 0, len(text)
|
||||
|
||||
def flush():
|
||||
if buf:
|
||||
segs.append((False, "".join(buf)))
|
||||
buf.clear()
|
||||
|
||||
while i < n:
|
||||
ch = text[i]
|
||||
if ch == '"': # proper double-quoted string: copy verbatim
|
||||
flush()
|
||||
j = i + 1
|
||||
s = ['"']
|
||||
while j < n:
|
||||
c = text[j]
|
||||
s.append(c)
|
||||
if c == "\\" and j + 1 < n:
|
||||
s.append(text[j + 1])
|
||||
j += 2
|
||||
continue
|
||||
if c == '"':
|
||||
j += 1
|
||||
break
|
||||
j += 1
|
||||
segs.append((True, "".join(s)))
|
||||
i = j
|
||||
elif ch == "'": # single-quoted string: convert to double-quoted
|
||||
flush()
|
||||
j = i + 1
|
||||
inner: list[str] = []
|
||||
while j < n:
|
||||
c = text[j]
|
||||
if c == "\\" and j + 1 < n:
|
||||
nxt = text[j + 1]
|
||||
if nxt == "'":
|
||||
inner.append("'") # \' has no meaning in JSON
|
||||
else:
|
||||
inner.append(c)
|
||||
inner.append(nxt)
|
||||
j += 2
|
||||
continue
|
||||
if c == "'":
|
||||
j += 1
|
||||
break
|
||||
if c == '"':
|
||||
inner.append('\\"')
|
||||
j += 1
|
||||
continue
|
||||
inner.append(c)
|
||||
j += 1
|
||||
segs.append((True, '"' + "".join(inner) + '"'))
|
||||
notes.append("converted single-quoted strings")
|
||||
i = j
|
||||
elif ch == "/" and i + 1 < n and text[i + 1] == "/":
|
||||
notes.append("removed // comments")
|
||||
while i < n and text[i] != "\n":
|
||||
i += 1
|
||||
elif ch == "/" and i + 1 < n and text[i + 1] == "*":
|
||||
notes.append("removed /* */ comments")
|
||||
i += 2
|
||||
while i + 1 < n and not (text[i] == "*" and text[i + 1] == "/"):
|
||||
i += 1
|
||||
i = min(i + 2, n)
|
||||
elif ch == "#":
|
||||
# Only treat as a comment at line start (after whitespace) — never
|
||||
# mid-value, where # could be part of an unquoted token.
|
||||
line_start = text.rfind("\n", 0, i) + 1
|
||||
if text[line_start:i].strip() == "":
|
||||
notes.append("removed # comments")
|
||||
while i < n and text[i] != "\n":
|
||||
i += 1
|
||||
else:
|
||||
buf.append(ch)
|
||||
i += 1
|
||||
else:
|
||||
buf.append(ch)
|
||||
i += 1
|
||||
flush()
|
||||
return segs
|
||||
|
||||
|
||||
def _repair_segments(segs: list[tuple[bool, str]], notes: list[str]) -> str:
|
||||
"""Structural fixes that only apply outside strings."""
|
||||
fixed: list[str] = []
|
||||
for idx, (is_str, seg) in enumerate(segs):
|
||||
if is_str:
|
||||
fixed.append(seg)
|
||||
continue
|
||||
s = seg
|
||||
# Python / JS literals -> JSON
|
||||
s2 = re.sub(r"\bTrue\b", "true", s)
|
||||
s2 = re.sub(r"\bFalse\b", "false", s2)
|
||||
s2 = re.sub(r"\bNone\b|\bundefined\b", "null", s2)
|
||||
if s2 != s:
|
||||
notes.append("converted Python/JS literals (True/False/None)")
|
||||
s = s2
|
||||
# Unquoted keys: { key: or , key: -> "key":
|
||||
s2 = re.sub(r"([{,]\s*)([A-Za-z_$][\w$.-]*)(\s*:)", r'\1"\2"\3', s)
|
||||
if s2 != s:
|
||||
notes.append("quoted bare object keys")
|
||||
s = s2
|
||||
# Missing comma before the next string on a new line, e.g.
|
||||
# "args": ["x"]\n"env": {...} or {...}\n"name2": {...}
|
||||
# A string directly after a value-ish ending across a newline can never
|
||||
# be valid JSON without a comma, so inserting one is always safe.
|
||||
if idx + 1 < len(segs) and segs[idx + 1][0]:
|
||||
body = s.rstrip()
|
||||
tail_ws = s[len(body) :]
|
||||
prev = body[-1:] if body else (fixed[-1].rstrip()[-1:] if fixed else "")
|
||||
value_ending = prev in ('"', "}", "]") or prev.isdigit()
|
||||
if "\n" in tail_ws and value_ending:
|
||||
notes.append("inserted missing commas")
|
||||
s = body + "," + tail_ws
|
||||
fixed.append(s)
|
||||
|
||||
out = "".join(fixed)
|
||||
# Trailing commas (safe now: strings are intact, commas here are structural)
|
||||
segs2 = _tokenize(out, [])
|
||||
parts: list[str] = []
|
||||
changed = False
|
||||
for is_str, seg in segs2:
|
||||
if is_str:
|
||||
parts.append(seg)
|
||||
else:
|
||||
new = re.sub(r",(\s*[}\]])", r"\1", seg)
|
||||
changed = changed or new != seg
|
||||
parts.append(new)
|
||||
if changed:
|
||||
notes.append("removed trailing commas")
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _extract_and_balance(text: str, notes: list[str]) -> str:
|
||||
"""Slice out the JSON object (dropping surrounding prose) and close any
|
||||
unclosed braces/brackets."""
|
||||
start = text.find("{")
|
||||
if start == -1:
|
||||
return text
|
||||
if text[:start].strip():
|
||||
notes.append("ignored text before the JSON block")
|
||||
|
||||
stack: list[str] = []
|
||||
i, n = start, len(text)
|
||||
end = -1
|
||||
while i < n:
|
||||
ch = text[i]
|
||||
if ch == '"': # skip strings
|
||||
i += 1
|
||||
while i < n:
|
||||
if text[i] == "\\":
|
||||
i += 2
|
||||
continue
|
||||
if text[i] == '"':
|
||||
break
|
||||
i += 1
|
||||
elif ch in "{[":
|
||||
stack.append("}" if ch == "{" else "]")
|
||||
elif ch in "}]":
|
||||
if stack and stack[-1] == ch:
|
||||
stack.pop()
|
||||
if not stack:
|
||||
end = i + 1
|
||||
break
|
||||
i += 1
|
||||
|
||||
if end != -1:
|
||||
if text[end:].strip():
|
||||
notes.append("ignored text after the JSON block")
|
||||
return text[start:end]
|
||||
# Ran out of input with open scopes: close them.
|
||||
if stack:
|
||||
notes.append("closed unclosed braces/brackets")
|
||||
return text[start:] + "".join(reversed(stack))
|
||||
return text[start:]
|
||||
|
||||
|
||||
def repair_json_text(text: str) -> tuple[str, list[str]]:
|
||||
"""
|
||||
Best-effort repair of an almost-JSON snippet. Returns (candidate, notes)
|
||||
where notes is a human-readable list of the fixes applied (deduplicated,
|
||||
in order). Does NOT guarantee the result parses — callers still try
|
||||
json.loads and surface its error if repair wasn't enough.
|
||||
"""
|
||||
notes: list[str] = []
|
||||
t = _strip_fences(text, notes)
|
||||
t = _normalize_unicode(t, notes)
|
||||
t = t.strip()
|
||||
|
||||
# Brace-less inner fragment: "name": { ... } (with no outer braces).
|
||||
# A quoted key at the start is a strong signal; a bare word only counts
|
||||
# when followed by '{' so prose like "Note: ..." isn't swallowed.
|
||||
if re.match(r'^\s*"[^"\n]+"\s*:\s*[{["]', t) or re.match(r"^\s*[A-Za-z_$][\w$.-]*\s*:\s*\{", t):
|
||||
notes.append("wrapped fragment in braces")
|
||||
t = "{" + t + "}"
|
||||
|
||||
t = _extract_and_balance(t, notes)
|
||||
segs = _tokenize(t, notes)
|
||||
t = _repair_segments(segs, notes)
|
||||
|
||||
# Re-balance in case comment/quote fixes exposed structure.
|
||||
t = _extract_and_balance(t, [])
|
||||
|
||||
seen: set[str] = set()
|
||||
unique = [x for x in notes if not (x in seen or seen.add(x))]
|
||||
return t, unique
|
||||
|
||||
|
||||
def parse_pasted_json_verbose(text: str) -> tuple[dict[str, dict], list[str]]:
|
||||
"""
|
||||
Like parse_pasted_json, but forgiving. Tries strict JSON first; if that
|
||||
fails, runs repair_json_text() and retries. Returns (servers, repair_notes).
|
||||
repair_notes is empty when the input was already valid.
|
||||
"""
|
||||
raw = (text or "").strip()
|
||||
if not raw:
|
||||
raise ValueError("Nothing to parse.")
|
||||
obj = json.loads(text)
|
||||
try:
|
||||
return _shape_servers(json.loads(raw)), []
|
||||
except json.JSONDecodeError as strict_err:
|
||||
candidate, notes = repair_json_text(raw)
|
||||
try:
|
||||
obj = json.loads(candidate)
|
||||
except json.JSONDecodeError:
|
||||
# Repair wasn't enough — report the original, more meaningful error.
|
||||
raise ValueError(
|
||||
f"Couldn't parse that as JSON even after auto-repair "
|
||||
f"(line {strict_err.lineno}, column {strict_err.colno}: {strict_err.msg})."
|
||||
) from strict_err
|
||||
return _shape_servers(obj), notes
|
||||
|
||||
|
||||
def _shape_servers(obj) -> dict[str, dict]:
|
||||
"""Shared shape-detection for parsed paste content."""
|
||||
if not isinstance(obj, dict):
|
||||
raise ValueError("Top-level JSON must be an object ({ ... }).")
|
||||
|
||||
@@ -252,13 +529,30 @@ def parse_pasted_json(text: str) -> dict[str, dict]:
|
||||
for name, data in servers.items():
|
||||
if not looks_like_server(data):
|
||||
raise ValueError(
|
||||
f"'{name}' doesn't look like an MCP server "
|
||||
f"(it needs a 'command' or a 'url')."
|
||||
f"'{name}' doesn't look like an MCP server (it needs a 'command' or a 'url')."
|
||||
)
|
||||
clean[str(name)] = data
|
||||
return clean
|
||||
|
||||
|
||||
def parse_pasted_json(text: str) -> dict[str, dict]:
|
||||
"""
|
||||
Accept any of the shapes MCP docs hand out and return {name: server_dict}:
|
||||
|
||||
1. Full config: {"mcpServers": {"name": {...}}}
|
||||
2. Inner block only: {"name": {"command": ...}}
|
||||
3. A bare server obj: {"command": ..., "args": [...]} (name is suggested)
|
||||
|
||||
Input doesn't have to be valid JSON — markdown fences, comments, trailing
|
||||
commas, smart/single quotes, unquoted keys, surrounding prose, and missing
|
||||
braces are repaired automatically (see repair_json_text).
|
||||
|
||||
Raises ValueError with a human message on anything unrecognizable.
|
||||
"""
|
||||
servers, _notes = parse_pasted_json_verbose(text)
|
||||
return servers
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Validation
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -308,26 +602,30 @@ def system_path() -> str:
|
||||
dirs: list[str] = []
|
||||
|
||||
if sys.platform == "darwin":
|
||||
for p in ["/etc/paths"] + sorted(glob.glob("/etc/paths.d/*")):
|
||||
try:
|
||||
for p in ["/etc/paths", *sorted(glob.glob("/etc/paths.d/*"))]:
|
||||
with contextlib.suppress(OSError):
|
||||
dirs.extend(ln.strip() for ln in Path(p).read_text().splitlines() if ln.strip())
|
||||
except OSError:
|
||||
pass
|
||||
# Package-manager install prefixes: present on disk once installed,
|
||||
# accessible to all processes regardless of shell configuration.
|
||||
for d in (
|
||||
"/opt/homebrew/bin", "/opt/homebrew/sbin", # Homebrew (Apple Silicon)
|
||||
"/usr/local/bin", "/usr/local/sbin", # Homebrew (Intel) / manual installs
|
||||
"/opt/local/bin", "/opt/local/sbin", # MacPorts
|
||||
"/opt/homebrew/bin",
|
||||
"/opt/homebrew/sbin", # Homebrew (Apple Silicon)
|
||||
"/usr/local/bin",
|
||||
"/usr/local/sbin", # Homebrew (Intel) / manual installs
|
||||
"/opt/local/bin",
|
||||
"/opt/local/sbin", # MacPorts
|
||||
):
|
||||
dirs.append(d)
|
||||
|
||||
elif os.name == "nt":
|
||||
try:
|
||||
import winreg
|
||||
|
||||
for hive, sub in [
|
||||
(winreg.HKEY_LOCAL_MACHINE,
|
||||
r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment"),
|
||||
(
|
||||
winreg.HKEY_LOCAL_MACHINE,
|
||||
r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment",
|
||||
),
|
||||
(winreg.HKEY_CURRENT_USER, r"Environment"),
|
||||
]:
|
||||
try:
|
||||
@@ -338,19 +636,23 @@ def system_path() -> str:
|
||||
pass
|
||||
except ImportError:
|
||||
pass
|
||||
dirs.extend(["C:\\Windows\\System32", "C:\\Windows",
|
||||
"C:\\Windows\\System32\\Wbem",
|
||||
os.path.expandvars(r"%ProgramFiles%\nodejs")])
|
||||
dirs.extend(
|
||||
[
|
||||
"C:\\Windows\\System32",
|
||||
"C:\\Windows",
|
||||
"C:\\Windows\\System32\\Wbem",
|
||||
os.path.expandvars(r"%ProgramFiles%\nodejs"),
|
||||
]
|
||||
)
|
||||
|
||||
else: # Linux / other
|
||||
try:
|
||||
for line in Path("/etc/environment").read_text().splitlines():
|
||||
if line.upper().startswith("PATH="):
|
||||
dirs.extend(line[5:].strip('"\'').split(os.pathsep))
|
||||
dirs.extend(line[5:].strip("\"'").split(os.pathsep))
|
||||
except OSError:
|
||||
pass
|
||||
for d in ("/usr/local/bin", "/usr/bin", "/bin",
|
||||
"/usr/sbin", "/sbin", "/snap/bin"):
|
||||
for d in ("/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin", "/snap/bin"):
|
||||
dirs.append(d)
|
||||
|
||||
seen, out = set(), []
|
||||
@@ -384,7 +686,9 @@ def augmented_path() -> str:
|
||||
str(home / ".volta" / "bin"),
|
||||
]
|
||||
user_dirs += glob.glob(str(home / ".nvm" / "versions" / "node" / "*" / "bin"))
|
||||
user_dirs += glob.glob(str(home / ".local" / "share" / "fnm" / "node-versions" / "*" / "installation" / "bin"))
|
||||
user_dirs += glob.glob(
|
||||
str(home / ".local" / "share" / "fnm" / "node-versions" / "*" / "installation" / "bin")
|
||||
)
|
||||
if os.name == "nt":
|
||||
appdata = os.environ.get("APPDATA", "")
|
||||
if appdata:
|
||||
@@ -407,12 +711,12 @@ def refresh_path_cache():
|
||||
# Tailored install advice keyed by the runner's basename.
|
||||
RUNNER_HINTS = {
|
||||
"npx": "Node.js / npx not found. Install Node from nodejs.org or `brew install node`. "
|
||||
"With nvm, the binary lives at ~/.nvm/versions/node/<version>/bin.",
|
||||
"With nvm, the binary lives at ~/.nvm/versions/node/<version>/bin.",
|
||||
"node": "Node.js not found. Install from nodejs.org or `brew install node`.",
|
||||
"uvx": "uv not found. Install with `curl -LsSf https://astral.sh/uv/install.sh | sh` "
|
||||
"(or `brew install uv`). uvx ships inside uv.",
|
||||
"(or `brew install uv`). uvx ships inside uv.",
|
||||
"uv": "uv not found. Install with `curl -LsSf https://astral.sh/uv/install.sh | sh` "
|
||||
"(or `brew install uv`).",
|
||||
"(or `brew install uv`).",
|
||||
"python": "Python not found on this PATH.",
|
||||
"python3": "Python 3 not found on this PATH.",
|
||||
"docker": "Docker not found. Install Docker Desktop and make sure it's running.",
|
||||
@@ -428,8 +732,10 @@ def runner_hint(cmd: str) -> str:
|
||||
if base in RUNNER_HINTS:
|
||||
return RUNNER_HINTS[base]
|
||||
if ("/" in cmd) or ("\\" in cmd):
|
||||
return (f"'{cmd}' looks like a file path, but it doesn't exist or isn't executable. "
|
||||
f"Check the path and that it's marked executable (chmod +x).")
|
||||
return (
|
||||
f"'{cmd}' looks like a file path, but it doesn't exist or isn't executable. "
|
||||
f"Check the path and that it's marked executable (chmod +x)."
|
||||
)
|
||||
return f"'{cmd}' was not found on PATH. Install it, or put the full path to the executable in 'command'."
|
||||
|
||||
|
||||
@@ -466,14 +772,29 @@ def check_dependency(data: dict, path: str | None = None) -> dict:
|
||||
Returns: {status, label, resolved, in_base, searched_path, hints, commands, detail}
|
||||
"""
|
||||
if "url" in data and "command" not in data:
|
||||
return {"status": "remote", "label": "remote endpoint", "detail": data.get("url", ""),
|
||||
"resolved": {}, "in_base": {}, "searched_path": "", "hints": [], "commands": []}
|
||||
return {
|
||||
"status": "remote",
|
||||
"label": "remote endpoint",
|
||||
"detail": data.get("url", ""),
|
||||
"resolved": {},
|
||||
"in_base": {},
|
||||
"searched_path": "",
|
||||
"hints": [],
|
||||
"commands": [],
|
||||
}
|
||||
|
||||
cmds = _commands_to_check(data)
|
||||
if not cmds:
|
||||
return {"status": "unknown", "label": "no command set", "detail": "",
|
||||
"resolved": {}, "in_base": {}, "searched_path": "",
|
||||
"hints": ["This server has no 'command' to run."], "commands": []}
|
||||
return {
|
||||
"status": "unknown",
|
||||
"label": "no command set",
|
||||
"detail": "",
|
||||
"resolved": {},
|
||||
"in_base": {},
|
||||
"searched_path": "",
|
||||
"hints": ["This server has no 'command' to run."],
|
||||
"commands": [],
|
||||
}
|
||||
|
||||
aug = path or augmented_path()
|
||||
resolved: dict[str, str | None] = {}
|
||||
@@ -515,8 +836,16 @@ def check_dependency(data: dict, path: str | None = None) -> dict:
|
||||
status = "ok"
|
||||
label = "found: " + ", ".join(Path(p).name for p in resolved.values())
|
||||
|
||||
return {"status": status, "label": label, "resolved": resolved, "in_base": in_base,
|
||||
"searched_path": aug, "hints": hints, "commands": cmds, "detail": ""}
|
||||
return {
|
||||
"status": status,
|
||||
"label": label,
|
||||
"resolved": resolved,
|
||||
"in_base": in_base,
|
||||
"searched_path": aug,
|
||||
"hints": hints,
|
||||
"commands": cmds,
|
||||
"detail": "",
|
||||
}
|
||||
|
||||
|
||||
def diagnostics_text(name: str, data: dict) -> str:
|
||||
@@ -563,8 +892,10 @@ def diagnostics_text(name: str, data: dict) -> str:
|
||||
L.append((" + " if d in extra else " ") + d)
|
||||
if extra:
|
||||
L.append("")
|
||||
L.append("(+ = user-specific dirs not in the standard system PATH. "
|
||||
"Claude Desktop launched from Finder may NOT have these.)")
|
||||
L.append(
|
||||
"(+ = user-specific dirs not in the standard system PATH. "
|
||||
"Claude Desktop launched from Finder may NOT have these.)"
|
||||
)
|
||||
return "\n".join(L)
|
||||
|
||||
|
||||
@@ -582,9 +913,12 @@ def test_remote(url: str, timeout: float = 5.0) -> tuple[bool, str]:
|
||||
if not (url.startswith("http://") or url.startswith("https://")):
|
||||
return False, "not an http(s) URL"
|
||||
req = urllib.request.Request(
|
||||
url, method="GET",
|
||||
headers={"Accept": "text/event-stream, application/json, */*",
|
||||
"User-Agent": "BetterClaudeConfig/1.0"},
|
||||
url,
|
||||
method="GET",
|
||||
headers={
|
||||
"Accept": "text/event-stream, application/json, */*",
|
||||
"User-Agent": "BetterClaudeConfig/1.0",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
@@ -596,9 +930,9 @@ def test_remote(url: str, timeout: float = 5.0) -> tuple[bool, str]:
|
||||
if isinstance(reason, socket.timeout):
|
||||
return False, "timed out"
|
||||
return False, str(reason)
|
||||
except socket.timeout:
|
||||
except TimeoutError:
|
||||
return False, "timed out"
|
||||
except Exception as e: # noqa: BLE001 - surface anything else as a clean message
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user