axiometa-cli 2.0.0__tar.gz → 2.0.2__tar.gz

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.
Files changed (21) hide show
  1. {axiometa_cli-2.0.0 → axiometa_cli-2.0.2}/PKG-INFO +2 -3
  2. {axiometa_cli-2.0.0 → axiometa_cli-2.0.2}/README.md +1 -1
  3. axiometa_cli-2.0.2/axiometa_cli/agnostic.py +162 -0
  4. {axiometa_cli-2.0.0 → axiometa_cli-2.0.2}/axiometa_cli/cli.py +27 -3
  5. {axiometa_cli-2.0.0 → axiometa_cli-2.0.2}/axiometa_cli/core.py +109 -0
  6. {axiometa_cli-2.0.0 → axiometa_cli-2.0.2}/axiometa_cli/knowledge_client.py +42 -0
  7. {axiometa_cli-2.0.0 → axiometa_cli-2.0.2}/axiometa_cli/mcp.py +59 -5
  8. {axiometa_cli-2.0.0 → axiometa_cli-2.0.2}/axiometa_cli/provision.py +71 -0
  9. {axiometa_cli-2.0.0 → axiometa_cli-2.0.2}/axiometa_cli/toolchain.py +18 -0
  10. {axiometa_cli-2.0.0 → axiometa_cli-2.0.2}/axiometa_cli/ui.py +33 -6
  11. {axiometa_cli-2.0.0 → axiometa_cli-2.0.2}/axiometa_cli.egg-info/PKG-INFO +2 -3
  12. {axiometa_cli-2.0.0 → axiometa_cli-2.0.2}/axiometa_cli.egg-info/SOURCES.txt +1 -0
  13. {axiometa_cli-2.0.0 → axiometa_cli-2.0.2}/pyproject.toml +4 -2
  14. {axiometa_cli-2.0.0 → axiometa_cli-2.0.2}/axiometa_cli/__init__.py +0 -0
  15. {axiometa_cli-2.0.0 → axiometa_cli-2.0.2}/axiometa_cli/__main__.py +0 -0
  16. {axiometa_cli-2.0.0 → axiometa_cli-2.0.2}/axiometa_cli/detect.py +0 -0
  17. {axiometa_cli-2.0.0 → axiometa_cli-2.0.2}/axiometa_cli.egg-info/dependency_links.txt +0 -0
  18. {axiometa_cli-2.0.0 → axiometa_cli-2.0.2}/axiometa_cli.egg-info/entry_points.txt +0 -0
  19. {axiometa_cli-2.0.0 → axiometa_cli-2.0.2}/axiometa_cli.egg-info/requires.txt +0 -0
  20. {axiometa_cli-2.0.0 → axiometa_cli-2.0.2}/axiometa_cli.egg-info/top_level.txt +0 -0
  21. {axiometa_cli-2.0.0 → axiometa_cli-2.0.2}/setup.cfg +0 -0
@@ -1,10 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: axiometa-cli
3
- Version: 2.0.0
3
+ Version: 2.0.2
4
4
  Summary: Axiometa hardware truths and upload methods, for your editor and your AI.
5
5
  Author-email: Axiometa <dr.dumcius@gmail.com>
6
6
  Project-URL: Homepage, https://axiometa.io
7
- Project-URL: Source, https://github.com/axiometa/axiometa-studio/tree/main/axiometa-cli
8
7
  Project-URL: Studio, https://studio.axiometa.io
9
8
  Keywords: hardware,ai,mcp,esp32,nrf52840,arduino,iot,cli,maker,embedded
10
9
  Classifier: Development Status :: 4 - Beta
@@ -59,7 +58,7 @@ Add `--json` to any of them. That is the form an agent wants.
59
58
 
60
59
  | | |
61
60
  |---|---|
62
- | `provision` | install the private toolchain |
61
+ | `provision` | install the private toolchain (~7 GB, 15-20 min, once per machine) |
63
62
  | `devices` | what is plugged in |
64
63
  | `compile <dir> -b <board>` | build with the right target, profile and libraries |
65
64
  | `upload <dir> -b <board>` | build and put it on the board |
@@ -37,7 +37,7 @@ Add `--json` to any of them. That is the form an agent wants.
37
37
 
38
38
  | | |
39
39
  |---|---|
40
- | `provision` | install the private toolchain |
40
+ | `provision` | install the private toolchain (~7 GB, 15-20 min, once per machine) |
41
41
  | `devices` | what is plugged in |
42
42
  | `compile <dir> -b <board>` | build with the right target, profile and libraries |
43
43
  | `upload <dir> -b <board>` | build and put it on the board |
