axiometa-cli 1.0.3__tar.gz → 2.0.0__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 (36) hide show
  1. axiometa_cli-2.0.0/PKG-INFO +147 -0
  2. axiometa_cli-2.0.0/README.md +125 -0
  3. axiometa_cli-2.0.0/axiometa_cli/__init__.py +34 -0
  4. axiometa_cli-2.0.0/axiometa_cli/cli.py +349 -0
  5. axiometa_cli-2.0.0/axiometa_cli/core.py +331 -0
  6. {axiometa_cli-1.0.3 → axiometa_cli-2.0.0}/axiometa_cli/detect.py +103 -106
  7. {axiometa_cli-1.0.3 → axiometa_cli-2.0.0}/axiometa_cli/knowledge_client.py +32 -13
  8. axiometa_cli-2.0.0/axiometa_cli/mcp.py +372 -0
  9. axiometa_cli-2.0.0/axiometa_cli/provision.py +292 -0
  10. axiometa_cli-2.0.0/axiometa_cli/toolchain.py +297 -0
  11. axiometa_cli-2.0.0/axiometa_cli/ui.py +98 -0
  12. axiometa_cli-2.0.0/axiometa_cli.egg-info/PKG-INFO +147 -0
  13. {axiometa_cli-1.0.3 → axiometa_cli-2.0.0}/axiometa_cli.egg-info/SOURCES.txt +2 -6
  14. axiometa_cli-2.0.0/axiometa_cli.egg-info/requires.txt +1 -0
  15. axiometa_cli-2.0.0/pyproject.toml +41 -0
  16. axiometa_cli-1.0.3/PKG-INFO +0 -131
  17. axiometa_cli-1.0.3/README.md +0 -109
  18. axiometa_cli-1.0.3/axiometa_cli/__init__.py +0 -27
  19. axiometa_cli-1.0.3/axiometa_cli/auth.py +0 -147
  20. axiometa_cli-1.0.3/axiometa_cli/chat.py +0 -137
  21. axiometa_cli-1.0.3/axiometa_cli/cli.py +0 -302
  22. axiometa_cli-1.0.3/axiometa_cli/core.py +0 -397
  23. axiometa_cli-1.0.3/axiometa_cli/flash.py +0 -131
  24. axiometa_cli-1.0.3/axiometa_cli/interactive.py +0 -470
  25. axiometa_cli-1.0.3/axiometa_cli/provision.py +0 -284
  26. axiometa_cli-1.0.3/axiometa_cli/serve.py +0 -162
  27. axiometa_cli-1.0.3/axiometa_cli/setup.py +0 -124
  28. axiometa_cli-1.0.3/axiometa_cli/ui.py +0 -229
  29. axiometa_cli-1.0.3/axiometa_cli.egg-info/PKG-INFO +0 -131
  30. axiometa_cli-1.0.3/axiometa_cli.egg-info/requires.txt +0 -4
  31. axiometa_cli-1.0.3/pyproject.toml +0 -41
  32. {axiometa_cli-1.0.3 → axiometa_cli-2.0.0}/axiometa_cli/__main__.py +0 -0
  33. {axiometa_cli-1.0.3 → axiometa_cli-2.0.0}/axiometa_cli.egg-info/dependency_links.txt +0 -0
  34. {axiometa_cli-1.0.3 → axiometa_cli-2.0.0}/axiometa_cli.egg-info/entry_points.txt +0 -0
  35. {axiometa_cli-1.0.3 → axiometa_cli-2.0.0}/axiometa_cli.egg-info/top_level.txt +0 -0
  36. {axiometa_cli-1.0.3 → axiometa_cli-2.0.0}/setup.cfg +0 -0
