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.
- axiometa_cli/__init__.py +19 -0
- axiometa_cli/__main__.py +4 -0
- axiometa_cli/auth.py +116 -0
- axiometa_cli/chat.py +137 -0
- axiometa_cli/cli.py +303 -0
- axiometa_cli/core.py +393 -0
- axiometa_cli/data/boards/pi-hat/micropython/firmware/board_pins.py +79 -0
- axiometa_cli/data/boards/pi-hat/micropython/firmware/boot.py +18 -0
- axiometa_cli/data/boards.json +61 -0
- axiometa_cli/detect.py +92 -0
- axiometa_cli/flash.py +125 -0
- axiometa_cli/interactive.py +405 -0
- axiometa_cli/provision.py +284 -0
- axiometa_cli/serve.py +162 -0
- axiometa_cli/setup.py +124 -0
- axiometa_cli/ui.py +142 -0
- axiometa_cli-1.0.0.dist-info/METADATA +131 -0
- axiometa_cli-1.0.0.dist-info/RECORD +21 -0
- axiometa_cli-1.0.0.dist-info/WHEEL +5 -0
- axiometa_cli-1.0.0.dist-info/entry_points.txt +2 -0
- axiometa_cli-1.0.0.dist-info/top_level.txt +1 -0
axiometa_cli/__init__.py
ADDED
|
@@ -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"
|
axiometa_cli/__main__.py
ADDED
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()
|