@@ -0,0 +1,162 @@
1
+ """Separate the CLI's knowledge from Studio's surface. Build-time only — nothing calls this at runtime.
2
+
3
+ backend/knowledge/ is authored for Studio and is never edited for our sake, so this is the one place
4
+ that states the seam. Two rules, and the difference between them is the whole design:
5
+
6
+ DELETE what would silently produce a broken build. A %%TOKEN%% is a hole Studio's build fills;
7
+ nothing fills it here, so a sketch carrying one compiles clean and then does not work. Those
8
+ strings go, and an entry naming the integrations relay goes whole.
9
+
10
+ RENAME Studio's own surfaces. "the Studio serial box", "the setup tab", "the Upload button" are
11
+ instructions to use a UI that does not exist outside the browser. The RULE around each is real —
12
+ print the IP because the page's console is invisible, encode the MT: payload directly or a
13
+ controller cannot scan it, declare the bigger partition or a Matter build overflows — so the rule
14
+ survives and only the surface changes.
15
+
16
+ What is NOT touched: anything about the hardware. Pins, buses, addresses, libraries, init, timing,
17
+ silicon gotchas all ship exactly as written. An earlier version of this file tried to reword those
18
+ too and became twenty exact-string substitutions that went stale the moment anyone edited a
19
+ sentence. A wrong noun costs nothing; a rule lost to a stale pattern costs the build.
20
+
21
+ The list below is deliberately short and deliberately EXACT-STRING. No \\bStudio\\b regex: the nRF
22
+ board package legitimately names "Seeed-Studio/Adafruit_nRF52_Arduino", and a pattern would corrupt
23
+ an upstream vendor path.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import re
29
+
30
+ # In CODE a credential token becomes an obvious literal; every agent fills a YOUR_* value unprompted.
31
+ _CREDENTIALS = {
32
+ "%%WIFI_SSID%%": "YOUR_WIFI_SSID",
33
+ "%%WIFI_PASSWORD%%": "YOUR_WIFI_PASSWORD",
34
+ "%%WIFI_PASS%%": "YOUR_WIFI_PASSWORD",
35
+ }
36
+ # A backend capability the CLI does not ship. An entry naming one is dropped whole.
37
+ _BACKEND = ("%%RELAY_URL%%", "%%DEVICE_KEY%%")
38
+
39
+ _ANY_TOKEN = re.compile(r"%%[A-Z_]+%%")
40
+
41
+ # Keys whose strings are CODE (tokens get a literal). Elsewhere a token means the string is ABOUT
42
+ # the substitution, so the string goes.
43
+ _CODE_KEYS = {"init", "snippet", "includes", "example"}
44
+
45
+ # Studio surfaces, stated as instructions. Keep the requirement, drop the UI.
46
+ _RENAME = (
47
+ # The USB serial peer. `Serial` is `Serial` wherever it is plugged in.
48
+ ("`Serial`, the link to Studio", "`Serial`, the link to the host"),
49
+ ("the USB-CDC link to Studio", "the USB-CDC link to the host"),
50
+ ("USB-CDC link to Studio", "USB-CDC link to the host"),
51
+ ("the USB link to Studio", "the USB link to the host"),
52
+ ("Studio's serial link and every example use 115200",
53
+ "every example uses 115200"),
54
+ ("the user reads it in the Studio serial box; you read it with read_serial",
55
+ "the user reads it in a serial monitor; you read it with read_serial"),
56
+ # A button in a browser. The requirement — declare the bigger partition — is real.
57
+ ("so the Upload button builds a big-enough app slot", "so the build gets a big-enough app slot"),
58
+ # Studio tabs. The MT: payload rule is a real contract with whatever scans the QR.
59
+ ("The setup tab MUST encode that MT: payload directly into the QR",
60
+ "Whatever renders the QR MUST encode that MT: payload directly"),
61
+ ("Defensive: if the tab receives a URL, take its data= param before encoding.",
62
+ "Defensive: if what you receive is a URL, take its data= param before encoding."),
63
+ ("print it to serial and surface it in the Matter Setup tab",
64
+ "print it to serial and surface it wherever the pairing QR is shown"),
65
+ ("A Matter build is plain files: sketch.ino prints its pairing code over serial "
66
+ "(MATTER_QR: / MATTER_CODE:), browser/MatterSetup.html renders the live pairing QR from those "
67
+ "lines, and the walkthrough goes in reports/MatterSetup.md.",
68
+ "A Matter build is plain files: sketch.ino prints its pairing code over serial "
69
+ "(MATTER_QR: / MATTER_CODE:), and whatever surface you build reads those lines to render the "
70
+ "live pairing QR."),
71
+ ("invisible to you and to Studio", "invisible to you and to your tools"),
72
+ ("Studio's serial link is fixed at 115200", "the serial link is fixed at 115200"),
73
+ ("verbatim placeholder — Studio fills it", "fill in the network's SSID"),
74
+ ("Studio parses these lines to draw the pairing QR",
75
+ "whatever watches the serial channel draws the pairing QR from these lines"),
76
+ # A settings dialog that does not exist here; the substitution behind it is already stripped.
77
+ ("WiFi credentials are set by the user in Studio's WiFi settings and baked into the sketch when "
78
+ "you GENERATE it.",
79
+ "WiFi credentials are baked into the sketch when you GENERATE it, so ask for them first."),
80
+ ("A one-click Studio push is not wired yet: frame OTA as 'update from your browser', never "
81
+ "'Studio pushes automatically'.",
82
+ "Frame OTA as 'update from your browser', never as something that pushes automatically."),
83
+ )
84
+
85
+
86
+ def _rename(s: str) -> str:
87
+ for old, new in _RENAME:
88
+ s = s.replace(old, new)
89
+ return s
90
+
91
+
92
+ def _code(s: str):
93
+ if any(t in s for t in _BACKEND):
94
+ return None
95
+ for tok, lit in _CREDENTIALS.items():
96
+ s = s.replace(tok, lit)
97
+ return _rename(s) if not _ANY_TOKEN.search(s) else None
98
+
99
+
100
+ def _prose(s: str):
101
+ # Any %% at all: the tree writes things like "it still holds raw %%...%%", which a
102
+ # %%[A-Z_]+%% pattern walks straight past.
103
+ return None if "%%" in s else _rename(s)
104
+
105
+
106
+ def _walk(node, code: bool = False, root: bool = False):
107
+ if isinstance(node, str):
108
+ return _code(node) if code else _prose(node)
109
+ if isinstance(node, list):
110
+ return [c for c in (_walk(v, code) for v in node) if c is not None]
111
+ if isinstance(node, dict):
112
+ # An ENTRY naming a capability we do not ship goes whole. Never the root: it contains every
113
+ # entry, so testing it recursively would discard the entire part.
114
+ if not root and any(t in json.dumps(node) for t in _BACKEND):
115
+ return None
116
+ out = {}
117
+ for k, v in node.items():
118
+ c = _walk(v, code=(k in _CODE_KEYS))
119
+ if c is not None:
120
+ out[k] = c
121
+ return out
122
+ return node
123
+
124
+
125
+ def transform_metadata(text: str) -> str:
126
+ try:
127
+ data = json.loads(text)
128
+ except Exception:
129
+ return text
130
+ cleaned = _walk(data, root=True)
131
+ if not isinstance(cleaned, dict):
132
+ return text
133
+ return json.dumps(cleaned, indent=2, ensure_ascii=False) + "\n"
134
+
135
+
136
+ # APPENDED to each Arduino contract. A real headless run lost a compile round-trip to
137
+ # `uint8_t index = 0`: Arduino.h pulls in newlib's <strings.h>, which declares
138
+ # char *index(const char*, int), and the cascading errors point at the USE sites rather than the
139
+ # name. Same species as the min()/max() and auto-prototype rules the contract already carries — an
140
+ # ordinary C++ name this toolchain has already spent.
141
+ #
142
+ # It is added HERE, not authored upstream, because backend/knowledge is Studio's and live. If it
143
+ # ever lands there, this appender finds its own text already present and does nothing.
144
+ _RESERVED_RULE = """
145
+
146
+ Names the toolchain has already taken. `Arduino.h` pulls in newlib's `<strings.h>` and `<math.h>`, so a few ordinary-looking identifiers are already declared at global scope and redeclaring one fails the build: `index` (`char *index(const char*, int)`), `y0` / `y1` / `yn` and `j0` / `j1` / `jn` (Bessel functions), and the `B0`-`B11111111` binary constants. The error names the redeclaration, but the errors after it point at the USE sites, which reads as a type problem somewhere else entirely. Prefix or rename instead — `colorIndex`, `idx`, `currentIndex` — and never at file scope, where the clash is guaranteed."""
147
+
148
+
149
+ def transform(relpath: str, text: str) -> str:
150
+ """One knowledge file, in its shipped form. metadata.json gets the structural pass (entries
151
+ dropped, code told apart from prose); every other text file gets the rename, because a Studio
152
+ surface reaches an agent just as well from a contract as from a coding note."""
153
+ if relpath.endswith("metadata.json"):
154
+ return transform_metadata(text)
155
+ if "%%" in text:
156
+ # Prose files: drop only the lines that name a token, never a whole paragraph — losing a
157
+ # toolchain law silently is worse than carrying one sentence that does not apply.
158
+ text = "\n".join(l for l in text.splitlines() if "%%" not in l) + "\n"
159
+ text = _rename(text)
160
+ if relpath.endswith("firmware/contract.md") and "already taken" not in text:
161
+ text = text.rstrip() + _RESERVED_RULE + "\n"
162
+ return text
@@ -20,6 +20,7 @@ Two jobs, for someone else's AI working in someone else's repo:
20
20
  axiometa devices