@@ -0,0 +1,147 @@
1
+ Metadata-Version: 2.4
2
+ Name: axiometa-cli
3
+ Version: 2.0.0
4
+ Summary: Axiometa hardware truths and upload methods, for your editor and your AI.
5
+ Author-email: Axiometa <dr.dumcius@gmail.com>
6
+ Project-URL: Homepage, https://axiometa.io
7
+ Project-URL: Source, https://github.com/axiometa/axiometa-studio/tree/main/axiometa-cli
8
+ Project-URL: Studio, https://studio.axiometa.io
9
+ Keywords: hardware,ai,mcp,esp32,nrf52840,arduino,iot,cli,maker,embedded
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: POSIX :: Linux
15
+ Classifier: Operating System :: MacOS
16
+ Classifier: Operating System :: Microsoft :: Windows
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Topic :: System :: Hardware
19
+ Requires-Python: >=3.9
20
+ Description-Content-Type: text/markdown
21
+ Requires-Dist: pyserial>=3.5
22
+
23
+ # axiometa-cli
24
+
25
+ **Hardware truths and upload methods, for a repo that is not ours.**
26
+
27
+ You are deep in an existing project and adding Axiometa hardware to it. Your editor is your editor,
28
+ your AI is your AI, your files are yours. This provides the two things neither of them can derive:
29
+ what the board actually is, and how to get firmware onto it.
30
+
31
+ It creates nothing in your repo — no manifest, no config file, no convention about where a sketch
32
+ lives. Every command is stateless.
33
+
34
+ ```bash
35
+ pip install axiometa-cli
36
+ axiometa login <key> # from Studio -> account menu -> Headless
37
+ axiometa provision # once per machine
38
+ axiometa board genesis-one # pins, buses, fqbn, silicon gotchas
39
+ axiometa part genesis-one AX22-0005 # one part's pins, library, init, rules
40
+ axiometa upload ./firmware -b genesis-one --parts AX22-0005
41
+ ```
42
+
43
+ ## Hardware truths
44
+
45
+ An Axiometa board is a custom board package. Its pins, its `esp32:esp32:axiometa_*` target and its
46
+ parts match nothing in a stock core, so none of it is inferable — and a wrong pin map compiles
47
+ cleanly and fails silently in hardware.
48
+
49
+ | | |
50
+ |---|---|
51
+ | `boards` | what exists, and what language each speaks |
52
+ | `board <id>` | ports and pin naming, shared vs unique buses, which pins read analog, fqbn, build options, silicon gotchas |
53
+ | `parts <id>` | the catalog: every module, display, onboard radio, host part |
54
+ | `part <id> <part>` | one part's real pins, i2c address, library name+version, locked init, coding rules |
55
+
56
+ Add `--json` to any of them. That is the form an agent wants.
57
+
58
+ ## Upload methods
59
+
60
+ | | |
61
+ |---|---|
62
+ | `provision` | install the private toolchain |
63
+ | `devices` | what is plugged in |
64
+ | `compile <dir> -b <board>` | build with the right target, profile and libraries |
65
+ | `upload <dir> -b <board>` | build and put it on the board |
66
+
67
+ `--parts AX22-0005,AX22-0031` names what the build uses, which decides the libraries. You name them
68
+ because there is no manifest to read them from, and guessing from `#include`s is how a build ends up
69
+ with the wrong library. `--option PartitionScheme=app_max_no_ota` sets a build option.
70
+
71
+ ## MCP
72
+
73
+ ```bash
74
+ axiometa mcp
75
+ ```
76
+
77
+ The same tools over stdio, for clients that speak MCP. Six: `list_boards`, `get_board`, `list_parts`,
78
+ `get_part`, `compile`, `upload`. File editing and shell are the client's own job, so they are not here.
79
+
80
+ It holds no prompt and does no reasoning. Your AI writes the code.
81
+
82
+ ## It cannot collide with your Arduino setup
83
+
84
+ Axiometa boards are a custom board package laid into a **pinned** ESP32 core (3.3.6 — 3.3.7/3.3.8
85
+ carry a NimBLE regression that boot-loops Matter sketches on ESP32-S3). Installing that into a shared
86
+ arduino-cli would rewrite `boards.txt`, replace your core version, and add libraries to your
87
+ sketchbook.
88
+
89
+ So we never touch yours. Everything lives in one prefix:
90
+
91
+ ```
92
+ ~/.axiometa/toolchain/
93
+ bin/arduino-cli our own pinned binary — never the one on your PATH
94
+ arduino-cli.yaml our own config, passed as --config-file on every invocation
95
+ data/ our cores + our patched boards.txt
96
+ user/libraries/ our libraries
97
+ venv/ our mpremote / adafruit-nrfutil
98
+ ```
99
+
100
+ Your `arduino-cli`, your core version, your `arduino-cli.yaml`, your sketchbook and your Python
101
+ environment are never read and never written. Uninstall is `rm -rf ~/.axiometa`.
102
+ `AXIOMETA_HOME` moves the prefix.
103
+
104
+ ## Where the knowledge comes from
105
+
106
+ The CLI ships no board data. It fetches `GET /api/knowledge/boards` from its own service (`server/`,
107
+ deployed separately) and caches it under `~/.cache/axiometa/`, re-fetching only when the content hash
108
+ changes. A board or part added upstream reaches every installed CLI with no release.
109
+
110
+ That fetch needs a **key**. Studio mints one per signed-in account (account menu -> Axiometa
111
+ Headless); `axiometa login <key>` stores it, or set `AXIOMETA_KEY`.
112
+
113
+ The token is HMAC-signed and carries a public `key_id`:
114
+
115
+ ```
116
+ axm_<base64url(user_id:key_id:issued_at)>.<hmac-sha256>
117
+ ```
118
+
119
+ Signature first, database second. The HMAC proves authenticity with no lookup, so a forgery is
120
+ rejected before anything touches the database. The `key_id` is then what makes a single key
121
+ revocable and what usage counts against — the two questions a signature cannot answer. The raw
122
+ token is stored nowhere; it is shown once, at creation.
123
+
124
+ Both deployments need the same `AXIOMETA_HEADLESS_SECRET`. Schema: `backend/sql/headless.sql`.
125
+ Supabase is optional on the service — without it the signature still gates access and usage simply
126
+ is not recorded, because a knowledge fetch must never fail over an analytics table. With no secret
127
+ set at all the service stays open rather than locking everyone out over a missing env var.
128
+
129
+ Board knowledge is authored once, in `backend/knowledge/`. `build_knowledge.py` is the only bridge:
130
+ it copies the hardware payload here and **deletes** anything carrying a `%%TOKEN%%` — those are
131
+ build-time substitutions belonging to a browser product this one is not part of. Deleted, not
132
+ reworded: hardware knowledge is pins, buses, addresses, libraries, init and timing, and a placeholder
133
+ for someone else's build step is none of those. Everything else passes through as written. Run
134
+ `--check` in CI.
135
+
136
+ ## Boards
137
+
138
+ | Board | Silicon | Upload |
139
+ |---|---|---|
140
+ | Genesis One / Genesis Mini | ESP32-S3 | `upload` |
141
+ | XIAO Shield (ESP32-S3) | ESP32-S3 | `upload` |
142
+ | XIAO Shield (nRF52840) | nRF52840 | `upload` (Seeed nRF52 core) |
143
+ | Pi HAT | RP2040 | `upload` (mpremote over the Pi UART) |
144
+
145
+ Verbs are facts of the (board, language), read from `board.json` — not a list in this README.
146
+
147
+ Python 3.9+. `pyserial` is the only dependency; the build tools install into the private prefix.
@@ -0,0 +1,125 @@
1
+ # axiometa-cli
2
+
3
+ **Hardware truths and upload methods, for a repo that is not ours.**
4
+
5
+ You are deep in an existing project and adding Axiometa hardware to it. Your editor is your editor,
6
+ your AI is your AI, your files are yours. This provides the two things neither of them can derive:
7
+ what the board actually is, and how to get firmware onto it.
8
+
9
+ It creates nothing in your repo — no manifest, no config file, no convention about where a sketch
10
+ lives. Every command is stateless.
11
+
12
+ ```bash
13
+ pip install axiometa-cli
14
+ axiometa login <key> # from Studio -> account menu -> Headless
15
+ axiometa provision # once per machine
16
+ axiometa board genesis-one # pins, buses, fqbn, silicon gotchas
17
+ axiometa part genesis-one AX22-0005 # one part's pins, library, init, rules
18
+ axiometa upload ./firmware -b genesis-one --parts AX22-0005
19
+ ```
20
+
21
+ ## Hardware truths
22
+
23
+ An Axiometa board is a custom board package. Its pins, its `esp32:esp32:axiometa_*` target and its
24
+ parts match nothing in a stock core, so none of it is inferable — and a wrong pin map compiles
25
+ cleanly and fails silently in hardware.
26
+
27
+ | | |
28
+ |---|---|
29
+ | `boards` | what exists, and what language each speaks |
30
+ | `board <id>` | ports and pin naming, shared vs unique buses, which pins read analog, fqbn, build options, silicon gotchas |
31
+ | `parts <id>` | the catalog: every module, display, onboard radio, host part |
32
+ | `part <id> <part>` | one part's real pins, i2c address, library name+version, locked init, coding rules |
33
+
34
+ Add `--json` to any of them. That is the form an agent wants.
35
+
36
+ ## Upload methods
37
+
38
+ | | |
39
+ |---|---|
40
+ | `provision` | install the private toolchain |
41
+ | `devices` | what is plugged in |
42
+ | `compile <dir> -b <board>` | build with the right target, profile and libraries |
43
+ | `upload <dir> -b <board>` | build and put it on the board |
44
+
45
+ `--parts AX22-0005,AX22-0031` names what the build uses, which decides the libraries. You name them
46
+ because there is no manifest to read them from, and guessing from `#include`s is how a build ends up
47
+ with the wrong library. `--option PartitionScheme=app_max_no_ota` sets a build option.
48
+
49
+ ## MCP
50
+
51
+ ```bash
52
+ axiometa mcp
53
+ ```
54
+
55
+ The same tools over stdio, for clients that speak MCP. Six: `list_boards`, `get_board`, `list_parts`,
56
+ `get_part`, `compile`, `upload`. File editing and shell are the client's own job, so they are not here.
57
+
58
+ It holds no prompt and does no reasoning. Your AI writes the code.
59
+
60
+ ## It cannot collide with your Arduino setup
61
+
62
+ Axiometa boards are a custom board package laid into a **pinned** ESP32 core (3.3.6 — 3.3.7/3.3.8
63
+ carry a NimBLE regression that boot-loops Matter sketches on ESP32-S3). Installing that into a shared
64
+ arduino-cli would rewrite `boards.txt`, replace your core version, and add libraries to your
65
+ sketchbook.
66
+
67
+ So we never touch yours. Everything lives in one prefix:
68
+
69
+ ```
70
+ ~/.axiometa/toolchain/
71
+ bin/arduino-cli our own pinned binary — never the one on your PATH
72
+ arduino-cli.yaml our own config, passed as --config-file on every invocation
73
+ data/ our cores + our patched boards.txt
74
+ user/libraries/ our libraries
75
+ venv/ our mpremote / adafruit-nrfutil
76
+ ```
77
+
78
+ Your `arduino-cli`, your core version, your `arduino-cli.yaml`, your sketchbook and your Python
79
+ environment are never read and never written. Uninstall is `rm -rf ~/.axiometa`.
80
+ `AXIOMETA_HOME` moves the prefix.
81
+
82
+ ## Where the knowledge comes from
83
+
84
+ The CLI ships no board data. It fetches `GET /api/knowledge/boards` from its own service (`server/`,
85
+ deployed separately) and caches it under `~/.cache/axiometa/`, re-fetching only when the content hash
86
+ changes. A board or part added upstream reaches every installed CLI with no release.
87
+
88
+ That fetch needs a **key**. Studio mints one per signed-in account (account menu -> Axiometa
89
+ Headless); `axiometa login <key>` stores it, or set `AXIOMETA_KEY`.
90
+
91
+ The token is HMAC-signed and carries a public `key_id`:
92
+
93
+ ```
94
+ axm_<base64url(user_id:key_id:issued_at)>.<hmac-sha256>
95
+ ```
96
+
97
+ Signature first, database second. The HMAC proves authenticity with no lookup, so a forgery is
98
+ rejected before anything touches the database. The `key_id` is then what makes a single key
99
+ revocable and what usage counts against — the two questions a signature cannot answer. The raw
100
+ token is stored nowhere; it is shown once, at creation.
101
+
102
+ Both deployments need the same `AXIOMETA_HEADLESS_SECRET`. Schema: `backend/sql/headless.sql`.
103
+ Supabase is optional on the service — without it the signature still gates access and usage simply
104
+ is not recorded, because a knowledge fetch must never fail over an analytics table. With no secret
105
+ set at all the service stays open rather than locking everyone out over a missing env var.
106
+
107
+ Board knowledge is authored once, in `backend/knowledge/`. `build_knowledge.py` is the only bridge:
108
+ it copies the hardware payload here and **deletes** anything carrying a `%%TOKEN%%` — those are
109
+ build-time substitutions belonging to a browser product this one is not part of. Deleted, not
110
+ reworded: hardware knowledge is pins, buses, addresses, libraries, init and timing, and a placeholder
111
+ for someone else's build step is none of those. Everything else passes through as written. Run
112
+ `--check` in CI.
113
+
114
+ ## Boards
115
+
116
+ | Board | Silicon | Upload |
117
+ |---|---|---|
118
+ | Genesis One / Genesis Mini | ESP32-S3 | `upload` |
119
+ | XIAO Shield (ESP32-S3) | ESP32-S3 | `upload` |
120
+ | XIAO Shield (nRF52840) | nRF52840 | `upload` (Seeed nRF52 core) |
121
+ | Pi HAT | RP2040 | `upload` (mpremote over the Pi UART) |
122
+
123
+ Verbs are facts of the (board, language), read from `board.json` — not a list in this README.
124
+
125
+ Python 3.9+. `pyserial` is the only dependency; the build tools install into the private prefix.
@@ -0,0 +1,34 @@
1
+ """axiometa-cli — Axiometa hardware from any terminal, editor or agent.
2
+
3
+ ONE job: the half of the work that can only happen on the machine the board is plugged into.
4
+
5
+ board facts · provision a toolchain · compile · flash · read serial
6
+
7
+ It does NOT generate code. Whatever is driving it — an editor, Claude Code, a build script, a person —
8
+ already does that better than a prompt box in a terminal, and the thing they cannot do is reach a
9
+ board. So the CLI answers "what is this board, exactly" (ports, pins, fqbn, build options, transports)
10
+ and then turns a sketch into a flashed device.
11
+
12
+ Two properties make that usable inside someone else's project:
13
+
14
+ - NO BUNDLED BOARD DATA. Board facts come from the backend (GET /api/knowledge/boards) and are
15
+ cached locally, so a board or module added to the knowledge tree is available here immediately
16
+ with no CLI release. One source of truth (knowledge_client.py).
17
+
18
+ - A FULLY PRIVATE TOOLCHAIN. Axiometa boards are a custom board package laid into a pinned ESP32
19
+ core; installing that into a shared arduino-cli would rewrite files other projects depend on. So
20
+ we ship our own arduino-cli, cores and libraries under ~/.axiometa and never read or touch the
21
+ user's own (toolchain.py).
22
+
23
+ A board is shipped here only if it declares an fqbn — something this toolchain can actually build
24
+ and upload. Serving a board it cannot serve would be a promise the tool breaks at the last step.
25
+ """
26
+ # Read the real installed version from package metadata so `--version` never goes stale.
27
+ try:
28
+ from importlib.metadata import version as _pkg_version, PackageNotFoundError
29
+ try:
30
+ __version__ = _pkg_version("axiometa-cli")
31
+ except PackageNotFoundError:
32
+ __version__ = "0.0.0+dev"
33
+ except Exception:
34
+ __version__ = "0.0.0+dev"
@@ -0,0 +1,349 @@
1
+ """axiometa — hardware truth, and putting firmware on the board.
2
+
3
+ Two jobs, for someone else's AI working in someone else's repo:
4
+
5
+ FETCH HARDWARE TRUTH what a board and its parts actually are. An Axiometa board is a custom board
6
+ package — its pins, its esp32:esp32:axiometa_* target and its parts match
7
+ nothing in a stock core, so none of it is inferable, and a wrong pin map
8
+ compiles cleanly and fails silently in hardware.
9
+
10
+ axiometa boards
11
+ axiometa board genesis-one
12
+ axiometa parts genesis-one
13
+ axiometa part genesis-one AX22-0005
14
+
15
+ UPLOAD TO THE BOARD a toolchain that can actually build for these boards, with the libraries
16
+ each part declares, and put the result on the device.
17
+
18
+ axiometa login <key>
19
+ axiometa provision
20
+ axiometa devices
21
+ axiometa compile ./firmware -b genesis-one --parts AX22-0005
22
+ axiometa upload ./firmware -b genesis-one --parts AX22-0005
23
+
24
+ axiometa mcp the same, for clients that speak MCP
25
+
26
+ Stateless: every command takes what it needs as arguments and writes only inside ~/.axiometa. There
27
+ is no manifest, no project file and no convention about where a sketch lives — the repo belongs to
28
+ whoever is working in it. Whoever is driving writes the code. This does not.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import argparse
33
+ import json
34
+ import sys
35
+ from pathlib import Path
36
+
37
+ from . import __version__, core, detect, toolchain, ui
38
+
39
+
40
+ def _board(board_id: str) -> dict:
41
+ boards = core.load_boards()
42
+ if not boards:
43
+ ui.err("no board knowledge available. First run fetches it; check the network, or point "
44
+ "AXIOMETA_KNOWLEDGE at a knowledge/boards folder.")
45
+ sys.exit(1)
46
+ b = boards.get(board_id)
47
+ if not b:
48
+ ui.err(f"unknown board {board_id!r}. Known: {', '.join(boards)}")
49
+ sys.exit(1)
50
+ return b
51
+
52
+
53
+ def _sub(board: dict, language: str | None):
54
+ return core.resolve_substrate(board, language)
55
+
56
+
57
+ def _parts_arg(value: str | None) -> list:
58
+ """Catalog ids the build uses, comma-separated. The CALLER names them — there is no manifest to
59
+ read them out of, and guessing from #includes is how a build ends up with the wrong library."""
60
+ return [p.strip() for p in (value or "").split(",") if p.strip()]
61
+
62
+
63
+ def _options_arg(values: list | None) -> dict:
64
+ """--option PartitionScheme=app_max_no_ota, repeatable. Roles resolve against the board's own
65
+ build_options; an unknown value passes through and surfaces as a normal compile error."""
66
+ out = {}
67
+ for v in values or []:
68
+ if "=" not in v:
69
+ ui.err(f"--option expects KEY=VALUE, got {v!r}")
70
+ sys.exit(2)
71
+ k, val = v.split("=", 1)
72
+ out[k.strip()] = val.strip()
73
+ return out
74
+
75
+
76
+ # ── hardware truths ──────────────────────────────────────────────────────────────────────────────
77
+ def cmd_boards(args):
78
+ boards = core.load_boards()
79
+ if args.json:
80
+ print(json.dumps(list(boards.values()), indent=2))
81
+ return
82
+ for b in boards.values():
83
+ langs = ", ".join(b.get("languages_available") or [])
84
+ print(f" {b['id']:<22} {b.get('name',''):<24} {b.get('silicon',''):<10} "
85
+ f"{b.get('port_count','?')} ports [{langs}]")
86
+
87
+
88
+ def cmd_board(args):
89
+ b = _board(args.board_id)
90
+ lang = args.language if args.language in (b.get("languages_available") or []) else b.get("default_language")
91
+ sub = _sub(b, lang)
92
+ root = core.knowledge_root()
93
+ fw = (root / b["id"] / lang / "firmware") if root and lang else None
94
+
95
+ def _read(name):
96
+ f = fw / name if fw else None
97
+ return f.read_text(encoding="utf-8") if f and f.is_file() else ""
98
+
99
+ if args.json:
100
+ print(json.dumps({
101
+ "board": b, "language": lang,
102
+ "device_program": sub.get("device_program"),
103
+ "fqbn": sub.get("fqbn"),
104
+ "fqbn_with_default_profile": core.fqbn_with_options(sub) or None,
105
+ "build_options": sub.get("build_options") or {},
106
+ "deploy_verbs": core.verbs_for(sub),
107
+ "board_specs": _read("board-specs.md"),
108
+ "contract": _read("contract.md"),
109
+ }, indent=2))
110
+ return
111
+ print(f"{b.get('name')} ({b['id']})")
112
+ print(f" silicon {b.get('silicon')} mcu {b.get('mcu')} ports {b.get('port_count')} "
113
+ f"logic {b.get('logic_voltage')}")
114
+ print(f" language {lang} device program {sub.get('device_program')}")
115
+ if sub.get("fqbn"):
116
+ print(f" fqbn {core.fqbn_with_options(sub)}")
117
+ for k, v in (sub.get("build_options") or {}).items():
118
+ roles = (v or {}).get("roles") or {}
119
+ print(f" {k:<16} default={v.get('default')} roles: {', '.join(roles)}")
120
+ print(f" verbs {', '.join(core.verbs_for(sub))}")
121
+ specs = _read("board-specs.md")
122
+ if specs:
123
+ print()
124
+ print(specs)
125
+
126
+
127
+ def cmd_parts(args):
128
+ b = _board(args.board_id)
129
+ lang = args.language if args.language in (b.get("languages_available") or []) else b.get("default_language")
130
+ parts = core.load_parts(b["id"], lang)
131
+ if args.json:
132
+ print(json.dumps(parts, indent=2) if args.full else
133
+ json.dumps([{"id": p["id"], "name": p.get("name"), "kind": p.get("kind"),
134
+ "category": p.get("category")} for p in parts.values()], indent=2))
135
+ return
136
+ for p in parts.values():
137
+ print(f" {p['id']:<12} {p.get('kind',''):<9} {p.get('name','')}")
138
+
139
+
140
+ def cmd_part(args):
141
+ b = _board(args.board_id)
142
+ lang = args.language if args.language in (b.get("languages_available") or []) else b.get("default_language")
143
+ parts = core.load_parts(b["id"], lang)
144
+ p = parts.get(args.part_id)
145
+ if not p:
146
+ ui.err(f"unknown part {args.part_id!r} for {b['id']}/{lang}. List them: axiometa parts {b['id']}")
147
+ sys.exit(1)
148
+ print(json.dumps(p, indent=2))
149
+
150
+
151
+ def cmd_devices(args):
152
+ devs = detect.detect()
153
+ if args.json:
154
+ from dataclasses import asdict
155
+ print(json.dumps([asdict(d) for d in devs], indent=2, default=str))
156
+ return
157
+ if not devs:
158
+ ui.warn("no serial devices found.")
159
+ hint = detect.permission_hint()
160
+ if hint:
161
+ ui.hint(hint)
162
+ return
163
+ for d in devs:
164
+ print(" " + d.label())
165
+
166
+
167
+ # ── upload methods ───────────────────────────────────────────────────────────────────────────────
168
+ def cmd_provision(args):
169
+ from . import provision
170
+ boards = core.load_boards()
171
+ if not boards:
172
+ ui.err("no board knowledge available — cannot provision without knowing the boards.")
173
+ sys.exit(1)
174
+ sys.exit(0 if provision.provision(boards) else 1)
175
+
176
+
177
+ def _build(args, verb: str):
178
+ b = _board(args.board)
179
+ lang = args.language if args.language in (b.get("languages_available") or []) else b.get("default_language")
180
+ sub = _sub(b, lang)
181
+ src = Path(args.source).expanduser().resolve()
182
+ if not src.is_dir():
183
+ ui.err(f"{src} is not a directory (point at the folder holding the device program).")
184
+ sys.exit(2)
185
+ # What a part DECLARES, plus whatever the caller wants on top. The catalog is a starting point,
186
+ # not a fence: a build is free to use any library from the Arduino Library Manager, and --lib is
187
+ # how it says so. Order matters only in that declared parts come first.
188
+ libs = core.libraries_for(b["id"], lang, _parts_arg(args.parts))
189
+ libs += [l for l in (args.lib or []) if l not in libs]
190
+ verbs = core.verbs_for(sub)
191
+ if verb == "upload":
192
+ verb = "deploy" if "deploy" in verbs else ("install" if "install" in verbs else "")
193
+ if not verb:
194
+ ui.err(f"{b['id']} declares no upload verb (has {', '.join(verbs)}).")
195
+ sys.exit(2)
196
+ return b, sub, src, libs, verb
197
+
198
+
199
+ def cmd_compile(args):
200
+ b, sub, src, libs, _ = _build(args, "compile")
201
+ if not sub.get("fqbn"):
202
+ ui.err(f"{b['id']} has no compile step for {sub.get('substrate')} — deploy copies the "
203
+ f"program to the device instead.")
204
+ sys.exit(2)
205
+ ext = sub.get("ext") or Path(sub["device_program"]).suffix
206
+ entry = core.resolve_entry(src, sub["device_program"], ext)
207
+ if not entry:
208
+ ui.err(f"no {sub['device_program']} (or a single {ext} file) in {src}.")
209
+ sys.exit(2)
210
+ # arduino-cli needs <folder>/<folder>.ino; stage so any filename in the user's repo works.
211
+ src = core.stage(src, entry, ext)
212
+ fqbn = core.fqbn_with_options(sub, _options_arg(args.option))
213
+ if args.print_command:
214
+ # The escape hatch. Everything this tool knows that you could not derive is in this one
215
+ # line: our binary, our config, the resolved target with its build profile. Take it, change
216
+ # it, script it, run it from your own Makefile — nothing here needs to stay in the loop.
217
+ print(" ".join(toolchain.ac_cmd("compile", "--fqbn", fqbn, str(src))))
218
+ return
219
+ ui.step(f"compile {src.name} → {b.get('name')} ({fqbn})")
220
+ failed = toolchain.install_libraries(list(toolchain.REQUIRED_LIBRARIES) + libs,
221
+ on_line=lambda l: print(ui.c(" " + l, "grey")))
222
+ if failed:
223
+ ui.warn("could not install: " + ", ".join(failed))
224
+ 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)
227
+ sys.exit(0 if r.ok else 1)
228
+
229
+
230
+ def cmd_upload(args):
231
+ b, sub, src, libs, verb = _build(args, "upload")
232
+ port = core.serial_port(sub, args.port)
233
+ ui.step(f"{verb} {src.name} → {b.get('name')} on {port or '(autodetect)'}")
234
+ res = core.deploy(b, sub, src, verb, port, declared=_options_arg(args.option), libraries=libs,
235
+ on_line=lambda l: print(ui.c(" " + l, "grey")))
236
+ (ui.ok if res.ok else ui.err)(res.detail.splitlines()[0] if res.detail else "done")
237
+ sys.exit(0 if res.ok else 1)
238
+
239
+
240
+ def cmd_lib(args):
241
+ """Install or list libraries in the toolchain. Any Arduino Library Manager name works — the
242
+ catalog decides what a PART needs, never what a build is allowed to use."""
243
+ if not args.spec:
244
+ r = core._run(toolchain.ac_cmd("lib", "list"), env=toolchain.env(), timeout=60)
245
+ print(r.detail)
246
+ return
247
+ failed = toolchain.install_libraries(args.spec, on_line=lambda l: print(ui.c(" " + l, "grey")))
248
+ if failed:
249
+ ui.err("could not install: " + ", ".join(failed))
250
+ sys.exit(1)
251
+ ui.ok(f"installed {len(args.spec)}")
252
+
253
+
254
+ def cmd_login(args):
255
+ """Store the headless key. Get it from studio.axiometa.io -> account menu -> Axiometa Headless."""
256
+ if not args.key:
257
+ k = toolchain.key()
258
+ if k:
259
+ print(f"{k[:12]}... (stored at {toolchain.key_file()})")
260
+ else:
261
+ ui.err("no key stored. Get one at studio.axiometa.io -> account menu -> "
262
+ "Axiometa Headless, then: axiometa login <key>")
263
+ sys.exit(1)
264
+ return
265
+ if not args.key.startswith("axm_"):
266
+ ui.err("that does not look like a headless key (they start with axm_).")
267
+ sys.exit(2)
268
+ ui.ok(f"key saved to {toolchain.save_key(args.key)}")
269
+
270
+
271
+ def cmd_mcp(args):
272
+ from . import mcp
273
+ sys.exit(mcp.serve())
274
+
275
+
276
+ # ── parser ───────────────────────────────────────────────────────────────────────────────────────
277
+ def build_parser() -> argparse.ArgumentParser:
278
+ p = argparse.ArgumentParser(prog="axiometa",
279
+ description="Axiometa hardware truths and upload methods.")
280
+ p.add_argument("--version", action="version", version=f"axiometa-cli {__version__}")
281
+ sub = p.add_subparsers(dest="cmd", required=True)
282
+
283
+ def lang(sp):
284
+ sp.add_argument("-l", "--language", help="runtime; defaults to the board's own default")
285
+ return sp
286
+
287
+ b = sub.add_parser("boards", help="every Axiometa board")
288
+ b.add_argument("--json", action="store_true"); b.set_defaults(func=cmd_boards)
289
+
290
+ bd = lang(sub.add_parser("board", help="one board: ports, pins, fqbn, build options, specs"))
291
+ bd.add_argument("board_id"); bd.add_argument("--json", action="store_true")
292
+ bd.set_defaults(func=cmd_board)
293
+
294
+ ps = lang(sub.add_parser("parts", help="the catalog for a board"))
295
+ ps.add_argument("board_id"); ps.add_argument("--json", action="store_true")
296
+ ps.add_argument("--full", action="store_true", help="every part's full metadata, not just identity")
297
+ ps.set_defaults(func=cmd_parts)
298
+
299
+ pt = lang(sub.add_parser("part", help="one part: pins, library, init, coding rules"))
300
+ pt.add_argument("board_id"); pt.add_argument("part_id"); pt.set_defaults(func=cmd_part)
301
+
302
+ dv = sub.add_parser("devices", help="what is plugged in")
303
+ dv.add_argument("--json", action="store_true"); dv.set_defaults(func=cmd_devices)
304
+
305
+ sub.add_parser("provision", help="install the private toolchain (once per machine)") .set_defaults(func=cmd_provision)
306
+
307
+ def build_args(sp):
308
+ sp.add_argument("source", help="directory holding the device program")
309
+ sp.add_argument("-b", "--board", required=True)
310
+ sp.add_argument("--parts", help="catalog ids this build uses, comma-separated — decides "
311
+ "which libraries get installed")
312
+ sp.add_argument("--lib", action="append",
313
+ help="any other library to install (repeatable), e.g. --lib ArduinoJson@7.0.4")
314
+ sp.add_argument("--option", action="append",
315
+ help="build option, KEY=VALUE (repeatable), e.g. PartitionScheme=app_max_no_ota")
316
+ return lang(sp)
317
+
318
+ cp = build_args(sub.add_parser("compile", help="build for a board with its profile and libraries"))
319
+ cp.add_argument("--print-command", action="store_true",
320
+ help="print the exact toolchain command instead of running it")
321
+ cp.set_defaults(func=cmd_compile)
322
+
323
+ lb = sub.add_parser("lib", help="install or list libraries in the toolchain")
324
+ lb.add_argument("spec", nargs="*", help="Library Manager names, e.g. 'ArduinoJson@7.0.4'")
325
+ lb.set_defaults(func=cmd_lib)
326
+
327
+ up = build_args(sub.add_parser("upload", help="build and put it on the connected board"))
328
+ up.add_argument("-p", "--port", help="serial port; autodetected if omitted")
329
+ up.set_defaults(func=cmd_upload)
330
+
331
+ lg = sub.add_parser("login", help="store your headless key (from Studio -> Axiometa Headless)")
332
+ lg.add_argument("key", nargs="?", help="the axm_... key; omit to show the stored one")
333
+ lg.set_defaults(func=cmd_login)
334
+
335
+ sub.add_parser("mcp", help="the same tools over MCP (stdio)").set_defaults(func=cmd_mcp)
336
+ return p
337
+
338
+
339
+ def main(argv=None):
340
+ args = build_parser().parse_args(argv)
341
+ try:
342
+ args.func(args)
343
+ except KeyboardInterrupt:
344
+ print()
345
+ sys.exit(130)
346
+
347
+
348
+ if __name__ == "__main__":
349
+ main()