firmwareloop 0.0.8__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.
tools/fw_mcp_server.py ADDED
@@ -0,0 +1,1272 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ fw_mcp_server.py - FirmwareLoop High-Level Workflow & Device MCP Server (v0.0.8).
4
+
5
+ Exposes high-level firmware engineering and hardware management tools to AI Coding
6
+ Agents (Antigravity CLI, Claude Code CLI, Qoder IDE, Cursor) via the Model Context
7
+ Protocol (JSON-RPC 2.0 stdio).
8
+
9
+ Tools provided:
10
+ - fw_doctor: Environment & toolchain diagnostic (doctor.ps1)
11
+ - fw_build: Multi-backend firmware compilation & diagnostics (build.ps1)
12
+ - fw_flash: Flash firmware image to target MCU (flash.ps1 / agentic-hil)
13
+ - fw_reset: Reset physical or simulated target MCU (reset.ps1)
14
+ - fw_run_hil_test: Hardware-in-the-loop pytest suite (test.ps1)
15
+ - fw_acceptance_scenario: End-to-end acceptance scenario (acceptance-scenario.ps1)
16
+ - fw_measure: Controlled PyVISA/SCPI instrument measurement (instrument_cli.py)
17
+ - fw_logic_capture: Logic analyzer protocol capture (logic_capture.ps1)
18
+ - fw_logic_decode: Logic analyzer protocol decoding & assertion (logic_decode.ps1)
19
+ - fw_get_evidence: Read structured audit run evidence (artifacts/runs/)
20
+ - fw_configure_lab: Interactively configure project, build backend, chip & ports (lab.yaml)
21
+ - fw_scan_hardware: Auto-detect connected ST-LINK / J-Link probes, MCU targets & COM ports
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import os
28
+ import shutil
29
+ import subprocess
30
+ import sys
31
+ import traceback
32
+ from typing import Any, Dict, List, Optional
33
+
34
+ try:
35
+ import yaml
36
+ except ImportError:
37
+ yaml = None
38
+
39
+ REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
40
+ PYTHON_EXE = sys.executable
41
+ AHIL_EXE = os.path.join(REPO_ROOT, ".venv", "Scripts", "agentic-hil.exe")
42
+ PYOCD_EXE = os.path.join(REPO_ROOT, ".venv", "Scripts", "pyocd.exe")
43
+
44
+
45
+ def find_powershell() -> str:
46
+ for candidate in ["pwsh", "powershell"]:
47
+ path = shutil.which(candidate)
48
+ if path:
49
+ return path
50
+ return "powershell.exe"
51
+
52
+
53
+ PWSH_EXE = find_powershell()
54
+
55
+
56
+ def run_process(cmd: List[str], timeout: int = 300) -> Dict[str, Any]:
57
+ try:
58
+ proc = subprocess.run(
59
+ cmd,
60
+ cwd=REPO_ROOT,
61
+ stdout=subprocess.PIPE,
62
+ stderr=subprocess.PIPE,
63
+ text=True,
64
+ encoding="utf-8",
65
+ errors="replace",
66
+ timeout=timeout,
67
+ )
68
+ stdout_raw = proc.stdout.strip()
69
+ stderr_raw = proc.stderr.strip()
70
+
71
+ # Try to parse JSON from stdout
72
+ parsed = None
73
+ for line in reversed(stdout_raw.splitlines()):
74
+ line_str = line.strip()
75
+ if line_str.startswith("{") and line_str.endswith("}"):
76
+ try:
77
+ parsed = json.loads(line_str)
78
+ break
79
+ except Exception:
80
+ continue
81
+
82
+ if parsed is None and stdout_raw.startswith("{") and stdout_raw.endswith("}"):
83
+ try:
84
+ parsed = json.loads(stdout_raw)
85
+ except Exception:
86
+ pass
87
+
88
+ return {
89
+ "exit_code": proc.returncode,
90
+ "success": proc.returncode == 0,
91
+ "stdout": stdout_raw,
92
+ "stderr": stderr_raw,
93
+ "data": parsed,
94
+ }
95
+ except subprocess.TimeoutExpired:
96
+ return {
97
+ "exit_code": -1,
98
+ "success": False,
99
+ "error_class": "TIMEOUT",
100
+ "error": f"Command timed out after {timeout} seconds",
101
+ "stdout": "",
102
+ "stderr": "",
103
+ }
104
+ except Exception as e:
105
+ return {
106
+ "exit_code": -1,
107
+ "success": False,
108
+ "error_class": "EXECUTION_ERROR",
109
+ "error": str(e),
110
+ "traceback": traceback.format_exc(),
111
+ }
112
+
113
+
114
+ # ===========================================================================
115
+ # Tool Implementations
116
+ # ===========================================================================
117
+
118
+ def handle_fw_doctor(args: Dict[str, Any]) -> Dict[str, Any]:
119
+ script = os.path.join(REPO_ROOT, "tools", "doctor.ps1")
120
+ cmd = [PWSH_EXE, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", script, "-Json"]
121
+ res = run_process(cmd, timeout=60)
122
+ if res.get("data"):
123
+ return res["data"]
124
+ return res
125
+
126
+
127
+ def handle_fw_build(args: Dict[str, Any]) -> Dict[str, Any]:
128
+ script = os.path.join(REPO_ROOT, "tools", "build.ps1")
129
+ cmd = [PWSH_EXE, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", script, "-Json"]
130
+
131
+ backend = args.get("backend")
132
+ if backend and backend != "auto":
133
+ cmd.extend(["-Backend", backend])
134
+
135
+ config = args.get("configuration", "Debug")
136
+ if config:
137
+ cmd.extend(["-Configuration", config])
138
+
139
+ if args.get("clean", True):
140
+ cmd.append("-Clean")
141
+
142
+ source_dir = args.get("source_dir")
143
+ if source_dir:
144
+ cmd.extend(["-SourceDir", source_dir])
145
+
146
+ if args.get("dry_run", False):
147
+ cmd.append("-DryRun")
148
+
149
+ timeout_ms = args.get("timeout_ms", 300000)
150
+ res = run_process(cmd, timeout=int(timeout_ms / 1000) + 10)
151
+ if res.get("data"):
152
+ return res["data"]
153
+ return res
154
+
155
+
156
+ def handle_fw_flash(args: Dict[str, Any]) -> Dict[str, Any]:
157
+ script = os.path.join(REPO_ROOT, "tools", "flash.ps1")
158
+ backend = args.get("backend", "simulator")
159
+ artifact = args.get("artifact_path", "artifacts/build/firmware.elf")
160
+
161
+ cmd = [
162
+ PWSH_EXE, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass",
163
+ "-File", script, "-Backend", backend, "-Artifact", artifact, "-Json"
164
+ ]
165
+ res = run_process(cmd, timeout=120)
166
+ if res.get("data"):
167
+ return res["data"]
168
+ return res
169
+
170
+
171
+ def handle_fw_reset(args: Dict[str, Any]) -> Dict[str, Any]:
172
+ script = os.path.join(REPO_ROOT, "tools", "reset.ps1")
173
+ backend = args.get("backend", "simulator")
174
+ expected = args.get("expected_target")
175
+
176
+ cmd = [
177
+ PWSH_EXE, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass",
178
+ "-File", script, "-Backend", backend, "-Json"
179
+ ]
180
+ if expected:
181
+ cmd.extend(["-ExpectedTarget", expected])
182
+
183
+ res = run_process(cmd, timeout=30)
184
+ if res.get("data"):
185
+ return res["data"]
186
+ return res
187
+
188
+
189
+ def handle_fw_run_hil_test(args: Dict[str, Any]) -> Dict[str, Any]:
190
+ script = os.path.join(REPO_ROOT, "tools", "test.ps1")
191
+ cmd = [PWSH_EXE, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", script, "-Json"]
192
+
193
+ gate = args.get("gate", "simulator")
194
+ if gate:
195
+ cmd.extend(["-Gate", gate])
196
+
197
+ test_filter = args.get("test_filter")
198
+ if test_filter:
199
+ cmd.extend(["-Filter", test_filter])
200
+
201
+ res = run_process(cmd, timeout=120)
202
+ if res.get("data"):
203
+ return res["data"]
204
+ return res
205
+
206
+
207
+ def handle_fw_acceptance_scenario(args: Dict[str, Any]) -> Dict[str, Any]:
208
+ script = os.path.join(REPO_ROOT, "tools", "acceptance-scenario.ps1")
209
+ cmd = [PWSH_EXE, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", script, "-Json"]
210
+
211
+ mode = args.get("mode", "simulator")
212
+ if mode:
213
+ cmd.extend(["-Mode", mode])
214
+
215
+ hardware = args.get("hardware")
216
+ if hardware:
217
+ cmd.extend(["-Hardware", hardware])
218
+
219
+ res = run_process(cmd, timeout=180)
220
+ if res.get("data"):
221
+ return res["data"]
222
+ return res
223
+
224
+
225
+ def handle_fw_measure(args: Dict[str, Any]) -> Dict[str, Any]:
226
+ cli_script = os.path.join(REPO_ROOT, "tools", "instrument_cli.py")
227
+ instr_type = args.get("instrument_type", "scope")
228
+ subcommand = args.get("command", "measure-frequency")
229
+ instr_name = args.get("instrument_name", "scope1" if instr_type == "scope" else "psu1")
230
+ channel = args.get("channel", "CH1")
231
+ backend = args.get("backend", "simulator")
232
+
233
+ cmd = [PYTHON_EXE, cli_script, instr_type, subcommand, "--instrument", instr_name, "--backend", backend]
234
+ if instr_type == "scope" and channel:
235
+ cmd.extend(["--channel", channel])
236
+
237
+ res = run_process(cmd, timeout=30)
238
+ if res.get("data"):
239
+ return res["data"]
240
+ return res
241
+
242
+
243
+ def handle_fw_logic_capture(args: Dict[str, Any]) -> Dict[str, Any]:
244
+ script = os.path.join(REPO_ROOT, "tools", "logic_capture.ps1")
245
+ cmd = [PWSH_EXE, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", script, "-Json"]
246
+
247
+ protocol = args.get("protocol", "spi")
248
+ cmd.extend(["-Protocol", protocol])
249
+
250
+ sample_rate = args.get("sample_rate")
251
+ if sample_rate:
252
+ cmd.extend(["-SampleRate", str(sample_rate)])
253
+
254
+ duration_ms = args.get("duration_ms")
255
+ if duration_ms:
256
+ cmd.extend(["-DurationMs", str(duration_ms)])
257
+
258
+ output_file = args.get("output_file")
259
+ if output_file:
260
+ cmd.extend(["-OutputFile", output_file])
261
+
262
+ res = run_process(cmd, timeout=30)
263
+ if res.get("data"):
264
+ return res["data"]
265
+ return res
266
+
267
+
268
+ def handle_fw_logic_decode(args: Dict[str, Any]) -> Dict[str, Any]:
269
+ script = os.path.join(REPO_ROOT, "tools", "logic_decode.ps1")
270
+ cmd = [PWSH_EXE, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", script, "-Json"]
271
+
272
+ capture_file = args.get("capture_file")
273
+ if capture_file:
274
+ cmd.extend(["-CaptureFile", capture_file])
275
+
276
+ protocol = args.get("protocol", "spi")
277
+ cmd.extend(["-Protocol", protocol])
278
+
279
+ frequency = args.get("frequency_hz")
280
+ if frequency:
281
+ cmd.extend(["-FrequencyHz", str(frequency)])
282
+
283
+ res = run_process(cmd, timeout=30)
284
+ if res.get("data"):
285
+ return res["data"]
286
+ return res
287
+
288
+
289
+ def handle_fw_get_evidence(args: Dict[str, Any]) -> Dict[str, Any]:
290
+ run_id = args.get("run_id", "latest")
291
+ runs_dir = os.path.join(REPO_ROOT, "artifacts", "runs")
292
+
293
+ if not os.path.exists(runs_dir):
294
+ return {"ok": False, "error_class": "ARTIFACT_NOT_FOUND", "message": "artifacts/runs directory does not exist"}
295
+
296
+ target_dir = None
297
+ if run_id == "latest":
298
+ entries = [os.path.join(runs_dir, d) for d in os.listdir(runs_dir) if os.path.isdir(os.path.join(runs_dir, d))]
299
+ if not entries:
300
+ return {"ok": False, "error_class": "ARTIFACT_NOT_FOUND", "message": "No runs found in artifacts/runs"}
301
+ target_dir = max(entries, key=os.path.getmtime)
302
+ else:
303
+ candidate = os.path.join(runs_dir, run_id)
304
+ if os.path.exists(candidate):
305
+ target_dir = candidate
306
+ else:
307
+ return {"ok": False, "error_class": "ARTIFACT_NOT_FOUND", "message": f"Run '{run_id}' not found"}
308
+
309
+ summary_file = os.path.join(target_dir, "summary.json")
310
+ summary_data = None
311
+ if os.path.exists(summary_file):
312
+ try:
313
+ with open(summary_file, "r", encoding="utf-8") as f:
314
+ summary_data = json.load(f)
315
+ except Exception:
316
+ pass
317
+
318
+ report_file = os.path.join(target_dir, "final-report.json")
319
+ report_data = None
320
+ if os.path.exists(report_file):
321
+ try:
322
+ with open(report_file, "r", encoding="utf-8") as f:
323
+ report_data = json.load(f)
324
+ except Exception:
325
+ pass
326
+
327
+ files = os.listdir(target_dir)
328
+ return {
329
+ "ok": True,
330
+ "run_id": os.path.basename(target_dir),
331
+ "directory": target_dir,
332
+ "files": files,
333
+ "summary": summary_data,
334
+ "final_report": report_data,
335
+ }
336
+
337
+
338
+ def handle_fw_init_project(args: Dict[str, Any]) -> Dict[str, Any]:
339
+ """Scaffold multi-agent instruction files (AGENTS.md, CLAUDE.md, GEMINI.md), lab.yaml, and .mcp.json."""
340
+ target_dir = args.get("target_dir", ".")
341
+ target_dir = os.path.abspath(target_dir)
342
+ os.makedirs(target_dir, exist_ok=True)
343
+ target_chip = args.get("target_chip", "STM32F103C8")
344
+ build_backend = args.get("build_backend", "keil")
345
+ overwrite = args.get("overwrite", False)
346
+
347
+ created_files = []
348
+
349
+ # 1. AGENTS.md
350
+ agents_path = os.path.join(target_dir, "AGENTS.md")
351
+ agents_content = """# AGENTS.md — Firmware Engineering & Lab Automation Guidelines
352
+
353
+ This repository is configured with **FirmwareLoop (`fwloop`)** for AI Agent-driven firmware development, building, flashing, and hardware-in-the-loop (HIL) testing.
354
+
355
+ ## Dual-Tier MCP Tools Available
356
+ - **Upper-Tier (`fwloop`)**:
357
+ - `fw_doctor()`: Check compiler, Python, COM ports, and probe connectivity.
358
+ - `fw_configure_lab(project_name, build_backend, target_chip, uart_port)`: Interactively configure project settings in `lab/lab.yaml`.
359
+ - `fw_scan_hardware(adopt=false)`: Scan connected ST-LINK / J-Link / CMSIS-DAP probes and COM ports.
360
+ - `fw_build(backend="auto", configuration="Debug", clean=true)`: Build firmware across Keil5, CMake, Make, IAR, PlatformIO, etc.
361
+ - `fw_flash(backend="auto", artifact_path)`: Flash compiled firmware image into target MCU via ST-LINK or J-Link.
362
+ - `fw_reset(backend="auto")`: Hardware/software reset target MCU.
363
+ - `fw_run_hil_test()`: Run pytest automated hardware-in-the-loop test suite.
364
+ - `fw_measure(instrument_type, command)`: Safe PyVISA instrument measurement.
365
+ - `fw_logic_capture(protocol="i2c|spi|uart")` / `fw_logic_decode()`: Logic analyzer protocol capture & decode.
366
+ - `fw_get_evidence()`: Retrieve audit run artifacts and test evidence.
367
+ - `fw_init_project()`: Scaffold multi-agent instruction files and lab configurations.
368
+
369
+ - **Lower-Tier (`agentic-hil`)**:
370
+ - `probe_target()`, `flash_firmware()`, `reset_target()`, `com_session_*()`, `debug_*()`.
371
+
372
+ ## Core Agent Rules & Principles
373
+ 1. **Real-Hardware-First & Zero Fake Results**: Never fake, simulate, or mock results during real firmware development. If toolchains (Keil5/GCC), debug probes (ST-LINK/J-Link), target MCU, or instruments are missing or disconnected, immediately fail closed with explicit error classes (`TOOLCHAIN_NOT_FOUND`, `PROBE_NOT_FOUND`, `TARGET_UNREACHABLE`) and actionable setup guidance for the user. Never claim success on incomplete conditions!
374
+ 2. **Evidence-Driven**: Never judge success by `exit code == 0` alone. Inspect Build Evidence + Runtime Evidence + Measurement Evidence + Assertion.
375
+ 3. **Safety First**: Never bypass `lab/limits.yaml` safety boundaries.
376
+ 4. **Iteration Limit**: Maximum 3 automated fix attempts before requesting human guidance.
377
+ """
378
+ if not os.path.exists(agents_path) or overwrite:
379
+ with open(agents_path, "w", encoding="utf-8") as fh:
380
+ fh.write(agents_content)
381
+ created_files.append("AGENTS.md")
382
+
383
+ # 2. CLAUDE.md
384
+ claude_path = os.path.join(target_dir, "CLAUDE.md")
385
+ claude_content = f"# CLAUDE.md — Claude Code Guidelines\n\n{agents_content}"
386
+ if not os.path.exists(claude_path) or overwrite:
387
+ with open(claude_path, "w", encoding="utf-8") as fh:
388
+ fh.write(claude_content)
389
+ created_files.append("CLAUDE.md")
390
+
391
+ # 3. GEMINI.md
392
+ gemini_path = os.path.join(target_dir, "GEMINI.md")
393
+ gemini_content = "# GEMINI.md — Antigravity Agent Guidelines\n\nSee `AGENTS.md` for full project guidelines and Dual-Tier MCP tools.\n"
394
+ if not os.path.exists(gemini_path) or overwrite:
395
+ with open(gemini_path, "w", encoding="utf-8") as fh:
396
+ fh.write(gemini_content)
397
+ created_files.append("GEMINI.md")
398
+
399
+ # 4. lab/lab.yaml
400
+ lab_dir = os.path.join(target_dir, "lab")
401
+ os.makedirs(lab_dir, exist_ok=True)
402
+ lab_yaml_path = os.path.join(lab_dir, "lab.yaml")
403
+ lab_yaml_content = f"""# FirmwareLoop Project & Hardware Bench Configuration
404
+ schema: "firmwareloop-lab-config/v1"
405
+
406
+ project:
407
+ name: "{os.path.basename(target_dir)}"
408
+ target_chip: "{target_chip}"
409
+ build_backend: "{build_backend}"
410
+ source_dir: "."
411
+
412
+ hardware:
413
+ debugger_probe: "stlink"
414
+ probe_serial: "auto"
415
+ uart:
416
+ port: "COM3"
417
+ baudrate: 115200
418
+ power_supply:
419
+ default_voltage: 3.3
420
+ """
421
+ if not os.path.exists(lab_yaml_path) or overwrite:
422
+ with open(lab_yaml_path, "w", encoding="utf-8") as fh:
423
+ fh.write(lab_yaml_content)
424
+ created_files.append("lab/lab.yaml")
425
+
426
+ # 5. .mcp.json
427
+ mcp_json_path = os.path.join(target_dir, ".mcp.json")
428
+ mcp_json_content = """{
429
+ "mcpServers": {
430
+ "fwloop": {
431
+ "command": "fwloop"
432
+ },
433
+ "agentic-hil": {
434
+ "command": "agentic-hil",
435
+ "args": ["mcp-stdio"]
436
+ }
437
+ }
438
+ }
439
+ """
440
+ if not os.path.exists(mcp_json_path) or overwrite:
441
+ with open(mcp_json_path, "w", encoding="utf-8") as fh:
442
+ fh.write(mcp_json_content)
443
+ created_files.append(".mcp.json")
444
+
445
+ # 6. skills/firmwareloop/SKILL.md
446
+ skill_dir = os.path.join(target_dir, "skills", "firmwareloop")
447
+ os.makedirs(skill_dir, exist_ok=True)
448
+ skill_path = os.path.join(skill_dir, "SKILL.md")
449
+ skill_content = """---
450
+ name: firmwareloop
451
+ description: FirmwareLoop workflow skill for orchestrating firmware builds (Keil/CMake/Make/etc.), probe detection (ST-LINK/J-Link), interactive lab configuration, pytest HIL testing, I2C/SPI logic analyzer captures, PyVISA instrument measurements, and end-to-end hardware acceptance.
452
+ ---
453
+
454
+ # FirmwareLoop Skill
455
+
456
+ Use this skill when developing, building, testing, or diagnosing embedded firmware within the FirmwareLoop repository.
457
+
458
+ ## Capabilities & Tools
459
+
460
+ ### Upper-Tier MCP Tools (`firmwareloop`)
461
+ - `fw_doctor`: Run environmental health check.
462
+ - `fw_configure_lab`: Interactively configure or update project parameters, target chip (e.g. STM32F103C8), build backend (e.g. keil), source directory, COM port, and debugger probe in `lab/lab.yaml`.
463
+ - `fw_scan_hardware`: Scan and identify attached hardware debuggers (ST-LINK, J-Link, CMSIS-DAP) and COM ports.
464
+ - `fw_build`: Compile firmware across 7 backends (Keil, CMake, Make, IAR, PlatformIO, Zephyr, ESP-IDF).
465
+ - `fw_flash`: Flash compiled firmware image into target MCU.
466
+ - `fw_reset`: Hardware or software reset of the target MCU.
467
+ - `fw_run_hil_test`: Run 12-item pytest HIL automated testing.
468
+ - `fw_acceptance_scenario`: Execute end-to-end acceptance scenario.
469
+ - `fw_measure`: Query PyVISA/SCPI instrument readings (frequency, duty cycle, Vpp, voltage, current).
470
+ - `fw_logic_capture` / `fw_logic_decode`: Digital protocol capture and verification (I2C, SPI, UART).
471
+ - `fw_get_evidence`: Retrieve audit run artifacts and reports.
472
+ - `fw_init_project`: Scaffold multi-agent instruction files and lab configurations.
473
+
474
+ ## Mandatory Rules
475
+ 1. **Real-Hardware-First & Zero Fake Data**: Never fake or mock hardware results. When compilers, debuggers, or MCU targets are missing, fail closed immediately and output explicit error diagnostics and user setup guidance.
476
+ 2. **Max 3 Code Iterations**: Never loop infinitely fixing code.
477
+ 3. **Fail Closed on Safety**: Never bypass `limits.yaml`.
478
+ 4. **Verify Evidence**: Ensure all evidence is captured in `artifacts/runs/<run_id>/`.
479
+ """
480
+ if not os.path.exists(skill_path) or overwrite:
481
+ with open(skill_path, "w", encoding="utf-8") as fh:
482
+ fh.write(skill_content)
483
+ created_files.append("skills/firmwareloop/SKILL.md")
484
+
485
+ return {
486
+ "ok": True,
487
+ "target_dir": target_dir,
488
+ "created_files": created_files,
489
+ "message": f"Successfully initialized FirmwareLoop multi-agent files in {target_dir}"
490
+ }
491
+
492
+
493
+ def handle_fw_configure_lab(args: Dict[str, Any]) -> Dict[str, Any]:
494
+ lab_yaml_path = os.path.join(REPO_ROOT, "lab", "lab.yaml")
495
+ example_yaml_path = os.path.join(REPO_ROOT, "lab", "lab.example.yaml")
496
+
497
+ config: Dict[str, Any] = {}
498
+ if os.path.exists(lab_yaml_path):
499
+ try:
500
+ if yaml:
501
+ with open(lab_yaml_path, "r", encoding="utf-8") as f:
502
+ config = yaml.safe_load(f) or {}
503
+ else:
504
+ with open(lab_yaml_path, "r", encoding="utf-8") as f:
505
+ config = json.load(f) or {}
506
+ except Exception:
507
+ config = {}
508
+ elif os.path.exists(example_yaml_path) and yaml:
509
+ try:
510
+ with open(example_yaml_path, "r", encoding="utf-8") as f:
511
+ config = yaml.safe_load(f) or {}
512
+ except Exception:
513
+ config = {}
514
+
515
+ if "project" not in config:
516
+ config["project"] = {}
517
+ if "dut" not in config:
518
+ config["dut"] = {"uart": {}}
519
+ if "hardware" not in config:
520
+ config["hardware"] = {}
521
+
522
+ updated = []
523
+
524
+ if "project_name" in args and args["project_name"] is not None:
525
+ config["project"]["name"] = args["project_name"]
526
+ updated.append("project.name")
527
+
528
+ if "build_backend" in args and args["build_backend"] is not None:
529
+ config["project"]["build_backend"] = args["build_backend"]
530
+ updated.append("project.build_backend")
531
+
532
+ if "source_dir" in args and args["source_dir"] is not None:
533
+ config["project"]["source_dir"] = args["source_dir"]
534
+ updated.append("project.source_dir")
535
+
536
+ if "target_chip" in args and args["target_chip"] is not None:
537
+ config["project"]["target_chip"] = args["target_chip"]
538
+ config["dut"]["expected_target"] = args["target_chip"]
539
+ updated.append("project.target_chip")
540
+
541
+ if "uart_port" in args and args["uart_port"] is not None:
542
+ if "uart" not in config["dut"]:
543
+ config["dut"]["uart"] = {}
544
+ config["dut"]["uart"]["port"] = args["uart_port"]
545
+ updated.append("dut.uart.port")
546
+
547
+ if "uart_baudrate" in args and args["uart_baudrate"] is not None:
548
+ if "uart" not in config["dut"]:
549
+ config["dut"]["uart"] = {}
550
+ config["dut"]["uart"]["baudrate"] = int(args["uart_baudrate"])
551
+ updated.append("dut.uart.baudrate")
552
+
553
+ if "debugger_backend" in args and args["debugger_backend"] is not None:
554
+ config["hardware"]["debugger_backend"] = args["debugger_backend"]
555
+ updated.append("hardware.debugger_backend")
556
+
557
+ if "debugger_probe_id" in args and args["debugger_probe_id"] is not None:
558
+ config["hardware"]["probe_id"] = args["debugger_probe_id"]
559
+ updated.append("hardware.probe_id")
560
+
561
+ os.makedirs(os.path.dirname(lab_yaml_path), exist_ok=True)
562
+ if yaml:
563
+ with open(lab_yaml_path, "w", encoding="utf-8") as f:
564
+ yaml.dump(config, f, allow_unicode=True, default_flow_style=False)
565
+ else:
566
+ with open(lab_yaml_path, "w", encoding="utf-8") as f:
567
+ json.dump(config, f, ensure_ascii=False, indent=2)
568
+
569
+ return {
570
+ "ok": True,
571
+ "message": f"Successfully updated lab/lab.yaml ({len(updated)} fields modified)",
572
+ "updated_fields": updated,
573
+ "config_file": lab_yaml_path,
574
+ "config": config,
575
+ }
576
+
577
+
578
+ def handle_fw_scan_hardware(args: Dict[str, Any]) -> Dict[str, Any]:
579
+ probes = []
580
+ com_ports = []
581
+ messages = []
582
+
583
+ # 1. Probe detection via pyocd
584
+ if os.path.exists(PYOCD_EXE):
585
+ res = run_process([PYOCD_EXE, "list"], timeout=15)
586
+ raw_output = res.get("stdout", "")
587
+ if "No available debug probes" not in raw_output:
588
+ for line in raw_output.splitlines():
589
+ if line.strip() and not line.startswith("#") and not line.startswith("usage"):
590
+ probes.append({"raw": line.strip(), "source": "pyocd"})
591
+
592
+ # 2. Probe & COM detection via agentic-hil
593
+ if os.path.exists(AHIL_EXE):
594
+ # COM Ports
595
+ com_res = run_process([AHIL_EXE, "com-ports"], timeout=10)
596
+ if com_res.get("data") and "ports" in com_res["data"]:
597
+ for p in com_res["data"]["ports"]:
598
+ com_ports.append(p)
599
+ elif com_res.get("stdout"):
600
+ for line in com_res["stdout"].splitlines():
601
+ if line.strip():
602
+ com_ports.append({"raw": line.strip()})
603
+
604
+ # Debugger Probes
605
+ probe_res = run_process([AHIL_EXE, "debugger-probes"], timeout=15)
606
+ if probe_res.get("stdout") and "No" not in probe_res["stdout"]:
607
+ messages.append(probe_res["stdout"])
608
+
609
+ # Auto adopt if requested
610
+ if args.get("adopt", False):
611
+ adopt_res = run_process([AHIL_EXE, "adopt-hardware"], timeout=20)
612
+ messages.append(f"adopt-hardware: {adopt_res.get('stdout', '')}")
613
+
614
+ return {
615
+ "ok": True,
616
+ "probes": probes,
617
+ "probes_count": len(probes),
618
+ "com_ports": com_ports,
619
+ "com_ports_count": len(com_ports),
620
+ "details": messages,
621
+ "recommendation": (
622
+ "No physical probes detected. Connect an ST-LINK or J-Link debugger and retry."
623
+ if len(probes) == 0 else
624
+ f"Detected {len(probes)} probe(s) and {len(com_ports)} COM port(s)."
625
+ ),
626
+ }
627
+
628
+
629
+ # ===========================================================================
630
+ # Tool Definitions & Schemas
631
+ # ===========================================================================
632
+
633
+ TOOLS_REGISTRY = {
634
+ "fw_doctor": {
635
+ "description": "Run diagnostic check on environment, toolchains, Python modules, COM ports, and probe/instrument readiness.",
636
+ "inputSchema": {
637
+ "type": "object",
638
+ "properties": {
639
+ "include_ports": {
640
+ "type": "boolean",
641
+ "description": "Whether to query active COM ports on the host",
642
+ "default": True,
643
+ }
644
+ },
645
+ "additionalProperties": False,
646
+ },
647
+ "handler": handle_fw_doctor,
648
+ },
649
+ "fw_configure_lab": {
650
+ "description": "Interactively configure or update project parameters, target chip (e.g. STM32F103C8), build backend (e.g. keil), source directory, COM port, and debugger probe in lab/lab.yaml.",
651
+ "inputSchema": {
652
+ "type": "object",
653
+ "properties": {
654
+ "project_name": {
655
+ "type": "string",
656
+ "description": "Project identifier name",
657
+ },
658
+ "build_backend": {
659
+ "type": "string",
660
+ "enum": ["keil", "cmake", "make", "iar", "zephyr", "esp-idf", "platformio"],
661
+ "description": "Build toolchain backend",
662
+ },
663
+ "source_dir": {
664
+ "type": "string",
665
+ "description": "Source or project directory containing .uvprojx, Makefile, or CMakeLists.txt",
666
+ },
667
+ "target_chip": {
668
+ "type": "string",
669
+ "description": "Target MCU model (e.g. STM32F103C8, STM32F407ZG)",
670
+ },
671
+ "debugger_backend": {
672
+ "type": "string",
673
+ "enum": ["stlink", "jlink", "pyocd", "openocd", "daplink"],
674
+ "description": "Hardware debugger probe backend",
675
+ },
676
+ "debugger_probe_id": {
677
+ "type": "string",
678
+ "description": "Serial number or ID of the hardware probe",
679
+ },
680
+ "uart_port": {
681
+ "type": "string",
682
+ "description": "DUT serial communication COM port (e.g. COM5)",
683
+ },
684
+ "uart_baudrate": {
685
+ "type": "integer",
686
+ "description": "UART baud rate (e.g. 115200)",
687
+ "default": 115200,
688
+ },
689
+ },
690
+ "additionalProperties": False,
691
+ },
692
+ "handler": handle_fw_configure_lab,
693
+ },
694
+ "fw_scan_hardware": {
695
+ "description": "Scan and identify all attached hardware debuggers (ST-LINK, J-Link, CMSIS-DAP), target MCU identity, and active COM ports.",
696
+ "inputSchema": {
697
+ "type": "object",
698
+ "properties": {
699
+ "adopt": {
700
+ "type": "boolean",
701
+ "description": "Whether to carry detected hardware parameters into configuration automatically",
702
+ "default": False,
703
+ }
704
+ },
705
+ "additionalProperties": False,
706
+ },
707
+ "handler": handle_fw_scan_hardware,
708
+ },
709
+ "fw_build": {
710
+ "description": "Compile firmware using the repository's build system (Keil, CMake, Make, IAR, PlatformIO, Zephyr, ESP-IDF). Returns structured artifact SHA256 and compiler diagnostics (file, line, col, message).",
711
+ "inputSchema": {
712
+ "type": "object",
713
+ "properties": {
714
+ "backend": {
715
+ "type": "string",
716
+ "enum": ["auto", "keil", "cmake", "make", "platformio", "iar", "zephyr", "esp-idf"],
717
+ "description": "Build system backend (defaults to auto detection or lab.yaml)",
718
+ "default": "auto",
719
+ },
720
+ "configuration": {
721
+ "type": "string",
722
+ "enum": ["Debug", "Release"],
723
+ "description": "Build configuration",
724
+ "default": "Debug",
725
+ },
726
+ "clean": {
727
+ "type": "boolean",
728
+ "description": "Perform full clean rebuild before compiling",
729
+ "default": True,
730
+ },
731
+ "source_dir": {
732
+ "type": "string",
733
+ "description": "Custom source directory (optional)",
734
+ },
735
+ "dry_run": {
736
+ "type": "boolean",
737
+ "description": "Only construct and return the build command without executing",
738
+ "default": False,
739
+ },
740
+ },
741
+ "additionalProperties": False,
742
+ },
743
+ "handler": handle_fw_build,
744
+ },
745
+ "fw_flash": {
746
+ "description": "Flash compiled firmware image (.elf / .hex / .bin / .axf) into target MCU via ST-LINK, J-Link, pyOCD, or simulator.",
747
+ "inputSchema": {
748
+ "type": "object",
749
+ "properties": {
750
+ "backend": {
751
+ "type": "string",
752
+ "enum": ["simulator", "agentic-hil", "openocd", "stm32cubeprogrammer", "jlink"],
753
+ "description": "Flashing backend driver",
754
+ "default": "simulator",
755
+ },
756
+ "artifact_path": {
757
+ "type": "string",
758
+ "description": "Relative or absolute path to the binary artifact to flash",
759
+ "default": "artifacts/build/firmware.elf",
760
+ },
761
+ },
762
+ "additionalProperties": False,
763
+ },
764
+ "handler": handle_fw_flash,
765
+ },
766
+ "fw_reset": {
767
+ "description": "Reset physical or simulated target MCU via debug probe or hardware reset line.",
768
+ "inputSchema": {
769
+ "type": "object",
770
+ "properties": {
771
+ "backend": {
772
+ "type": "string",
773
+ "enum": ["simulator", "agentic-hil", "openocd"],
774
+ "description": "Reset backend driver",
775
+ "default": "simulator",
776
+ },
777
+ "expected_target": {
778
+ "type": "string",
779
+ "description": "Expected MCU identity to verify before issuing reset",
780
+ },
781
+ },
782
+ "additionalProperties": False,
783
+ },
784
+ "handler": handle_fw_reset,
785
+ },
786
+ "fw_run_hil_test": {
787
+ "description": "Execute pytest hardware-in-the-loop (HIL) automated test suite (boot, UART, protocol, power, signal, logic analyzer). Returns structured JUnit & evidence.",
788
+ "inputSchema": {
789
+ "type": "object",
790
+ "properties": {
791
+ "gate": {
792
+ "type": "string",
793
+ "enum": ["simulator", "agentic-hil", "direct-serial"],
794
+ "description": "UART/Hardware gate mode",
795
+ "default": "simulator",
796
+ },
797
+ "test_filter": {
798
+ "type": "string",
799
+ "description": "pytest filter expression (-k filter)",
800
+ },
801
+ },
802
+ "additionalProperties": False,
803
+ },
804
+ "handler": handle_fw_run_hil_test,
805
+ },
806
+ "fw_acceptance_scenario": {
807
+ "description": "Execute complete end-to-end acceptance scenario (Build -> Flash -> Reset -> UART Observation -> Logic Analyzer -> Scope -> pytest -> Final Report).",
808
+ "inputSchema": {
809
+ "type": "object",
810
+ "properties": {
811
+ "mode": {
812
+ "type": "string",
813
+ "enum": ["simulator", "real"],
814
+ "description": "Execution mode (simulator for host CI/offline, real for physical bench)",
815
+ "default": "simulator",
816
+ },
817
+ "hardware": {
818
+ "type": "string",
819
+ "enum": ["agentic-hil"],
820
+ "description": "Hardware adapter when running in real mode",
821
+ "default": "agentic-hil",
822
+ },
823
+ },
824
+ "additionalProperties": False,
825
+ },
826
+ "handler": handle_fw_acceptance_scenario,
827
+ },
828
+ "fw_measure": {
829
+ "description": "Perform controlled, safe physical or simulated instrument measurements (Scope, PSU, DMM, AWG) via PyVISA/SCPI, enforced by safety limits.",
830
+ "inputSchema": {
831
+ "type": "object",
832
+ "properties": {
833
+ "instrument_type": {
834
+ "type": "string",
835
+ "enum": ["scope", "psu", "dmm"],
836
+ "description": "Category of instrument",
837
+ "default": "scope",
838
+ },
839
+ "command": {
840
+ "type": "string",
841
+ "enum": [
842
+ "measure-frequency",
843
+ "measure-duty",
844
+ "measure-vpp",
845
+ "measure-rms",
846
+ "measure-rise-time",
847
+ "measure-voltage",
848
+ "measure-current",
849
+ "measure-power",
850
+ "measure-resistance",
851
+ ],
852
+ "description": "Measurement command",
853
+ "default": "measure-frequency",
854
+ },
855
+ "instrument_name": {
856
+ "type": "string",
857
+ "description": "Instrument identifier configured in lab.yaml (e.g., scope1, psu1)",
858
+ "default": "scope1",
859
+ },
860
+ "channel": {
861
+ "type": "string",
862
+ "description": "Scope channel (CH1, CH2, etc.)",
863
+ "default": "CH1",
864
+ },
865
+ "backend": {
866
+ "type": "string",
867
+ "enum": ["simulator", "visa"],
868
+ "description": "Driver backend (simulator or real VISA)",
869
+ "default": "simulator",
870
+ },
871
+ },
872
+ "required": ["instrument_type", "command"],
873
+ "additionalProperties": False,
874
+ },
875
+ "handler": handle_fw_measure,
876
+ },
877
+ "fw_logic_capture": {
878
+ "description": "Capture digital protocol waveforms (I2C, SPI, UART) via Saleae / sigrok or deterministic simulator.",
879
+ "inputSchema": {
880
+ "type": "object",
881
+ "properties": {
882
+ "protocol": {
883
+ "type": "string",
884
+ "enum": ["i2c", "spi", "uart"],
885
+ "description": "Target communication protocol (e.g. i2c, spi, uart)",
886
+ "default": "i2c",
887
+ },
888
+ "sample_rate": {
889
+ "type": "integer",
890
+ "description": "Sample rate in Hz",
891
+ "default": 10000000,
892
+ },
893
+ "duration_ms": {
894
+ "type": "integer",
895
+ "description": "Capture duration in milliseconds",
896
+ "default": 50,
897
+ },
898
+ "output_file": {
899
+ "type": "string",
900
+ "description": "Path to save raw capture file (optional)",
901
+ },
902
+ },
903
+ "additionalProperties": False,
904
+ },
905
+ "handler": handle_fw_logic_capture,
906
+ },
907
+ "fw_logic_decode": {
908
+ "description": "Decode captured digital waveforms into protocol packets/frames (I2C address/ACK, SPI bytes, UART data) and verify assertions.",
909
+ "inputSchema": {
910
+ "type": "object",
911
+ "properties": {
912
+ "capture_file": {
913
+ "type": "string",
914
+ "description": "Path to capture file (defaults to latest capture in artifacts/captures/)",
915
+ },
916
+ "protocol": {
917
+ "type": "string",
918
+ "enum": ["i2c", "spi", "uart"],
919
+ "description": "Protocol decoder",
920
+ "default": "i2c",
921
+ },
922
+ "frequency_hz": {
923
+ "type": "integer",
924
+ "description": "Clock frequency for decoding (Hz)",
925
+ "default": 400000,
926
+ },
927
+ },
928
+ "additionalProperties": False,
929
+ },
930
+ "handler": handle_fw_logic_decode,
931
+ },
932
+ "fw_get_evidence": {
933
+ "description": "Retrieve audit evidence, summary, and artifacts from recent test and acceptance runs.",
934
+ "inputSchema": {
935
+ "type": "object",
936
+ "properties": {
937
+ "run_id": {
938
+ "type": "string",
939
+ "description": "Specific run ID or 'latest' for the most recent run",
940
+ "default": "latest",
941
+ }
942
+ },
943
+ "additionalProperties": False,
944
+ },
945
+ "handler": handle_fw_get_evidence,
946
+ },
947
+ "fw_init_project": {
948
+ "description": "Scaffold multi-agent instruction files (AGENTS.md, CLAUDE.md, GEMINI.md), lab.yaml bench config, and .mcp.json in the current or specified firmware project directory.",
949
+ "inputSchema": {
950
+ "type": "object",
951
+ "properties": {
952
+ "target_dir": {
953
+ "type": "string",
954
+ "description": "Directory path of the firmware project to initialize (defaults to current directory '.')",
955
+ "default": ".",
956
+ },
957
+ "target_chip": {
958
+ "type": "string",
959
+ "description": "Target MCU chip model (e.g. STM32F103C8, STM32F407ZG)",
960
+ "default": "STM32F103C8",
961
+ },
962
+ "build_backend": {
963
+ "type": "string",
964
+ "enum": ["keil", "cmake", "make", "platformio", "iar", "zephyr", "esp-idf"],
965
+ "description": "Build system backend",
966
+ "default": "keil",
967
+ },
968
+ "overwrite": {
969
+ "type": "boolean",
970
+ "description": "Whether to overwrite existing instruction files if present",
971
+ "default": False,
972
+ },
973
+ },
974
+ "additionalProperties": False,
975
+ },
976
+ "handler": handle_fw_init_project,
977
+ },
978
+ }
979
+
980
+
981
+ # ===========================================================================
982
+ # JSON-RPC 2.0 MCP Protocol Processor
983
+ # ===========================================================================
984
+
985
+ def build_tools_list() -> List[Dict[str, Any]]:
986
+ tools = []
987
+ for name, info in TOOLS_REGISTRY.items():
988
+ tools.append({
989
+ "name": name,
990
+ "description": info["description"],
991
+ "inputSchema": info["inputSchema"],
992
+ })
993
+ return tools
994
+
995
+
996
+ def process_request(req: Dict[str, Any]) -> Optional[Dict[str, Any]]:
997
+ req_id = req.get("id")
998
+ method = req.get("method")
999
+ params = req.get("params", {})
1000
+
1001
+ if method == "initialize":
1002
+ return {
1003
+ "jsonrpc": "2.0",
1004
+ "id": req_id,
1005
+ "result": {
1006
+ "protocolVersion": "2024-11-05",
1007
+ "capabilities": {
1008
+ "tools": {
1009
+ "listChanged": False
1010
+ }
1011
+ },
1012
+ "serverInfo": {
1013
+ "name": "firmwareloop",
1014
+ "version": "0.0.8"
1015
+ }
1016
+ }
1017
+ }
1018
+
1019
+ if method == "notifications/initialized":
1020
+ return None
1021
+
1022
+ if method == "ping":
1023
+ return {
1024
+ "jsonrpc": "2.0",
1025
+ "id": req_id,
1026
+ "result": {}
1027
+ }
1028
+
1029
+ if method == "tools/list":
1030
+ return {
1031
+ "jsonrpc": "2.0",
1032
+ "id": req_id,
1033
+ "result": {
1034
+ "tools": build_tools_list()
1035
+ }
1036
+ }
1037
+
1038
+ if method == "tools/call":
1039
+ tool_name = params.get("name")
1040
+ arguments = params.get("arguments", {})
1041
+
1042
+ if tool_name not in TOOLS_REGISTRY:
1043
+ return {
1044
+ "jsonrpc": "2.0",
1045
+ "id": req_id,
1046
+ "error": {
1047
+ "code": -32601,
1048
+ "message": f"Tool '{tool_name}' not found"
1049
+ }
1050
+ }
1051
+
1052
+ handler = TOOLS_REGISTRY[tool_name]["handler"]
1053
+ try:
1054
+ result_data = handler(arguments)
1055
+ result_text = json.dumps(result_data, ensure_ascii=False, indent=2)
1056
+ return {
1057
+ "jsonrpc": "2.0",
1058
+ "id": req_id,
1059
+ "result": {
1060
+ "content": [
1061
+ {
1062
+ "type": "text",
1063
+ "text": result_text
1064
+ }
1065
+ ],
1066
+ "isError": not result_data.get("ok", result_data.get("success", True))
1067
+ }
1068
+ }
1069
+ except Exception as e:
1070
+ err_body = {
1071
+ "ok": False,
1072
+ "error_class": "TOOL_EXECUTION_EXCEPTION",
1073
+ "error": str(e),
1074
+ "traceback": traceback.format_exc()
1075
+ }
1076
+ return {
1077
+ "jsonrpc": "2.0",
1078
+ "id": req_id,
1079
+ "result": {
1080
+ "content": [
1081
+ {
1082
+ "type": "text",
1083
+ "text": json.dumps(err_body, ensure_ascii=False, indent=2)
1084
+ }
1085
+ ],
1086
+ "isError": True
1087
+ }
1088
+ }
1089
+
1090
+ # Unknown method
1091
+ if req_id is not None:
1092
+ return {
1093
+ "jsonrpc": "2.0",
1094
+ "id": req_id,
1095
+ "error": {
1096
+ "code": -32601,
1097
+ "message": f"Method '{method}' not implemented"
1098
+ }
1099
+ }
1100
+ return None
1101
+
1102
+
1103
+ def handle_cli_update() -> int:
1104
+ """Handle `firmwareloop update` command."""
1105
+ print("============================================================")
1106
+ print(" FirmwareLoop Auto-Updater (v0.0.8)")
1107
+ print("============================================================")
1108
+
1109
+ is_git_repo = os.path.exists(os.path.join(REPO_ROOT, ".git"))
1110
+ if is_git_repo:
1111
+ print(f"[*] Local Git repository detected at: {REPO_ROOT}")
1112
+ print("[*] Pulling latest updates from GitHub remote...")
1113
+ try:
1114
+ res_pull = subprocess.run(["git", "pull"], cwd=REPO_ROOT, text=True, capture_output=True)
1115
+ print(res_pull.stdout.strip() if res_pull.stdout else "")
1116
+ if res_pull.returncode != 0:
1117
+ print(f"[-] Git pull failed: {res_pull.stderr.strip()}")
1118
+ return 1
1119
+ print("[+] Git pull completed successfully.")
1120
+ except Exception as e:
1121
+ print(f"[-] Error executing git pull: {e}")
1122
+ return 1
1123
+
1124
+ print("[*] Reinstalling & updating package dependencies...")
1125
+ uv_path = shutil.which("uv")
1126
+ if uv_path:
1127
+ cmd = [uv_path, "pip", "install", "-e", "."]
1128
+ else:
1129
+ cmd = [sys.executable, "-m", "pip", "install", "-e", "."]
1130
+
1131
+ try:
1132
+ res_install = subprocess.run(cmd, cwd=REPO_ROOT, text=True, capture_output=True)
1133
+ if res_install.returncode == 0:
1134
+ print("[+] Dependencies and CLI entrypoints updated successfully.")
1135
+ else:
1136
+ print(f"[-] Dependency update warning: {res_install.stderr.strip()}")
1137
+ except Exception as e:
1138
+ print(f"[-] Error installing dependencies: {e}")
1139
+
1140
+ print("============================================================")
1141
+ print("[+] FirmwareLoop is now up to date!")
1142
+ print("============================================================")
1143
+ return 0
1144
+ else:
1145
+ print("[*] Global / uv-managed installation detected.")
1146
+ uv_path = shutil.which("uv")
1147
+ if uv_path:
1148
+ print("[*] Refreshing uv cache for firmwareloop...")
1149
+ try:
1150
+ subprocess.run([uv_path, "cache", "clean", "firmwareloop"], capture_output=True)
1151
+ print("[+] uv cache refreshed! The next run will automatically fetch the latest release from GitHub.")
1152
+ return 0
1153
+ except Exception as e:
1154
+ print(f"[-] Error refreshing uv cache: {e}")
1155
+ return 1
1156
+ else:
1157
+ print("[*] Upgrading via pip...")
1158
+ res = subprocess.run([sys.executable, "-m", "pip", "install", "--upgrade", "firmwareloop"])
1159
+ return res.returncode
1160
+
1161
+
1162
+ def handle_cli_doctor() -> int:
1163
+ """Handle `firmwareloop doctor` command."""
1164
+ print("[*] Running FirmwareLoop environment diagnostics...")
1165
+ cmd = [PWSH_EXE, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", os.path.join(REPO_ROOT, "tools", "doctor.ps1")]
1166
+ res = subprocess.run(cmd, cwd=REPO_ROOT)
1167
+ return res.returncode
1168
+
1169
+
1170
+ def handle_cli_setup() -> int:
1171
+ """Handle `firmwareloop setup` command."""
1172
+ cmd = [PWSH_EXE, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", os.path.join(REPO_ROOT, "tools", "setup-agent-mcp.ps1")]
1173
+ res = subprocess.run(cmd, cwd=REPO_ROOT)
1174
+ return res.returncode
1175
+
1176
+
1177
+ def handle_cli_init() -> int:
1178
+ """Handle `firmwareloop init` command."""
1179
+ print("============================================================")
1180
+ print(" FirmwareLoop Multi-Agent Project Initializer (v0.0.8)")
1181
+ print("============================================================")
1182
+ cwd = os.getcwd()
1183
+ print(f"[*] Initializing multi-agent guidelines & bench config in:\n {cwd}\n")
1184
+ res = handle_fw_init_project({"target_dir": cwd, "overwrite": False})
1185
+ if res.get("ok"):
1186
+ for f in res.get("created_files", []):
1187
+ print(f" [+] Created: {f}")
1188
+ if not res.get("created_files"):
1189
+ print(" [*] All agent instruction files (AGENTS.md, CLAUDE.md, GEMINI.md, lab.yaml, .mcp.json) already exist.")
1190
+ print("")
1191
+ print("============================================================")
1192
+ print("[+] Done! All AI Coding Agents (Claude Code, Qoder, Antigravity, Cursor) are now ready to operate in this repository.")
1193
+ print("============================================================")
1194
+ return 0
1195
+ else:
1196
+ print(f"[-] Initialization failed: {res.get('message')}")
1197
+ return 1
1198
+
1199
+
1200
+ def print_cli_help() -> None:
1201
+ print("""FirmwareLoop (fwloop) — AI Agent Firmware Engineering & Lab Automation Platform (v0.0.8)
1202
+
1203
+ Usage:
1204
+ fwloop [command] (or: firmwareloop [command])
1205
+
1206
+ Commands:
1207
+ init Initialize multi-agent rules (AGENTS.md, CLAUDE.md, GEMINI.md) & lab.yaml in current project
1208
+ update Check and update FirmwareLoop to the latest version
1209
+ doctor Run environment & toolchain diagnostics
1210
+ setup Print or generate AI Agent MCP registration commands
1211
+ version, -v Show current version
1212
+ help, -h Show this help message
1213
+
1214
+ Default behavior (no arguments):
1215
+ Starts the Model Context Protocol (MCP) JSON-RPC 2.0 stdio server.
1216
+ """)
1217
+
1218
+
1219
+ def main() -> None:
1220
+ args = sys.argv[1:]
1221
+ if args:
1222
+ cmd = args[0].lower().strip()
1223
+ if cmd in ["init", "scaffold"]:
1224
+ sys.exit(handle_cli_init())
1225
+ elif cmd in ["update", "upgrade"]:
1226
+ sys.exit(handle_cli_update())
1227
+ elif cmd in ["doctor", "check"]:
1228
+ sys.exit(handle_cli_doctor())
1229
+ elif cmd in ["setup", "register"]:
1230
+ sys.exit(handle_cli_setup())
1231
+ elif cmd in ["version", "-v", "--version"]:
1232
+ print("FirmwareLoop v0.0.8")
1233
+ sys.exit(0)
1234
+ elif cmd in ["help", "-h", "--help"]:
1235
+ print_cli_help()
1236
+ sys.exit(0)
1237
+ elif cmd in ["mcp", "mcp-stdio", "stdio"]:
1238
+ pass # Fall through to stdio server loop
1239
+
1240
+ # MCP stdio JSON-RPC loop
1241
+ if sys.platform == "win32":
1242
+ import msvcrt
1243
+ msvcrt.setmode(sys.stdin.fileno(), os.O_BINARY)
1244
+ msvcrt.setmode(sys.stdout.fileno(), os.O_BINARY)
1245
+
1246
+ reader = sys.stdin.buffer
1247
+ writer = sys.stdout.buffer
1248
+
1249
+ while True:
1250
+ line = reader.readline()
1251
+ if not line:
1252
+ break
1253
+
1254
+ line_str = line.decode("utf-8", errors="replace").strip()
1255
+ if not line_str:
1256
+ continue
1257
+
1258
+ try:
1259
+ req = json.loads(line_str)
1260
+ except Exception:
1261
+ continue
1262
+
1263
+ res = process_request(req)
1264
+ if res is not None:
1265
+ res_bytes = (json.dumps(res, ensure_ascii=False) + "\n").encode("utf-8")
1266
+ writer.write(res_bytes)
1267
+ writer.flush()
1268
+
1269
+
1270
+ if __name__ == "__main__":
1271
+ main()
1272
+