21
21
  axiometa compile ./firmware -b genesis-one --parts AX22-0005
22
22
  axiometa upload ./firmware -b genesis-one --parts AX22-0005
23
+ axiometa monitor what the board is printing
23
24
 
24
25
  axiometa mcp the same, for clients that speak MCP
25
26
 
@@ -208,7 +209,7 @@ def cmd_compile(args):
208
209
  ui.err(f"no {sub['device_program']} (or a single {ext} file) in {src}.")
209
210
  sys.exit(2)
210
211
  # arduino-cli needs <folder>/<folder>.ino; stage so any filename in the user's repo works.
211
- src = core.stage(src, entry, ext)
212
+ real, src = src, core.stage(src, entry, ext)
212
213
  fqbn = core.fqbn_with_options(sub, _options_arg(args.option))
213
214
  if args.print_command:
214
215
  # The escape hatch. Everything this tool knows that you could not derive is in this one
@@ -222,8 +223,9 @@ def cmd_compile(args):
222
223
  if failed:
223
224
  ui.warn("could not install: " + ", ".join(failed))
224
225
  r = core._run(toolchain.ac_cmd("compile", "--fqbn", fqbn, str(src)),
225
- on_line=lambda l: print(ui.c(" " + l, "grey")), env=toolchain.env())
226
- (ui.ok if r.ok else ui.err)("compiled" if r.ok else r.detail)
226
+ on_line=lambda l: print(ui.c(" " + core.unstage_paths(l, src, real), "grey")),
227
+ env=toolchain.env())
228
+ (ui.ok if r.ok else ui.err)("compiled" if r.ok else core.unstage_paths(r.detail, src, real))
227
229
  sys.exit(0 if r.ok else 1)
228
230
 
229
231
 
