nff 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
nff/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """nff — Claude Code IoT Bridge."""
2
+
3
+ __version__ = "0.1.0"
nff/cli.py ADDED
@@ -0,0 +1,79 @@
1
+ """nff — entry point that wires all subcommands into one CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ import click
8
+ from rich.console import Console
9
+
10
+ from nff import __version__
11
+
12
+ if sys.platform == "win32" and hasattr(sys.stdout, "reconfigure"):
13
+ sys.stdout.reconfigure(encoding="utf-8")
14
+
15
+ console = Console(legacy_windows=False)
16
+
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Root group
20
+ # ---------------------------------------------------------------------------
21
+
22
+ @click.group()
23
+ @click.version_option(__version__, "-V", "--version", prog_name="nff")
24
+ def cli() -> None:
25
+ """nff — Claude Code IoT Bridge.
26
+
27
+ Connects Claude Code to physical hardware devices via USB.
28
+ Run `nff init` to get started.
29
+ """
30
+
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Subcommands
34
+ # ---------------------------------------------------------------------------
35
+
36
+ from nff.commands.init import init # noqa: E402
37
+ from nff.commands.flash import flash # noqa: E402
38
+ from nff.commands.monitor import monitor # noqa: E402
39
+ from nff.commands.doctor import doctor # noqa: E402
40
+
41
+ cli.add_command(init)
42
+ cli.add_command(flash)
43
+ cli.add_command(monitor)
44
+ cli.add_command(doctor)
45
+
46
+
47
+ @cli.command("install-deps")
48
+ @click.option("--force", is_flag=True, help="Reinstall even if already present.")
49
+ def install_deps(force: bool) -> None:
50
+ """Download and install arduino-cli (runs automatically during `nff init`)."""
51
+ from nff.tools import installer
52
+ console.print("[bold cyan]arduino-cli installer[/bold cyan]")
53
+ try:
54
+ exe = installer.install(force=force)
55
+ if not installer.verify(exe):
56
+ raise SystemExit(1)
57
+ except Exception as exc:
58
+ console.print(f" [bold red]✗[/bold red] {exc}")
59
+ raise SystemExit(1)
60
+
61
+
62
+ @cli.command()
63
+ def mcp() -> None:
64
+ """Start the MCP server (stdio). Called automatically by Claude Code."""
65
+ from nff.mcp_server import main as _mcp_main
66
+ _mcp_main()
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Entry point
71
+ # ---------------------------------------------------------------------------
72
+
73
+ def main() -> None:
74
+ """Setuptools / pipx entry point: ``nff = "nff.cli:main"``."""
75
+ cli()
76
+
77
+
78
+ if __name__ == "__main__":
79
+ main()
File without changes
nff/commands/doctor.py ADDED
@@ -0,0 +1,176 @@
1
+ """nff doctor — dependency and configuration health check."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pathlib
6
+ import sys
7
+
8
+ # Fix sys.path when this file is run directly (`python doctor.py`).
9
+ # Python adds commands/ to sys.path, making `nff` unresolvable.
10
+ if __name__ == "__main__":
11
+ _pkg_parent = str(pathlib.Path(__file__).resolve().parents[2])
12
+ if _pkg_parent not in sys.path:
13
+ sys.path.insert(0, _pkg_parent)
14
+
15
+ import importlib.metadata
16
+ import json
17
+ import platform
18
+ import sys
19
+ from pathlib import Path
20
+ from typing import NamedTuple
21
+
22
+ import click
23
+ from rich.console import Console
24
+
25
+ # Windows PowerShell defaults to cp1252 which can't encode ✓/✗.
26
+ # Reconfigure stdout to UTF-8 before Rich initialises its stream.
27
+ if sys.platform == "win32" and hasattr(sys.stdout, "reconfigure"):
28
+ sys.stdout.reconfigure(encoding="utf-8")
29
+
30
+ from nff import config as cfg_module
31
+ from nff.tools import boards as boards_module
32
+ from nff.tools import toolchain
33
+
34
+ console = Console(legacy_windows=False)
35
+
36
+ _CLAUDE_DESKTOP_CONFIG = Path.home() / ".claude" / "claude_desktop_config.json"
37
+
38
+
39
+ class Check(NamedTuple):
40
+ passed: bool
41
+ detail: str # printed next to ✓ / ✗
42
+ fix: str | None = None # hint shown when failed
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Individual checks
47
+ # ---------------------------------------------------------------------------
48
+
49
+ def check_python() -> Check:
50
+ v = sys.version_info
51
+ label = f"Python {v.major}.{v.minor}.{v.micro}"
52
+ if (v.major, v.minor) >= (3, 10):
53
+ return Check(True, label)
54
+ return Check(False, f"{label} — nff requires Python 3.10+", "Upgrade Python")
55
+
56
+
57
+ def check_arduino_cli() -> Check:
58
+ version = toolchain.arduino_cli_version()
59
+ if version:
60
+ return Check(True, f"{version} ({toolchain.find_arduino_cli()})")
61
+ return Check(
62
+ False,
63
+ "arduino-cli not found",
64
+ "Install from https://arduino.github.io/arduino-cli",
65
+ )
66
+
67
+
68
+ def check_esptool() -> Check:
69
+ version = toolchain.esptool_version()
70
+ if version:
71
+ loc = toolchain.find_esptool() or "python -m esptool"
72
+ return Check(True, f"{version} ({loc})")
73
+ return Check(False, "esptool not found", "Run: pip install esptool")
74
+
75
+
76
+ def check_pyserial() -> Check:
77
+ try:
78
+ version = importlib.metadata.version("pyserial")
79
+ return Check(True, f"pyserial {version}")
80
+ except importlib.metadata.PackageNotFoundError:
81
+ return Check(False, "pyserial not installed", "Run: pip install pyserial")
82
+
83
+
84
+ def check_config() -> Check:
85
+ if not cfg_module.exists():
86
+ return Check(False, "Config not found", "Run: nff init")
87
+ try:
88
+ cfg_module.load()
89
+ return Check(True, f"Config found at {cfg_module.CONFIG_PATH}")
90
+ except cfg_module.ConfigError as exc:
91
+ return Check(
92
+ False,
93
+ f"Config unreadable: {exc}",
94
+ f"Fix or delete {cfg_module.CONFIG_PATH}",
95
+ )
96
+
97
+
98
+ def check_device() -> Check:
99
+ """Check that a recognised board is detected and its port is openable."""
100
+ # Lazy import — pyserial may not be installed; check_pyserial will flag it.
101
+ try:
102
+ import serial as _serial
103
+ except ImportError:
104
+ return Check(False, "Cannot check device — pyserial missing", "Run: pip install pyserial")
105
+
106
+ devices = boards_module.list_devices()
107
+ if not devices:
108
+ return Check(False, "No recognised board detected", "Plug in a board and run nff init")
109
+
110
+ device = devices[0]
111
+ label = f"Device detected: {device.board} on {device.port}"
112
+
113
+ try:
114
+ conn = _serial.Serial(device.port, timeout=0.5)
115
+ conn.close()
116
+ except _serial.SerialException as exc:
117
+ fix = f"Port {device.port} is inaccessible: {exc}"
118
+ if platform.system() == "Linux":
119
+ fix += "\n → Add yourself to the dialout group: sudo usermod -aG dialout $USER"
120
+ return Check(False, f"{label} — port inaccessible", fix)
121
+
122
+ return Check(True, label)
123
+
124
+
125
+ def check_claude_desktop() -> Check:
126
+ if not _CLAUDE_DESKTOP_CONFIG.exists():
127
+ return Check(False, "Claude Desktop config not found", "Run: nff init")
128
+ try:
129
+ data = json.loads(_CLAUDE_DESKTOP_CONFIG.read_text(encoding="utf-8"))
130
+ except (json.JSONDecodeError, OSError) as exc:
131
+ return Check(False, f"Claude Desktop config unreadable: {exc}")
132
+ if "nff" not in data.get("mcpServers", {}):
133
+ return Check(
134
+ False,
135
+ "nff not registered in Claude Desktop config",
136
+ "Run: nff init",
137
+ )
138
+ return Check(True, f"Claude Desktop config OK ({_CLAUDE_DESKTOP_CONFIG})")
139
+
140
+
141
+ # ---------------------------------------------------------------------------
142
+ # Click command
143
+ # ---------------------------------------------------------------------------
144
+
145
+ _CHECKS = [
146
+ check_python,
147
+ check_arduino_cli,
148
+ check_esptool,
149
+ check_pyserial,
150
+ check_config,
151
+ check_device,
152
+ check_claude_desktop,
153
+ ]
154
+
155
+
156
+ @click.command()
157
+ def doctor() -> None:
158
+ """Check dependencies, config, and device connectivity."""
159
+ any_failed = False
160
+
161
+ for fn in _CHECKS:
162
+ result = fn()
163
+ if result.passed:
164
+ console.print(f" [bold green]✓[/bold green] {result.detail}")
165
+ else:
166
+ console.print(f" [bold red]✗[/bold red] {result.detail}")
167
+ if result.fix:
168
+ console.print(f" [yellow]→[/yellow] {result.fix}")
169
+ any_failed = True
170
+
171
+ if any_failed:
172
+ sys.exit(1)
173
+
174
+
175
+ if __name__ == "__main__":
176
+ doctor()
nff/commands/flash.py ADDED
@@ -0,0 +1,190 @@
1
+ """nff flash — compile and upload a sketch to the connected board."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pathlib
6
+ import sys
7
+
8
+ if __name__ == "__main__":
9
+ _pkg_parent = str(pathlib.Path(__file__).resolve().parents[2])
10
+ if _pkg_parent not in sys.path:
11
+ sys.path.insert(0, _pkg_parent)
12
+
13
+ from pathlib import Path
14
+
15
+ import click
16
+ from rich.console import Console
17
+
18
+ from nff import config as cfg_module
19
+ from nff.tools import toolchain
20
+
21
+ if sys.platform == "win32" and hasattr(sys.stdout, "reconfigure"):
22
+ sys.stdout.reconfigure(encoding="utf-8")
23
+
24
+ console = Console(legacy_windows=False)
25
+
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Helpers
29
+ # ---------------------------------------------------------------------------
30
+
31
+ def _resolve_target(
32
+ board: str | None,
33
+ port: str | None,
34
+ ) -> tuple[str, str]:
35
+ """Return (fqbn, port), filling missing values from config.
36
+
37
+ Exits with code 1 if either value cannot be resolved.
38
+ """
39
+ try:
40
+ device = cfg_module.get_default_device()
41
+ except cfg_module.ConfigError:
42
+ device = {}
43
+
44
+ fqbn = board or device.get("fqbn") or ""
45
+ resolved_port = port or device.get("port") or ""
46
+
47
+ missing = []
48
+ if not fqbn:
49
+ missing.append("board FQBN (use --board or run nff init)")
50
+ if not resolved_port:
51
+ missing.append("port (use --port or run nff init)")
52
+
53
+ if missing:
54
+ for m in missing:
55
+ console.print(f" [bold red]✗[/bold red] Missing {m}")
56
+ sys.exit(1)
57
+
58
+ return fqbn, resolved_port
59
+
60
+
61
+ def _resolve_sketch(path: Path) -> Path:
62
+ """Return the sketch directory that arduino-cli should compile.
63
+
64
+ Handles three cases:
65
+ - A directory that is already a valid sketch folder.
66
+ - A .ino file already inside a correctly-named parent directory.
67
+ - A loose .ino file — copied to the default temp sketch directory.
68
+
69
+ Note: multi-file sketches passed as a loose .ino will only include
70
+ that single file. Pass the sketch directory for multi-file projects.
71
+
72
+ Exits with code 1 on invalid input.
73
+ """
74
+ if path.is_dir():
75
+ ino_files = list(path.glob("*.ino"))
76
+ if not ino_files:
77
+ console.print(
78
+ f" [bold red]✗[/bold red] No .ino file found in [bold]{path}[/bold]"
79
+ )
80
+ sys.exit(1)
81
+ return path
82
+
83
+ if path.suffix.lower() != ".ino":
84
+ console.print(
85
+ f" [bold red]✗[/bold red] Expected a .ino file or sketch directory, "
86
+ f"got: [bold]{path.name}[/bold]"
87
+ )
88
+ sys.exit(1)
89
+
90
+ # If the file already lives in a correctly-named sketch directory, use it in place.
91
+ if path.parent.name == path.stem:
92
+ return path.parent
93
+
94
+ # Loose .ino — write to the temp sketch directory.
95
+ console.print(
96
+ f" [dim]Copying {path.name} → temp sketch dir "
97
+ f"(multi-file sketches need a directory)[/dim]"
98
+ )
99
+ code = path.read_text(encoding="utf-8")
100
+ return toolchain.write_sketch(code)
101
+
102
+
103
+ def _stream_phase(label: str, stream: toolchain.ProcessStream) -> None:
104
+ """Print *label*, stream lines, then print pass/fail. Exits 1 on failure."""
105
+ console.print(f"\n {label}")
106
+ for line in stream:
107
+ if line.strip():
108
+ console.print(f" [dim]{line}[/dim]")
109
+
110
+ if stream.returncode != 0:
111
+ phase = label.split()[0].rstrip("…")
112
+ console.print(
113
+ f"\n [bold red]✗[/bold red] {phase} failed "
114
+ f"(exit {stream.returncode})"
115
+ )
116
+ sys.exit(1)
117
+
118
+ phase = label.split()[0].rstrip("…")
119
+ console.print(f" [bold green]✓[/bold green] {phase} complete")
120
+
121
+
122
+ # ---------------------------------------------------------------------------
123
+ # Click command
124
+ # ---------------------------------------------------------------------------
125
+
126
+ @click.command()
127
+ @click.argument("file", type=click.Path(exists=True, path_type=Path))
128
+ @click.option("--board", default=None, metavar="FQBN",
129
+ help="Board FQBN, e.g. arduino:avr:uno. Falls back to config.")
130
+ @click.option("--port", default=None, metavar="PORT",
131
+ help="Serial port, e.g. COM3. Falls back to config.")
132
+ @click.option("--baud", default=None, type=int,
133
+ help="Baud rate (stored in config, not used by arduino-cli).")
134
+ @click.option("--manual-reset", is_flag=True,
135
+ help="Pause before upload — use when auto-reset is broken (common on ESP32 clones).")
136
+ def flash(
137
+ file: Path,
138
+ board: str | None,
139
+ port: str | None,
140
+ baud: int | None,
141
+ manual_reset: bool,
142
+ ) -> None:
143
+ """Compile and upload FILE to the connected board.
144
+
145
+ FILE may be a .ino sketch file or a sketch directory.
146
+ Board and port default to the values stored by `nff init`.
147
+
148
+ If the upload fails with "Wrong boot mode detected", your board's
149
+ auto-reset is broken. Re-run with --manual-reset and hold the BOOT
150
+ button when prompted.
151
+ """
152
+ fqbn, resolved_port = _resolve_target(board, port)
153
+ sketch_dir = _resolve_sketch(file)
154
+
155
+ console.print(
156
+ f" [bold]{sketch_dir.name}[/bold] → "
157
+ f"[bold]{fqbn}[/bold] on [bold]{resolved_port}[/bold]"
158
+ )
159
+
160
+ # --- compile ---
161
+ try:
162
+ compile_stream = toolchain.stream_compile(sketch_dir, fqbn)
163
+ except toolchain.ToolchainError as exc:
164
+ console.print(f" [bold red]✗[/bold red] {exc}")
165
+ sys.exit(2)
166
+
167
+ _stream_phase("Compiling…", compile_stream)
168
+
169
+ # --- manual-reset gate ---
170
+ if manual_reset:
171
+ console.print(
172
+ "\n [yellow]Hold the BOOT button on your board, "
173
+ "then press Enter to start uploading…[/yellow]"
174
+ )
175
+ click.pause(info="")
176
+ console.print()
177
+
178
+ # --- upload ---
179
+ try:
180
+ upload_stream = toolchain.stream_upload(sketch_dir, fqbn, resolved_port)
181
+ except toolchain.ToolchainError as exc:
182
+ console.print(f" [bold red]✗[/bold red] {exc}")
183
+ sys.exit(2)
184
+
185
+ _stream_phase("Uploading…", upload_stream)
186
+
187
+
188
+ if __name__ == "__main__":
189
+ flash()
190
+
nff/commands/init.py ADDED
@@ -0,0 +1,207 @@
1
+ """nff init — detect board, write config, register the MCP server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pathlib
6
+ import sys
7
+
8
+ if __name__ == "__main__":
9
+ _pkg_parent = str(pathlib.Path(__file__).resolve().parents[2])
10
+ if _pkg_parent not in sys.path:
11
+ sys.path.insert(0, _pkg_parent)
12
+
13
+ import json
14
+ from pathlib import Path
15
+
16
+ import click
17
+ from rich.console import Console
18
+
19
+ from nff import config as cfg_module
20
+ from nff.tools import boards as boards_module
21
+ from nff.tools import toolchain
22
+ from nff.tools import installer
23
+ from nff.tools.boards import DetectedDevice
24
+
25
+ if sys.platform == "win32" and hasattr(sys.stdout, "reconfigure"):
26
+ sys.stdout.reconfigure(encoding="utf-8")
27
+
28
+ console = Console(legacy_windows=False)
29
+
30
+ _CLAUDE_DESKTOP_CONFIG = Path.home() / ".claude" / "claude_desktop_config.json"
31
+ _MCP_ENTRY: dict = {"command": "nff", "args": ["mcp"]}
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Helpers
36
+ # ---------------------------------------------------------------------------
37
+
38
+ def _ensure_arduino_cli() -> None:
39
+ """Install arduino-cli silently if it is not already on PATH."""
40
+ if toolchain.find_arduino_cli():
41
+ return
42
+ console.print(
43
+ " [yellow]⚠[/yellow] arduino-cli not found — installing automatically…"
44
+ )
45
+ try:
46
+ exe = installer.install(force=False)
47
+ if installer.verify(exe):
48
+ console.print(" [bold green]✓[/bold green] arduino-cli installed.")
49
+ else:
50
+ console.print(
51
+ " [yellow]⚠[/yellow] arduino-cli installed but could not be verified. "
52
+ "Restart your terminal if commands fail."
53
+ )
54
+ except Exception as exc:
55
+ console.print(
56
+ f" [yellow]⚠[/yellow] Could not auto-install arduino-cli: {exc}\n"
57
+ " Install manually: https://arduino.github.io/arduino-cli"
58
+ )
59
+
60
+
61
+ def _pick_device(devices: list[DetectedDevice]) -> DetectedDevice:
62
+ """Return the chosen device; prompts when more than one is connected."""
63
+ if len(devices) == 1:
64
+ return devices[0]
65
+
66
+ console.print("\n[bold]Multiple boards detected:[/bold]")
67
+ for i, d in enumerate(devices, 1):
68
+ console.print(f" {i}. [bold]{d.board}[/bold] on {d.port}")
69
+
70
+ choice = click.prompt(
71
+ "Select board",
72
+ type=click.IntRange(1, len(devices)),
73
+ default=1,
74
+ )
75
+ return devices[choice - 1]
76
+
77
+
78
+ def _update_claude_desktop_config() -> None:
79
+ """Merge the nff MCP entry into ~/.claude/claude_desktop_config.json.
80
+
81
+ Preserves all pre-existing keys and other mcpServers entries.
82
+
83
+ Raises:
84
+ OSError: If the file cannot be written.
85
+ ValueError: If an existing file contains invalid JSON (caller should
86
+ surface this as a warning rather than aborting).
87
+ """
88
+ _CLAUDE_DESKTOP_CONFIG.parent.mkdir(parents=True, exist_ok=True)
89
+
90
+ data: dict = {}
91
+ if _CLAUDE_DESKTOP_CONFIG.exists():
92
+ raw = _CLAUDE_DESKTOP_CONFIG.read_text(encoding="utf-8")
93
+ if raw.strip():
94
+ data = json.loads(raw) # raises ValueError on bad JSON
95
+
96
+ data.setdefault("mcpServers", {})["nff"] = _MCP_ENTRY
97
+
98
+ _CLAUDE_DESKTOP_CONFIG.write_text(json.dumps(data, indent=2), encoding="utf-8")
99
+
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # Click command
103
+ # ---------------------------------------------------------------------------
104
+
105
+ @click.command()
106
+ @click.option("--port", default=None, metavar="PORT",
107
+ help="Serial port to use; skips auto-detection.")
108
+ @click.option("--baud", default=9600, show_default=True,
109
+ help="Baud rate stored in config.")
110
+ @click.option("--force", is_flag=True,
111
+ help="Overwrite an existing config without prompting.")
112
+ def init(port: str | None, baud: int, force: bool) -> None:
113
+ """Detect a connected board, write config, and register the MCP server."""
114
+ _ensure_arduino_cli()
115
+
116
+ # Guard against overwriting an existing, valid config
117
+ if cfg_module.exists() and not force:
118
+ try:
119
+ existing = cfg_module.get_default_device()
120
+ if existing.get("port"):
121
+ console.print(
122
+ f"[yellow]Config already exists[/yellow] "
123
+ f"({existing.get('board', '?')} on {existing['port']}).\n"
124
+ " Pass [bold]--force[/bold] to overwrite."
125
+ )
126
+ return
127
+ except cfg_module.ConfigError:
128
+ pass # unreadable config → let the user fix it by re-running init
129
+
130
+ # -----------------------------------------------------------------
131
+ # Device resolution
132
+ # -----------------------------------------------------------------
133
+ device: DetectedDevice | None = None
134
+
135
+ if port:
136
+ # User supplied a port — accept it even if the board isn't recognised.
137
+ console.print(f" Using specified port [bold]{port}[/bold]…")
138
+ device = boards_module.find_device(port)
139
+ if device is None:
140
+ console.print(
141
+ f" [yellow]⚠[/yellow] {port} not matched to a known board. "
142
+ "Storing as 'Unknown'."
143
+ )
144
+ cfg_module.set_default_device(port=port, board="Unknown", fqbn="", baud=baud)
145
+ _write_success(port=port, board="Unknown", device=None)
146
+ return
147
+ else:
148
+ console.print(" Scanning USB ports…")
149
+ devices = boards_module.list_devices()
150
+
151
+ if not devices:
152
+ console.print(
153
+ "[bold red]✗[/bold red] No recognised boards found.\n"
154
+ " Plug in a board and try again, or use "
155
+ "[bold]--port PORT[/bold] to specify one manually."
156
+ )
157
+ sys.exit(1)
158
+
159
+ device = _pick_device(devices)
160
+
161
+ # -----------------------------------------------------------------
162
+ # Write nff config
163
+ # -----------------------------------------------------------------
164
+ cfg_module.set_default_device(
165
+ port=device.port,
166
+ board=device.board,
167
+ fqbn=device.fqbn,
168
+ baud=baud,
169
+ )
170
+
171
+ _write_success(port=device.port, board=device.board, device=device)
172
+
173
+
174
+ def _write_success(port: str, board: str, device: DetectedDevice | None) -> None:
175
+ """Print the success lines and update the Claude Desktop config."""
176
+ if device:
177
+ console.print(
178
+ f" [bold green]✓[/bold green] Found: [bold]{device.board}[/bold] "
179
+ f"on {device.port} "
180
+ f"(vendor: {device.vendor_id}, product: {device.product_id})"
181
+ )
182
+
183
+ console.print(
184
+ f" [bold green]✓[/bold green] Config written to "
185
+ f"[bold]{cfg_module.CONFIG_PATH}[/bold]"
186
+ )
187
+
188
+ try:
189
+ _update_claude_desktop_config()
190
+ console.print(
191
+ f" [bold green]✓[/bold green] MCP config written to "
192
+ f"[bold]{_CLAUDE_DESKTOP_CONFIG}[/bold]"
193
+ )
194
+ except ValueError as exc:
195
+ console.print(
196
+ f" [yellow]⚠[/yellow] Claude Desktop config has invalid JSON — "
197
+ f"fix it manually: {exc}\n"
198
+ f" Path: {_CLAUDE_DESKTOP_CONFIG}"
199
+ )
200
+ except OSError as exc:
201
+ console.print(
202
+ f" [yellow]⚠[/yellow] Could not write Claude Desktop config: {exc}"
203
+ )
204
+
205
+
206
+ if __name__ == "__main__":
207
+ init()