axiometa-cli 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,19 @@
1
+ """axiometa-cli — the terminal client for Axiometa Studio.
2
+
3
+ The CLI is a thin client of the same backend Studio's browser talks to: codegen stays in the
4
+ cloud (Claude + prompt_builder + catalog), and only the DEVICE half (build/deploy + reading the
5
+ serial channel) happens locally, on the machine the board is plugged into (your laptop, or a Pi
6
+ over SSH). That mirrors Studio exactly: Studio's backend generates and the browser flashes; the
7
+ CLI's backend generates and the terminal flashes.
8
+
9
+ Three ideas the CLI implements, all grounded in the backend registries:
10
+ - The device program is a property of the (board, substrate): sketch.ino on Arduino, main.py on
11
+ MicroPython. The DEPLOY VERBS follow from board.json: an fqbn board has one verb (`deploy` =
12
+ compile+flash); an mpremote board has two (`run` = ephemeral RAM test, `install` = persist).
13
+ - The CLIENT ENVIRONMENT (where surfaces render, whether host code runs) is sent to the backend
14
+ as a capability profile {surface, host_exec} so Axie builds only reachable surfaces. The CLI
15
+ never enumerates peripherals — what is connected is discovered, never predicted.
16
+ - The serial CHANNEL is one contract; only its TRANSPORT changes: raw stdout in a terminal, or a
17
+ 127.0.0.1 + token-gated localhost page (opened locally, or reached over `ssh -L`) for a surface.
18
+ """
19
+ __version__ = "1.0.0"
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
axiometa_cli/auth.py ADDED
@@ -0,0 +1,116 @@
1
+ """Auth: where the CLI keeps your token, and how `axiometa login` gets one.
2
+
3
+ The token sent to the backend is a Supabase JWT, the same auth Studio uses. Resolution order:
4
+ AXIOMETA_TOKEN env var (scripting / CI) > the token stored by `axiometa login`. So you sign in once
5
+ and it persists, instead of exporting an env var every session.
6
+
7
+ `axiometa login` tries a device-code browser flow against the backend (ask for a code, you approve it
8
+ in a signed-in browser, the CLI polls until granted). If the backend does not expose that endpoint
9
+ yet, it falls back to pasting a token. Either way the token is stored at ~/.config/axiometa/token
10
+ (0600). Guest mode (no token) still works, rate-limited.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import time
17
+ import urllib.error
18
+ import urllib.request
19
+ from pathlib import Path
20
+
21
+
22
+ def _config_dir() -> Path:
23
+ base = os.environ.get("XDG_CONFIG_HOME")
24
+ if base:
25
+ return Path(base) / "axiometa"
26
+ if os.name == "nt":
27
+ return Path(os.environ.get("APPDATA", Path.home())) / "axiometa"
28
+ return Path.home() / ".config" / "axiometa"
29
+
30
+
31
+ _TOKEN_FILE = _config_dir() / "token"
32
+
33
+
34
+ # ── storage ──
35
+ def stored_token() -> str | None:
36
+ try:
37
+ t = _TOKEN_FILE.read_text(encoding="utf-8").strip()
38
+ return t or None
39
+ except Exception:
40
+ return None
41
+
42
+
43
+ def get_token() -> str | None:
44
+ """The token to authenticate with: env var first (scripting), then the stored login."""
45
+ return os.environ.get("AXIOMETA_TOKEN") or stored_token()
46
+
47
+
48
+ def save_token(token: str) -> Path:
49
+ _TOKEN_FILE.parent.mkdir(parents=True, exist_ok=True)
50
+ _TOKEN_FILE.write_text(token.strip() + "\n", encoding="utf-8")
51
+ try:
52
+ os.chmod(_TOKEN_FILE, 0o600)
53
+ except Exception:
54
+ pass
55
+ return _TOKEN_FILE
56
+
57
+
58
+ def clear_token() -> bool:
59
+ try:
60
+ _TOKEN_FILE.unlink()
61
+ return True
62
+ except FileNotFoundError:
63
+ return False
64
+ except Exception:
65
+ return False
66
+
67
+
68
+ def token_path() -> Path:
69
+ return _TOKEN_FILE
70
+
71
+
72
+ # ── device-code flow (CLI side; needs the backend's /api/cli/login endpoints) ──
73
+ def _post(url: str, payload: dict, timeout: int = 15) -> dict:
74
+ req = urllib.request.Request(url, data=json.dumps(payload).encode(),
75
+ headers={"Content-Type": "application/json"})
76
+ with urllib.request.urlopen(req, timeout=timeout) as r:
77
+ return json.loads(r.read().decode() or "{}")
78
+
79
+
80
+ def device_login_start(backend: str) -> dict | None:
81
+ """Begin a device-code login. Returns the start payload (user_code, verification_url, device_code,
82
+ interval, expires_in), or None if the backend has no device-code endpoint yet."""
83
+ try:
84
+ return _post(backend.rstrip("/") + "/api/cli/login/start", {})
85
+ except urllib.error.HTTPError as e:
86
+ if e.code in (404, 405, 501):
87
+ return None
88
+ raise
89
+ except Exception:
90
+ return None
91
+
92
+
93
+ def device_login_poll(backend: str, start: dict, on_wait=None) -> str | None:
94
+ """Poll until the user approves (returns the token) or it expires/denies (returns None)."""
95
+ backend = backend.rstrip("/")
96
+ device_code = start.get("device_code")
97
+ interval = max(2, int(start.get("interval", 3)))
98
+ deadline = time.time() + int(start.get("expires_in", 300))
99
+ while time.time() < deadline:
100
+ time.sleep(interval)
101
+ if on_wait:
102
+ on_wait()
103
+ try:
104
+ r = _post(backend + "/api/cli/login/poll", {"device_code": device_code})
105
+ except urllib.error.HTTPError as e:
106
+ if e.code in (400, 428): # authorization_pending — keep waiting
107
+ continue
108
+ return None
109
+ except Exception:
110
+ continue
111
+ if r.get("token"):
112
+ save_token(r["token"])
113
+ return r["token"]
114
+ if r.get("error") in ("expired_token", "access_denied", "expired"):
115
+ return None
116
+ return None
axiometa_cli/chat.py ADDED
@@ -0,0 +1,137 @@
1
+ """Backend chat client — the SSE stream from POST /api/ai-chat.
2
+
3
+ Codegen lives in the cloud (Claude + the catalog + your auth/limits): the CLI sends the conversation
4
+ + the project VFS + the board + the client environment, and the backend streams back a Server-Sent
5
+ Events feed. This module speaks that exact contract (the event vocabulary the backend emits) and keeps
6
+ the multi-turn state (message history + the project files) so a follow-up like 'make it blink faster'
7
+ sees the current build.
8
+
9
+ SSE event types (from backend/app/main.py): round_start, tool_call, text, gen, tool_done,
10
+ file_write, file_edit, file_delete, context_trace, done, error. The CLI renders `text` live, shows
11
+ `tool_call` as activity, applies the file_* events to the VFS, and closes on `done` (whose `message`
12
+ is the full assistant turn, plus relay_url/device_key when a relay build was produced).
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import urllib.request
18
+ from dataclasses import dataclass, field
19
+
20
+ MANIFEST = "axiometa.json" # canonical; backend also reads legacy genesis.json
21
+
22
+
23
+ @dataclass
24
+ class TurnResult:
25
+ text: str = "" # the assistant's full reply this turn
26
+ files_changed: list = field(default_factory=list) # paths written/edited this turn
27
+ files_deleted: list = field(default_factory=list)
28
+ error: str | None = None
29
+ relay_url: str | None = None
30
+ device_key: str | None = None
31
+
32
+
33
+ class ChatSession:
34
+ """One project conversation. Holds the VFS + history so each turn is stateful."""
35
+
36
+ def __init__(self, backend: str, token: str | None, board_id: str,
37
+ capability_profile: dict, files: dict | None = None,
38
+ file_meta: dict | None = None, connected_integrations: list | None = None):
39
+ self.backend = backend.rstrip("/")
40
+ self.token = token
41
+ self.board_id = board_id
42
+ self.capability_profile = capability_profile
43
+ self.files: dict = dict(files or {})
44
+ self.file_meta: dict = dict(file_meta or {})
45
+ self.connected_integrations = connected_integrations or []
46
+ self.messages: list = []
47
+
48
+ # ── transport ──
49
+ def _request(self, payload: dict):
50
+ headers = {"Content-Type": "application/json", "Accept": "text/event-stream"}
51
+ if self.token:
52
+ headers["Authorization"] = f"Bearer {self.token}"
53
+ req = urllib.request.Request(self.backend + "/api/ai-chat",
54
+ data=json.dumps(payload).encode(), headers=headers)
55
+ return urllib.request.urlopen(req, timeout=600)
56
+
57
+ def _payload(self, active_module_ids: list | None) -> dict:
58
+ return {
59
+ "messages": self.messages,
60
+ "board_id": self.board_id,
61
+ "files": self.files,
62
+ "file_meta": self.file_meta,
63
+ "capability_profile": self.capability_profile,
64
+ "connected_integrations": self.connected_integrations,
65
+ "active_module_ids": active_module_ids,
66
+ }
67
+
68
+ # ── one turn ──
69
+ def send(self, user_text: str, on_event=None, active_module_ids: list | None = None) -> TurnResult:
70
+ """Append the user message, stream the reply, apply file events, return a TurnResult.
71
+
72
+ on_event(evt: dict) is called for every SSE event so the caller can render live (text deltas,
73
+ tool activity, file writes). The VFS and message history are updated in place.
74
+ """
75
+ self.messages.append({"role": "user", "content": user_text})
76
+ res = TurnResult()
77
+ acc_text = []
78
+ try:
79
+ resp = self._request(self._payload(active_module_ids))
80
+ except Exception as e:
81
+ res.error = f"backend call failed: {e}"
82
+ return res
83
+
84
+ ctype = (resp.headers.get("Content-Type") or "").lower()
85
+ if "text/event-stream" not in ctype:
86
+ # Non-streaming fallback (e.g. a local/old backend returning plain JSON).
87
+ try:
88
+ data = json.loads(resp.read().decode())
89
+ except Exception as e:
90
+ res.error = f"unexpected backend response: {e}"
91
+ return res
92
+ for path, content in (data.get("files") or data.get("project_files") or {}).items():
93
+ self.files[path] = content
94
+ res.files_changed.append(path)
95
+ res.text = data.get("message") or data.get("reply") or ""
96
+ self.messages.append({"role": "assistant", "content": res.text})
97
+ return res
98
+
99
+ for raw in resp:
100
+ line = raw.decode("utf-8", "replace").strip()
101
+ if not line.startswith("data:"):
102
+ continue
103
+ try:
104
+ evt = json.loads(line[5:].strip())
105
+ except Exception:
106
+ continue
107
+ if on_event:
108
+ on_event(evt)
109
+ t = evt.get("type")
110
+ if t == "text":
111
+ acc_text.append(evt.get("delta", ""))
112
+ elif t == "file_write":
113
+ self.files[evt["path"]] = evt.get("content", "")
114
+ if evt.get("name") or evt.get("kind"):
115
+ self.file_meta[evt["path"]] = {"name": evt.get("name"), "kind": evt.get("kind")}
116
+ res.files_changed.append(evt["path"])
117
+ elif t == "file_edit":
118
+ if evt.get("content") is not None:
119
+ self.files[evt["path"]] = evt["content"]
120
+ res.files_changed.append(evt["path"])
121
+ elif t == "file_delete":
122
+ self.files.pop(evt["path"], None)
123
+ res.files_deleted.append(evt["path"])
124
+ elif t == "done":
125
+ res.text = evt.get("message") or "".join(acc_text)
126
+ res.relay_url = evt.get("relay_url")
127
+ res.device_key = evt.get("device_key")
128
+ elif t == "error":
129
+ res.error = evt.get("message", "unknown error")
130
+
131
+ if not res.text:
132
+ res.text = "".join(acc_text)
133
+ # Dedup changed paths, preserve order.
134
+ seen = set()
135
+ res.files_changed = [p for p in res.files_changed if not (p in seen or seen.add(p))]
136
+ self.messages.append({"role": "assistant", "content": res.text})
137
+ return res
axiometa_cli/cli.py ADDED
@@ -0,0 +1,303 @@
1
+ """argparse dispatch for axiometa-cli.
2
+
3
+ `axiometa` with no verb drops into the guided experience (detect -> target -> chat -> deploy ->
4
+ monitor). The verbs are there for scripting and power use: provision the toolchain, set the target,
5
+ one-shot a prompt, deploy, monitor, serve. Codegen is the backend's job over SSE; deploy/monitor/serve
6
+ run locally on the machine the board is plugged into.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import os
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ from . import __version__, auth, chat, core, detect, interactive, ui
16
+ from . import serve as serve_mod
17
+
18
+ DEFAULT_BACKEND = os.environ.get("AXIOMETA_BACKEND", "https://studio.axiometa.io")
19
+
20
+
21
+ def _pdir(args) -> Path:
22
+ return Path(getattr(args, "project", None) or os.getcwd()).resolve()
23
+
24
+
25
+ def _target(pdir: Path, boards: dict):
26
+ man = interactive.load_manifest(pdir)
27
+ board = boards.get(man.get("board")) or boards.get("genesis-one") or next(iter(boards.values()), {"id": "?"})
28
+ sub = core.resolve_substrate(board, man.get("substrate"))
29
+ return board, sub
30
+
31
+
32
+ # ── verbs ──────────────────────────────────────────────────────────────────────
33
+ def cmd_chat(args):
34
+ interactive.run(args)
35
+
36
+
37
+ def cmd_provision(args):
38
+ from . import provision
39
+ boards = core.load_boards()
40
+ targets = None
41
+ if args.arduino or args.micropython:
42
+ targets = set()
43
+ if args.arduino:
44
+ targets.add("arduino")
45
+ if args.micropython:
46
+ targets.add("micropython")
47
+ provision.provision(boards, targets, firmware=getattr(args, "firmware", None))
48
+
49
+
50
+ def cmd_login(args):
51
+ """Sign in once: device-code browser flow if the backend supports it, else paste a token. Stored."""
52
+ if getattr(args, "token", None):
53
+ p = auth.save_token(args.token)
54
+ ui.ok(f"token saved ({p})")
55
+ return
56
+ backend = args.backend
57
+ start = auth.device_login_start(backend)
58
+ if start and start.get("user_code"):
59
+ url = start.get("verification_url") or backend
60
+ print()
61
+ ui.info("To sign in, open this URL and enter the code:")
62
+ ui.info(" " + ui.c(url, "cyan", "bold"))
63
+ ui.info(" code: " + ui.c(start["user_code"], "magenta", "bold"))
64
+ try:
65
+ import webbrowser
66
+ webbrowser.open(start.get("verification_url_complete") or url)
67
+ except Exception:
68
+ pass
69
+ with ui.Spinner("waiting for approval in the browser"):
70
+ token = auth.device_login_poll(backend, start)
71
+ if token:
72
+ ui.ok("signed in")
73
+ else:
74
+ ui.err("login did not complete (timed out or was denied).")
75
+ sys.exit(1)
76
+ return
77
+ # Fallback: the backend has no device-code endpoint yet — paste a token.
78
+ ui.hint("Browser login is not available on this backend yet. Paste your token to sign in")
79
+ ui.hint("(from a logged-in Studio session), or press Enter to stay in guest mode:")
80
+ try:
81
+ import getpass
82
+ tok = getpass.getpass("token: ").strip() if sys.stdin.isatty() else ""
83
+ except Exception:
84
+ tok = ""
85
+ if tok:
86
+ p = auth.save_token(tok)
87
+ ui.ok(f"token saved ({p})")
88
+ else:
89
+ ui.warn("no token saved. Guest mode works (~10 builds/day); a token unlocks unlimited.")
90
+
91
+
92
+ def cmd_logout(args):
93
+ if auth.clear_token():
94
+ ui.ok("signed out (stored token removed).")
95
+ else:
96
+ ui.info("no stored token.")
97
+
98
+
99
+ def cmd_setup(args):
100
+ """Enable Pi interfaces (serial / camera / i2c / spi) via raspi-config, with approval."""
101
+ from . import setup as setup_mod
102
+ steps = args.steps or ["serial"]
103
+ ui.banner()
104
+ ui.info(ui.c("System setup on this Pi", "bold") + ui.c(f" ({', '.join(steps)})", "grey"))
105
+ print()
106
+ res = setup_mod.apply(steps)
107
+ if res and res.get("_reboot") == "required":
108
+ ui.hint("reboot to apply: sudo reboot")
109
+
110
+
111
+ def cmd_devices(args):
112
+ devs = detect.detect()
113
+ if not devs:
114
+ ui.warn("no serial devices found.")
115
+ ph = detect.permission_hint()
116
+ if ph:
117
+ ui.hint(ph)
118
+ return
119
+ ui.info(ui.c("Connected:", "bold"))
120
+ for d in devs:
121
+ print(" " + d.label())
122
+
123
+
124
+ def cmd_board(args):
125
+ pdir = _pdir(args)
126
+ boards = core.load_boards()
127
+ if not boards:
128
+ ui.err("no board facts found (set AXIOMETA_KNOWLEDGE)."); sys.exit(1)
129
+ man = interactive.load_manifest(pdir)
130
+ if not args.board_id:
131
+ cur = man.get("board", "(unset)")
132
+ for b in boards.values():
133
+ langs = b.get("languages_available") or [b.get("default_language")]
134
+ mark = ui.c(" *", "green", "bold") if b["id"] == cur else ""
135
+ print(f" {b['id']:<14} {b.get('name',''):<16} [{', '.join(l for l in langs if l)}]{mark}")
136
+ return
137
+ if args.board_id not in boards:
138
+ ui.err(f"unknown board {args.board_id!r}. Known: {list(boards)}"); sys.exit(1)
139
+ man["board"] = args.board_id
140
+ if args.substrate:
141
+ man["substrate"] = args.substrate
142
+ interactive.save_manifest(pdir, man)
143
+ sub = core.resolve_substrate(boards[args.board_id], man.get("substrate"))
144
+ ui.ok(f"target: {boards[args.board_id].get('name')} · {sub['label']} · "
145
+ f"device={sub['device_program']} · verbs={core.verbs_for(sub)}")
146
+
147
+
148
+ def cmd_prompt(args):
149
+ """One-shot: send a single prompt, stream the reply, write the files."""
150
+ pdir = _pdir(args)
151
+ pdir.mkdir(parents=True, exist_ok=True)
152
+ boards = core.load_boards()
153
+ if not boards:
154
+ ui.err("no board facts found (set AXIOMETA_KNOWLEDGE)."); sys.exit(1)
155
+ board, sub = _target(pdir, boards)
156
+ profile = core.detect_profile()
157
+ man = interactive.load_manifest(pdir)
158
+ session = chat.ChatSession(
159
+ backend=args.backend, token=auth.get_token(), board_id=board["id"],
160
+ capability_profile={"surface": profile["surface"], "host_exec": profile["host_exec"]},
161
+ files=interactive.read_vfs(pdir), connected_integrations=man.get("connected_integrations"),
162
+ )
163
+ ui.info(ui.c(f"[{board['id']} · {sub['substrate']} · surface {profile['surface']}]", "grey"))
164
+ print(ui.c("axie", "cyan", "bold"))
165
+ res = session.send(args.text, on_event=interactive._Renderer())
166
+ print()
167
+ if res.error:
168
+ ui.err(res.error); sys.exit(1)
169
+ for path in res.files_changed:
170
+ dest = pdir / path
171
+ dest.parent.mkdir(parents=True, exist_ok=True)
172
+ dest.write_text(session.files[path], encoding="utf-8")
173
+ for path in res.files_deleted:
174
+ try:
175
+ (pdir / path).unlink()
176
+ except Exception:
177
+ pass
178
+ if res.files_changed:
179
+ ui.ok("wrote: " + ", ".join(res.files_changed))
180
+ verb = "run" if "run" in core.verbs_for(sub) else "deploy"
181
+ ui.hint(f"next: axiometa {verb}")
182
+
183
+
184
+ def cmd_deploy_like(verb):
185
+ def _cmd(args):
186
+ pdir = _pdir(args)
187
+ boards = core.load_boards()
188
+ board, sub = _target(pdir, boards)
189
+ port = core.serial_port(sub, args.port)
190
+ ui.step(f"{verb} → {board.get('name')} on {port or '(autodetect)'}")
191
+ res = core.deploy(board, sub, pdir, verb, port, on_line=lambda ln: print(ui.c(" " + ln, "grey")))
192
+ if res.ok:
193
+ ui.ok(res.detail.splitlines()[0] if res.detail else "done")
194
+ if verb == "run":
195
+ ui.hint("test run in RAM — NOT saved. `axiometa install` to persist.")
196
+ else:
197
+ ui.err(res.detail)
198
+ sys.exit(0 if res.ok else 1)
199
+ return _cmd
200
+
201
+
202
+ def cmd_flash(args):
203
+ """SWD: lay MicroPython firmware onto the RP2040 (Pi HAT) over OpenOCD. One-time / recovery."""
204
+ pdir = _pdir(args)
205
+ boards = core.load_boards()
206
+ board, sub = _target(pdir, boards)
207
+ fw = Path(args.firmware).expanduser() if args.firmware else None
208
+ ui.step(f"flash firmware → {board.get('name')} over SWD")
209
+ res = core.flash_firmware(board, sub, fw, on_line=lambda ln: print(ui.c(" " + ln, "grey")))
210
+ if res.ok:
211
+ ui.ok(res.detail.splitlines()[0] if res.detail else "flashed")
212
+ ui.hint("now deploy code over the UART REPL: axiometa run (or axiometa install)")
213
+ else:
214
+ ui.err(res.detail)
215
+ sys.exit(0 if res.ok else 1)
216
+
217
+
218
+ def cmd_host(args):
219
+ """Run the Pi-side program (host/app.py) on THIS machine, after showing it and getting a yes."""
220
+ pdir = _pdir(args)
221
+ interactive.run_host(pdir, show_code=not args.yes, auto_yes=args.yes)
222
+
223
+
224
+ def cmd_monitor(args):
225
+ pdir = _pdir(args)
226
+ boards = core.load_boards()
227
+ board, sub = _target(pdir, boards)
228
+ interactive.monitor(core.serial_port(sub, args.port))
229
+
230
+
231
+ def cmd_serve(args):
232
+ pdir = _pdir(args)
233
+ profile = core.detect_profile()
234
+ serve_mod.serve(pdir, core.pick_port(args.port), http_port=args.http_port, over_ssh=profile["over_ssh"])
235
+
236
+
237
+ # ── parser ───────────────────────────────────────────────────────────────────
238
+ def build_parser() -> argparse.ArgumentParser:
239
+ p = argparse.ArgumentParser(prog="axiometa", description="Prompt -> hardware, in your terminal.")
240
+ p.add_argument("--version", action="version", version=f"axiometa-cli {__version__}")
241
+ p.add_argument("--backend", default=DEFAULT_BACKEND, help="backend base URL")
242
+ p.add_argument("--project", help="project dir (default: cwd)")
243
+ p.add_argument("--port", help="serial port (default: autodetect)")
244
+ p.set_defaults(func=cmd_chat) # no verb -> guided experience
245
+ sub = p.add_subparsers(dest="cmd")
246
+
247
+ sub.add_parser("chat", help="the guided build experience (default)").set_defaults(func=cmd_chat)
248
+
249
+ pr = sub.add_parser("provision", help="install the local toolchain (arduino-cli/core/libs, mpremote, openocd)")
250
+ pr.add_argument("--arduino", action="store_true", help="only the Arduino toolchain")
251
+ pr.add_argument("--micropython", action="store_true", help="only the MicroPython toolchain")
252
+ pr.add_argument("--firmware", help="MicroPython .uf2/.elf/.bin to SWD-flash onto the Pi HAT during provision")
253
+ pr.set_defaults(func=cmd_provision)
254
+
255
+ lg = sub.add_parser("login", help="sign in (device-code browser flow, or paste a token); stored")
256
+ lg.add_argument("--token", help="store this token directly (skip the interactive flow)")
257
+ lg.set_defaults(func=cmd_login)
258
+
259
+ sub.add_parser("logout", help="remove the stored token").set_defaults(func=cmd_logout)
260
+
261
+ st = sub.add_parser("setup", help="enable Pi interfaces (serial/camera/i2c/spi) via raspi-config, approved")
262
+ st.add_argument("steps", nargs="*", help="which to enable (default: serial). e.g. serial camera i2c spi")
263
+ st.set_defaults(func=cmd_setup)
264
+
265
+ sub.add_parser("devices", help="list connected boards").set_defaults(func=cmd_devices)
266
+
267
+ b = sub.add_parser("board", help="show/set the target board (+ optional substrate)")
268
+ b.add_argument("board_id", nargs="?"); b.add_argument("--substrate")
269
+ b.set_defaults(func=cmd_board)
270
+
271
+ sp = sub.add_parser("prompt", help="one-shot: describe what to build; writes the files")
272
+ sp.add_argument("text"); sp.set_defaults(func=cmd_prompt)
273
+
274
+ sub.add_parser("run", help="MicroPython: test in RAM (ephemeral)").set_defaults(func=cmd_deploy_like("run"))
275
+ sub.add_parser("install", help="MicroPython: persist to flash").set_defaults(func=cmd_deploy_like("install"))
276
+ sub.add_parser("deploy", help="Arduino: compile + flash").set_defaults(func=cmd_deploy_like("deploy"))
277
+
278
+ fl = sub.add_parser("flash", help="RP2040 over SWD: lay MicroPython firmware on the chip (Pi HAT)")
279
+ fl.add_argument("--firmware", help="path to a MicroPython .uf2/.elf/.bin for the RP2040")
280
+ fl.set_defaults(func=cmd_flash)
281
+
282
+ hs = sub.add_parser("host", help="run the Pi-side program host/app.py on this machine (approve first)")
283
+ hs.add_argument("-y", "--yes", action="store_true", help="skip the approval prompt (you already trust it)")
284
+ hs.set_defaults(func=cmd_host)
285
+
286
+ sub.add_parser("monitor", help="stream the serial channel").set_defaults(func=cmd_monitor)
287
+
288
+ sv = sub.add_parser("serve", help="serve browser/*.html locally + bridge serial")
289
+ sv.add_argument("--http-port", type=int, default=8088); sv.set_defaults(func=cmd_serve)
290
+ return p
291
+
292
+
293
+ def main(argv=None):
294
+ args = build_parser().parse_args(argv)
295
+ try:
296
+ args.func(args)
297
+ except KeyboardInterrupt:
298
+ print()
299
+ sys.exit(130)
300
+
301
+
302
+ if __name__ == "__main__":
303
+ main()