@@ -268,6 +270,21 @@ def cmd_login(args):
268
270
  ui.ok(f"key saved to {toolchain.save_key(args.key)}")
269
271
 
270
272
 
273
+ def cmd_monitor(args):
274
+ """Stream the serial channel. Passive by default — opening will not reset a running board."""
275
+ port = args.port or core.pick_port(None)
276
+ if not port:
277
+ ui.err("no serial device. Plug the board in, or pass --port.")
278
+ sys.exit(1)
279
+ ui.step(f"reading {port} at {args.baud} for {args.seconds}s"
280
+ + (" (resetting first)" if args.reset else ""))
281
+ r = core.read_serial(port, baud=args.baud, seconds=args.seconds, reset=args.reset,
282
+ on_line=lambda l: print(ui.c(" " + l, "cyan")))
283
+ if not r.ok:
284
+ ui.err(r.detail)
285
+ sys.exit(1)
286
+
287
+
271
288
  def cmd_mcp(args):
272
289
  from . import mcp
273
290
  sys.exit(mcp.serve())
@@ -332,6 +349,13 @@ def build_parser() -> argparse.ArgumentParser:
332
349
  lg.add_argument("key", nargs="?", help="the axm_... key; omit to show the stored one")
333
350
  lg.set_defaults(func=cmd_login)
334
351
 
352
+ mo = sub.add_parser("monitor", help="read what the board is printing")
353
+ mo.add_argument("-p", "--port", help="serial port; autodetected if omitted")
354
+ mo.add_argument("--seconds", type=float, default=10.0)
355
+ mo.add_argument("--baud", type=int, default=115200)
356
+ mo.add_argument("--reset", action="store_true", help="restart the sketch to catch boot output")
357
+ mo.set_defaults(func=cmd_monitor)
358
+
335
359
  sub.add_parser("mcp", help="the same tools over MCP (stdio)").set_defaults(func=cmd_mcp)
336
360
  return p
337
361
 
@@ -289,6 +289,115 @@ def stage(source: Path, entry: Path, ext: str) -> Path:
289
289
  return staged
290
290
 
291
291
 
292
+ NL = b"\n"
293
+
294
+
295
+ def read_serial(port: str, baud: int = 115200, seconds: float = 5.0,
296
+ reset: bool = False, on_line=None) -> DeployResult:
297
+ """Read the board's serial output for `seconds`.
298
+
299
+ THE OPEN MUST NOT ASSERT DTR. pyserial raises DTR on open by default, and on an ESP32-S3 the USB
300
+ is native CDC — the ROM reads the DTR/RTS line states as a reset request, so a plain
301
+ Serial(port) can drop a running board into the download stub. The board then sits in "waiting for
302
+ download" and prints nothing, which reads as a dead sketch. Building the object unopened, setting
303
+ both lines low, and only then opening is what makes this observation passive.
304
+
305
+ RE-ENUMERATION IS EXPECTED, NOT AN ERROR. Native USB means the CDC device IS the firmware: any
306
+ reset tears the USB device down and the port disappears for a moment before coming back. A read
307
+ that treats a vanished port as failure reports "board died" for a board that is booting normally.
308
+ So a disappearance inside the window is waited out and the port reopened.
309
+
310
+ `reset` pulses RTS with DTR held LOW. That combination is a run-the-app reset; DTR HIGH with RTS
311
+ is what the ROM reads as enter-the-bootloader, which is the trap on the other side.
312
+ """
313
+ try:
314
+ import serial
315
+ from serial import SerialException
316
+ except Exception:
317
+ return DeployResult(False, "pyserial is not installed.", [])
318
+ if not port:
319
+ return DeployResult(False, "no serial port. Plug the board in, or pass one explicitly.", [])
320
+
321
+ def _open():
322
+ s = serial.Serial()
323
+ s.port = port
324
+ s.baudrate = baud
325
+ s.timeout = 0.2
326
+ s.dtr = False # both LOW before the port exists: see above
327
+ s.rts = False
328
+ s.open()
329
+ return s
330
+
331
+ lines: list = []
332
+ deadline = time.monotonic() + max(0.5, seconds)
333
+ try:
334
+ ser = _open()
335
+ except SerialException as e:
336
+ return DeployResult(False, f"could not open {port}: {e}", [])
337
+
338
+ if reset:
339
+ try:
340
+ ser.dtr = False
341
+ ser.rts = True
342
+ time.sleep(0.1)
343
+ ser.rts = False
344
+ except Exception:
345
+ pass # a port that will not take line changes still reads fine
346
+
347
+ buf = b""
348
+ while time.monotonic() < deadline:
349
+ try:
350
+ chunk = ser.read(256)
351
+ except (SerialException, OSError):
352
+ # The device went away — a reset re-enumerating, not a failure. Reopen until the window
353
+ # closes; only give up when time actually runs out.
354
+ try:
355
+ ser.close()
356
+ except Exception:
357
+ pass
358
+ ser = None
359
+ while time.monotonic() < deadline and ser is None:
360
+ time.sleep(0.25)
361
+ try:
362
+ ser = _open()
363
+ except SerialException:
364
+ ser = None
365
+ if ser is None:
366
+ break
367
+ continue
368
+ if not chunk:
369
+ continue
370
+ buf += chunk
371
+ while NL in buf:
372
+ raw, buf = buf.split(NL, 1)
373
+ line = raw.decode("utf-8", "replace").rstrip("\r")
374
+ lines.append(line)
375
+ if on_line:
376
+ on_line(line)
377
+ if buf:
378
+ lines.append(buf.decode("utf-8", "replace").rstrip("\r"))
379
+ try:
380
+ ser.close()
381
+ except Exception:
382
+ pass
383
+ return DeployResult(True, "\n".join(lines), [])
384
+
385
+
386
+ def unstage_paths(text: str, staged: Path, source: Path) -> str:
387
+ r"""Rewrite staged paths in toolchain output back to the user's own directory.
388
+
389
+ We compile a COPY under ~/.axiometa/build/<name>/ because arduino-cli demands
390
+ <folder>/<folder>.ino. That is our business, not the caller's: a compiler error citing
391
+ ~/.axiometa/build/sketch/sketch.ino:22 sends someone hunting for a file they never wrote, in a
392
+ directory they did not know existed. A real first run hit exactly that — the error was correct
393
+ and the path was ours. Point every diagnostic back at the real source."""
394
+ if not text:
395
+ return text
396
+ for a, b in ((str(staged), str(source)), (staged.as_posix(), source.as_posix())):
397
+ text = text.replace(a, b)
398
+ return text
399
+
400
+
292
401
  def arduino_deploy(sketch_dir: Path, fqbn: str, port: str | None, on_line=None,
293
402
  libraries: list | None = None) -> DeployResult:
