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 +3 -0
- nff/cli.py +79 -0
- nff/commands/__init__.py +0 -0
- nff/commands/doctor.py +176 -0
- nff/commands/flash.py +190 -0
- nff/commands/init.py +207 -0
- nff/commands/monitor.py +104 -0
- nff/config.py +90 -0
- nff/mcp_server.py +215 -0
- nff/tools/__init__.py +0 -0
- nff/tools/boards.py +90 -0
- nff/tools/installer.py +216 -0
- nff/tools/serial.py +244 -0
- nff/tools/toolchain.py +396 -0
- nff-0.1.0.dist-info/METADATA +220 -0
- nff-0.1.0.dist-info/RECORD +20 -0
- nff-0.1.0.dist-info/WHEEL +5 -0
- nff-0.1.0.dist-info/entry_points.txt +2 -0
- nff-0.1.0.dist-info/licenses/LICENSE +21 -0
- nff-0.1.0.dist-info/top_level.txt +1 -0
nff/__init__.py
ADDED
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()
|
nff/commands/__init__.py
ADDED
|
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()
|