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.
- {axiometa_cli-2.0.0 → axiometa_cli-2.0.2}/PKG-INFO +2 -3
- {axiometa_cli-2.0.0 → axiometa_cli-2.0.2}/README.md +1 -1
- axiometa_cli-2.0.2/axiometa_cli/agnostic.py +162 -0
- {axiometa_cli-2.0.0 → axiometa_cli-2.0.2}/axiometa_cli/cli.py +27 -3
- {axiometa_cli-2.0.0 → axiometa_cli-2.0.2}/axiometa_cli/core.py +109 -0
- {axiometa_cli-2.0.0 → axiometa_cli-2.0.2}/axiometa_cli/knowledge_client.py +42 -0
- {axiometa_cli-2.0.0 → axiometa_cli-2.0.2}/axiometa_cli/mcp.py +59 -5
- {axiometa_cli-2.0.0 → axiometa_cli-2.0.2}/axiometa_cli/provision.py +71 -0
- {axiometa_cli-2.0.0 → axiometa_cli-2.0.2}/axiometa_cli/toolchain.py +18 -0
- {axiometa_cli-2.0.0 → axiometa_cli-2.0.2}/axiometa_cli/ui.py +33 -6
- {axiometa_cli-2.0.0 → axiometa_cli-2.0.2}/axiometa_cli.egg-info/PKG-INFO +2 -3
- {axiometa_cli-2.0.0 → axiometa_cli-2.0.2}/axiometa_cli.egg-info/SOURCES.txt +1 -0
- {axiometa_cli-2.0.0 → axiometa_cli-2.0.2}/pyproject.toml +4 -2
- {axiometa_cli-2.0.0 → axiometa_cli-2.0.2}/axiometa_cli/__init__.py +0 -0
- {axiometa_cli-2.0.0 → axiometa_cli-2.0.2}/axiometa_cli/__main__.py +0 -0
- {axiometa_cli-2.0.0 → axiometa_cli-2.0.2}/axiometa_cli/detect.py +0 -0
- {axiometa_cli-2.0.0 → axiometa_cli-2.0.2}/axiometa_cli.egg-info/dependency_links.txt +0 -0
- {axiometa_cli-2.0.0 → axiometa_cli-2.0.2}/axiometa_cli.egg-info/entry_points.txt +0 -0
- {axiometa_cli-2.0.0 → axiometa_cli-2.0.2}/axiometa_cli.egg-info/requires.txt +0 -0
- {axiometa_cli-2.0.0 → axiometa_cli-2.0.2}/axiometa_cli.egg-info/top_level.txt +0 -0
- {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.
|
|
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")),
|
|
226
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
self.
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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.
|
|
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 |
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "axiometa-cli"
|
|
3
|
-
version = "2.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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|