294
403
  """Arduino: one verb, always persisted. Compile + upload with OUR arduino-cli, OUR config and OUR
@@ -30,6 +30,44 @@ _DEFAULT_BACKEND = "https://cli.axiometa.io" # axiometa-cli's own service, not
30
30
 
31
31
  _resolved: Path | None = None # memoize within a run so we hit the network at most once
32
32
  _resolved_done = False
33
+ _UPDATE: str | None = None # newer version the service advertised, if any
34
+
35
+
36
+ def _newer(latest: str | None, running: str) -> bool:
37
+ """True when `latest` is a higher release than what is running. Dev installs report 0.0.0+dev,
38
+ which compares as older than anything real — correct: a checkout should still be told."""
39
+ def parts(v):
40
+ out = []
41
+ for chunk in (v or "").split("+")[0].split("."):
42
+ digits = "".join(c for c in chunk if c.isdigit())
43
+ out.append(int(digits) if digits else 0)
44
+ return out
45
+ try:
46
+ return bool(latest) and parts(latest) > parts(running)
47
+ except Exception:
48
+ return False
49
+
50
+
51
+ def ping() -> None:
52
+ """Tell the service this session started. Best effort and non-blocking in spirit: it is
53
+ analytics, so it must never delay or fail a tool call. No key, no network, no problem."""
54
+ try:
55
+ from . import toolchain, __version__
56
+ k = toolchain.key()
57
+ if not k:
58
+ return
59
+ req = urllib.request.Request(
60
+ _backend() + "/api/ping",
61
+ headers={"Authorization": f"Bearer {k}", "X-Axiometa-CLI": __version__})
62
+ urllib.request.urlopen(req, timeout=5).close()
63
+ except Exception:
64
+ pass
65
+
66
+
67
+ def update_available() -> str | None:
68
+ """The newer version seen on the last fetch, or None. Read by the CLI and by list_boards, so the
69
+ agent learns about it from a call it was making anyway."""
70
+ return _UPDATE
33
71
 
34
72
 
35
73
  def _backend() -> str:
@@ -52,6 +90,10 @@ def boards_dir(force: bool = False) -> Path | None:
52
90
  req = urllib.request.Request(_backend() + "/api/knowledge/boards", headers=headers)
53
91
  with urllib.request.urlopen(req, timeout=30) as r:
54
92
  data = json.loads(r.read().decode())
93
+ global _UPDATE
94
+ from . import __version__
95
+ latest = data.get("cli_latest")
96
+ _UPDATE = latest if _newer(latest, __version__) else None
55
97
  version = str(data.get("version") or "")
56
98
  fresh = (_BOARDS.is_dir() and _STAMP.is_file()
57
99
  and _STAMP.read_text(encoding="utf-8").strip() == version)
@@ -11,13 +11,14 @@ the protocol's place for "how to work with this server". That is why nothing has
11
11
  anyone's agent: the hardware rules ship with the tools that need them. The model on the other end
12
12
  writes the code.
13
13
 
14
- Eight tools, which is the whole surface — file editing and shell are the client's own job:
14
+ Nine tools, which is the whole surface — file editing and shell are the client's own job:
15
15
 
16
16
  list_boards what exists, and what language each speaks
17
17
  get_board one board: pins, ports, fqbn, build options, contract, electrical fabric
18
18
  list_modules the catalog for a board: every module, display, onboard radio
19
19
  get_module one module: real pins, i2c address, library name+version, init, coding rules
20
20
  list_devices what is plugged in
21
+ read_serial what the running firmware is actually printing
21
22
  provision install the toolchain (once per machine)
22
23
  compile build for a board, with its build profile and libraries
23
24
  upload build and flash it to the connected board
@@ -66,7 +67,9 @@ Tool contract:
66
67
  libraries get installed, and it is read from no file — pass it. `libraries` adds anything else.
67
68
  - provision installs the toolchain and must run once on a machine before its first compile.
68
69
  - Read the real compiler and flasher output and react to it. Never report success because code was
69
- written."""
70
+ written. A clean compile and a clean upload still say nothing about whether the sketch does what
71
+ was asked — read_serial is how you find that out, and it is the last step of a build, not an
72
+ optional extra."""
70
73
 
71
74
  _BOARD_ARG = {"board_id": {"type": "string", "description": "Axiometa board id, e.g. genesis-one"}}
72
75
  _LANG_ARG = {"language": {"type": "string",
@@ -89,7 +92,8 @@ TOOLS = [
89
92
  "name": "list_boards",
90
93
  "description": "Every Axiometa board, with its silicon, port count and available languages. "
91
94
  "Call this first: an Axiometa board is a custom board package, not a generic "
92
- "dev board, and its pins do not match anything in a stock core.",
95
+ "dev board, and its pins do not match anything in a stock core. Also "
96
+ "reports `provisioned`: when false, run provision before any compile.",
93
97
  "inputSchema": {"type": "object", "properties": {}},
94
98
  },
95
99
  {
@@ -136,6 +140,24 @@ TOOLS = [
136
140
  "core, sketchbook or Python environment the user already has.",
137
141
  "inputSchema": {"type": "object", "properties": {}},
138
142
  },
143
+ {
144
+ "name": "read_serial",
145
+ "description": "Read the board's serial output for a few seconds and return the lines. This "
146
+ "is how you observe what the firmware actually did — a compile that succeeded "
147
+ "and an upload that succeeded still tell you nothing about whether the sketch "
148
+ "works. Opening is passive: it will not reset a running board. Pass reset=true "
149
+ "to restart the sketch and capture its boot output from the first line.",
150
+ "inputSchema": {"type": "object",
151
+ "properties": {
152
+ "port": {"type": "string",
153
+ "description": "serial port; autodetected if omitted"},
154
+ "seconds": {"type": "number",
155
+ "description": "how long to listen (default 5)"},
156
+ "baud": {"type": "number", "description": "default 115200"},
157
+ "reset": {"type": "boolean",
158
+ "description": "restart the sketch first, to catch boot output"},
159
+ }},
160
+ },
139
161
  {
140
162
  "name": "compile",
141
163
  "description": "Compile a directory for an Axiometa board using the private toolchain: the "
@@ -173,12 +195,26 @@ def _firmware_text(board_id: str, language: str, name: str) -> str:
173
195
  return f.read_text(encoding="utf-8") if f and f.is_file() else ""
174
196
 
175
197
 
198
+ def _update() -> str | None:
199
+ from . import knowledge_client
200
+ core.load_boards() # ensures a fetch has happened this run
201
+ return knowledge_client.update_available()
202
+
203
+
176
204
  def t_list_boards() -> dict:
177
- return {"boards": [
205
+ return {
206
+ # Answers "can I build here yet?" without running provision or eating a compile failure.
207
+ "provisioned": toolchain.provisioned(),
208
+ # And "am I current?" — the service advertises the newest release on the knowledge fetch,
209
+ # so an agent finds out here rather than never.
210
+ **({"update_available": _upd, "update_with": "pip install -U axiometa-cli"}
211
+ if (_upd := _update()) else {}),
212
+ "boards": [
178
213
  {"id": b["id"], "name": b.get("name"), "silicon": b.get("silicon"), "mcu": b.get("mcu"),
179
214
  "ports": b.get("port_count"), "logic_voltage": b.get("logic_voltage"),
180
215
  "languages": b.get("languages_available") or [], "default_language": b.get("default_language")}
181
- for b in core.load_boards().values()]}
216
+ for b in core.load_boards().values()],
217
+ }
182
218
 
183
219
 
184
220
  def t_get_board(board_id: str, language: str | None = None) -> dict:
@@ -235,6 +271,15 @@ def t_list_devices() -> dict:
235
271
  "hint": detect.permission_hint() if not devs else None}
236
272
 
237
273
 
274
+ def t_read_serial(port: str | None = None, seconds: float = 5.0, baud: int = 115200,
275
+ reset: bool = False) -> dict:
276
+ p = port or core.pick_port(None)
277
+ if not p:
278
+ return {"ok": False, "error": "no serial device found; plug the board in or pass port"}
279
+ r = core.read_serial(p, baud=int(baud), seconds=float(seconds), reset=bool(reset))
280
+ return {"ok": r.ok, "port": p, "output": r.detail}
281
+
282
+
238
283
  def t_provision() -> dict:
239
284
  """Long-running by nature — a first run downloads a couple of gigabytes. Output is returned whole
240
285
  so the caller can read what actually happened rather than a boolean."""
@@ -302,6 +347,7 @@ _IMPL = {
302
347
  "list_modules": t_list_modules,
303
348
  "get_module": t_get_module,
304
349
  "list_devices": t_list_devices,
350
+ "read_serial": t_read_serial,
305
351
  "provision": t_provision,
306
352
  "compile": t_compile,
307
353
  "upload": t_upload,
@@ -322,6 +368,14 @@ def handle(msg: dict):
322
368
  rid, method, params = msg.get("id"), msg.get("method"), msg.get("params") or {}
323
369
 
324
370
  if method == "initialize":
371
+ # One ping per session, on a background thread so a slow or unreachable service cannot
372
+ # delay the handshake the client is waiting on.
373
+ try:
374
+ import threading
375
+ from . import knowledge_client
376
+ threading.Thread(target=knowledge_client.ping, daemon=True).start()
377
+ except Exception:
378
+ pass
325
379
  return _result(rid, {
326
380
  "protocolVersion": PROTOCOL_VERSION,
327
381
  "capabilities": {"tools": {}},
@@ -217,6 +217,74 @@ def _inject_board_packages() -> bool:
217
217
  return ok
218
218
 
219
219
 
220
+ # The ESP32 platform declares 18 tool dependencies because it supports every ESP32 variant Espressif
221
+ # makes. arduino-cli installs all of them — toolsDependencies are the platform's declaration, not a
222
+ # setting, which is why deleting one and re-running brings it straight back. So the trim happens
223
+ # AFTER the install, once, and it is decided by the BOARDS rather than a list.
224
+ #
225
+ # Measured on a real install: 6.53 GB extracted, of which ~3.4 GB is for silicon that no Axiometa
226
+ # board uses — esp-rv32 alone is 2.3 GB of RISC-V compiler for chips we do not sell.
227
+ _ARCH_COMPILER = {"xtensa": "esp-x32", "riscv": "esp-rv32"}
228
+ _DEBUG_TOOLS = ("xtensa-esp-elf-gdb", "riscv32-esp-elf-gdb", "openocd-esp32")
229
+
230
+
231
+ def _prune_unused_tools(boards: dict) -> None:
232
+ tools_dir = toolchain.data_dir() / "packages" / "esp32" / "tools"
233
+ if not tools_dir.is_dir():
234
+ return
235
+ # ONLY esp32 silicons decide what the esp32 platform needs. An nRF board is ARM, served by a
236
+ # different package with its own tools; letting it vote here would keep the RISC-V compiler
237
+ # alive for a board that will never touch this toolchain.
238
+ have = {s for s in (str(b.get("silicon") or "").lower() for b in boards.values())
239
+ if s.startswith("esp32")}
240
+ if not have:
241
+ return
242
+ # Espressif's Xtensa parts are esp32, esp32s2, esp32s3; everything newer is RISC-V.
243
+ arches = {"xtensa" if s in ("esp32", "esp32s2", "esp32s3") else "riscv" for s in have}
244
+
245
+ freed, dropped = 0, []
246
+ for entry in sorted(p for p in tools_dir.iterdir() if p.is_dir()):
247
+ name = entry.name
248
+ why = None
249
+ if name in _DEBUG_TOOLS:
250
+ why = "debugger"
251
+ elif name.endswith("-libs") and name[:-5] not in have:
252
+ why = "unused silicon"
253
+ else:
254
+ for arch, tool in _ARCH_COMPILER.items():
255
+ if name == tool and arch not in arches:
256
+ why = f"no {arch} board"
257
+ if not why:
258
+ continue
259
+ try:
260
+ freed += sum(f.stat().st_size for f in entry.rglob("*") if f.is_file())
261
+ except OSError:
262
+ pass
263
+ shutil.rmtree(entry, ignore_errors=True)
264
+ dropped.append(f"{name} ({why})")
265
+ if dropped:
266
+ ui.ok(f"trimmed {freed / 1e9:.1f} GB of tools these boards cannot use")
267
+ for d in dropped:
268
+ ui.hint(f" {d}")
269
+
270
+
271
+ def _drop_download_cache() -> None:
272
+ """Delete toolchain/downloads/ once everything is installed.
273
+
274
+ arduino-cli keeps every archive it fetched alongside the copy it extracted, so a finished
275
+ provision carries ~2 GB of zips whose contents are already on disk. Nothing reads them again: a
276
+ re-provision that needs one re-downloads it. A real install measured 8.63 GB with this cache and
277
+ 6.7 GB without, which is the difference between "large" and "why is my disk full".
278
+ """
279
+ dl = toolchain.downloads_dir()
280
+ if not dl.is_dir():
281
+ return
282
+ freed = sum(f.stat().st_size for f in dl.rglob("*") if f.is_file())
283
+ shutil.rmtree(dl, ignore_errors=True)
284
+ if freed:
285
+ ui.ok(f"removed {freed / 1e9:.1f} GB of download archives (already extracted)")
286
+
287
+
220
288
  # ── libraries (into OUR sketchbook) ──────────────────────────────────────────────────────────────
221
289
  def _catalog_libraries(boards: dict) -> list:
222
290
  """Every Arduino library the CATALOG declares, resolved from part metadata across all boards.
@@ -283,6 +351,9 @@ def provision(boards: dict, targets: set | None = None, firmware: str | None = N
283
351
 
284
352
  ok = (_ensure_arduino_cli() and _install_cores() and _inject_board_packages()
285
353
  and _install_libs(boards))
354
+ if ok:
355
+ _prune_unused_tools(boards)
356
+ _drop_download_cache()
286
357
 
287
358
  print()
288
359
  if ok:
@@ -122,6 +122,24 @@ def installed() -> bool:
122
122
  return arduino_cli().is_file()
123
123
 
124
124
 
125
+ def provisioned() -> bool:
126
+ """Whether this machine can actually build: our arduino-cli AND the pinned core with the
127
+ Axiometa board definitions in it. `installed()` alone is not enough — the binary can be there
128
+ with no core behind it, which fails at compile time with something cryptic.
129
+
130
+ Exists so an agent can ASK. Before this the only ways to find out were to run provision (an
131
+ eighteen-minute-looking no-op when it is already done) or to eat a compile failure."""
132
+ if not installed():
133
+ return False
134
+ boards_txt = (data_dir() / "packages" / "esp32" / "hardware" / "esp32"
135
+ / ESP32_CORE_VERSION / "boards.txt")
136
+ try:
137
+ return boards_txt.is_file() and "axiometa_" in boards_txt.read_text(
138
+ encoding="utf-8", errors="replace")
139
+ except OSError:
140
+ return False
141
+
142
+
125
143
  # ── invocation: our binary, our config, our directories ──────────────────────────────────────────
126
144
  def ac_cmd(*args: str) -> list:
127
145
  """An arduino-cli argv pinned to OUR installation. --config-file is not optional: it is what
@@ -64,22 +64,49 @@ def hint(msg: str) -> None:
64
64
 
65
65
 
66
66
  class Spinner:
67
+ """Progress for a long step.
68
+
69
+ On a TTY this is a spinner. Off one — which is every agent, CI job and piped log — it prints a
70
+ line when it starts and then a SIZE HEARTBEAT every 30s, because the alternative is what a real
71
+ headless run hit: `installing esp32:esp32@3.3.6` printed once and nothing followed for twelve
72
+ minutes. The agent could not tell working from hung, and ended up polling the install directory
73
+ itself to answer "how much longer". A number that moves is the whole fix.
74
+
75
+ `watch` is a directory to measure; without one the heartbeat just reports elapsed time."""
67
76
  """Progress for the long installs. Silent when stderr is not a tty, so a captured log stays
68
77
  readable instead of filling with control characters."""
69
78
 
70
- def __init__(self, label: str):
79
+ def __init__(self, label: str, watch=None):
71
80
  self.label = label
81
+ self.watch = watch
72
82
  self._stop = threading.Event()
73
83
  self._thread = None
74
84
 
75
85
  def __enter__(self):
76
- if _color():
77
- self._thread = threading.Thread(target=self._spin, daemon=True)
78
- self._thread.start()
79
- else:
80
- print(f" {self.label} ...")
86
+ self._thread = threading.Thread(
87
+ target=self._spin if _color() else self._heartbeat, daemon=True)
88
+ if not _color():
89
+ print(f" {self.label} ...", flush=True)
90
+ self._thread.start()
81
91
  return self
82
92
 
93
+ def _size(self) -> str:
94
+ if not self.watch:
95
+ return ""
96
+ try:
97
+ n = sum(f.stat().st_size for f in self.watch.rglob("*") if f.is_file())
98
+ return f" — {n / 1e9:.1f} GB on disk"
99
+ except Exception:
100
+ return ""
101
+
102
+ def _heartbeat(self) -> None:
103
+ """Non-TTY: one line every 30s. Slow enough not to spam a log, often enough that nobody has
104
+ to guess whether it died."""
105
+ waited = 0
106
+ while not self._stop.wait(30):
107
+ waited += 30
108
+ print(f" {self.label} — {waited // 60}m{waited % 60:02d}s{self._size()}", flush=True)
109
+
83
110
  def _spin(self) -> None:
84
111
  frames = "|/-\\"
85
112
  i = 0
@@ -1,10 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: axiometa-cli
3
- Version: 2.0.0
3
+ Version: 2.0.2
4
4
  Summary: Axiometa hardware truths and upload methods, for your editor and your AI.
5
5
  Author-email: Axiometa <dr.dumcius@gmail.com>
6
6
  Project-URL: Homepage, https://axiometa.io
7
- Project-URL: Source, https://github.com/axiometa/axiometa-studio/tree/main/axiometa-cli
8
7
  Project-URL: Studio, https://studio.axiometa.io
9
8
  Keywords: hardware,ai,mcp,esp32,nrf52840,arduino,iot,cli,maker,embedded
10
9
  Classifier: Development Status :: 4 - Beta
@@ -59,7 +58,7 @@ Add `--json` to any of them. That is the form an agent wants.
59
58
 
60
59
  | | |
61
60
  |---|---|
62
- | `provision` | install the private toolchain |
61
+ | `provision` | install the private toolchain (~7 GB, 15-20 min, once per machine) |
63
62
  | `devices` | what is plugged in |
64
63
  | `compile <dir> -b <board>` | build with the right target, profile and libraries |
65
64
  | `upload <dir> -b <board>` | build and put it on the board |
@@ -2,6 +2,7 @@ README.md
2
2
  pyproject.toml
3
3
  axiometa_cli/__init__.py
4
4
  axiometa_cli/__main__.py
5
+ axiometa_cli/agnostic.py
5
6
  axiometa_cli/cli.py
6
7
  axiometa_cli/core.py
7
8
  axiometa_cli/detect.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "axiometa-cli"
3
- version = "2.0.0"
3
+ version = "2.0.2"
4
4
  description = "Axiometa hardware truths and upload methods, for your editor and your AI."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.9"
@@ -24,9 +24,11 @@ classifiers = [
24
24
  # from cli.axiometa.io and cached (knowledge_client.py).
25
25
  dependencies = ["pyserial>=3.5"]
26
26
 
27
+ # No Source link: the repository is private, so the URL 404s for anyone who checks — and someone
28
+ # auditing a package that touches USB and downloads a toolchain is exactly who checks. A dead link
29
+ # reads worse than no link.
27
30
  [project.urls]
28
31
  Homepage = "https://axiometa.io"
29
- Source = "https://github.com/axiometa/axiometa-studio/tree/main/axiometa-cli"
30
32
  Studio = "https://studio.axiometa.io"
31
33
 
32
34
  [project.scripts]
File without changes