axiometa-cli 1.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.
- axiometa_cli-1.0.0/PKG-INFO +131 -0
- axiometa_cli-1.0.0/README.md +109 -0
- axiometa_cli-1.0.0/axiometa_cli/__init__.py +19 -0
- axiometa_cli-1.0.0/axiometa_cli/__main__.py +4 -0
- axiometa_cli-1.0.0/axiometa_cli/auth.py +116 -0
- axiometa_cli-1.0.0/axiometa_cli/chat.py +137 -0
- axiometa_cli-1.0.0/axiometa_cli/cli.py +303 -0
- axiometa_cli-1.0.0/axiometa_cli/core.py +393 -0
- axiometa_cli-1.0.0/axiometa_cli/data/boards/pi-hat/micropython/firmware/board_pins.py +79 -0
- axiometa_cli-1.0.0/axiometa_cli/data/boards/pi-hat/micropython/firmware/boot.py +18 -0
- axiometa_cli-1.0.0/axiometa_cli/data/boards.json +61 -0
- axiometa_cli-1.0.0/axiometa_cli/detect.py +92 -0
- axiometa_cli-1.0.0/axiometa_cli/flash.py +125 -0
- axiometa_cli-1.0.0/axiometa_cli/interactive.py +405 -0
- axiometa_cli-1.0.0/axiometa_cli/provision.py +284 -0
- axiometa_cli-1.0.0/axiometa_cli/serve.py +162 -0
- axiometa_cli-1.0.0/axiometa_cli/setup.py +124 -0
- axiometa_cli-1.0.0/axiometa_cli/ui.py +142 -0
- axiometa_cli-1.0.0/axiometa_cli.egg-info/PKG-INFO +131 -0
- axiometa_cli-1.0.0/axiometa_cli.egg-info/SOURCES.txt +24 -0
- axiometa_cli-1.0.0/axiometa_cli.egg-info/dependency_links.txt +1 -0
- axiometa_cli-1.0.0/axiometa_cli.egg-info/entry_points.txt +2 -0
- axiometa_cli-1.0.0/axiometa_cli.egg-info/requires.txt +4 -0
- axiometa_cli-1.0.0/axiometa_cli.egg-info/top_level.txt +1 -0
- axiometa_cli-1.0.0/pyproject.toml +43 -0
- axiometa_cli-1.0.0/setup.cfg +4 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: axiometa-cli
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Prompt to hardware, in your terminal: detect a board, chat to build, deploy, watch serial.
|
|
5
|
+
Author-email: Axiometa <dr.dumcius@gmail.com>
|
|
6
|
+
Project-URL: Homepage, https://axiometa.io
|
|
7
|
+
Project-URL: Studio, https://studio.axiometa.io
|
|
8
|
+
Keywords: hardware,ai,esp32,rp2040,raspberry-pi,arduino,micropython,iot,cli,maker
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
13
|
+
Classifier: Operating System :: MacOS
|
|
14
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: System :: Hardware
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
Requires-Dist: pyserial>=3.5
|
|
20
|
+
Provides-Extra: micropython
|
|
21
|
+
Requires-Dist: mpremote>=1.20; extra == "micropython"
|
|
22
|
+
|
|
23
|
+
# axiometa-cli
|
|
24
|
+
|
|
25
|
+
Prompt → hardware, in your terminal. The CLI is a thin client of the same backend Studio's browser
|
|
26
|
+
talks to: **codegen stays in the cloud** (Claude + the catalog + your auth/limits), and only the
|
|
27
|
+
**local half** — provision the toolchain, deploy, read the serial channel — runs on the machine the
|
|
28
|
+
board is plugged into. Same brain as Studio; the terminal (or a Pi) is the body instead of the browser.
|
|
29
|
+
|
|
30
|
+
It is one experience whether the board speaks Arduino or MicroPython — the board's facts decide the
|
|
31
|
+
deploy verb (`deploy` = compile+flash on Arduino; `run`/`install` on MicroPython).
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
**Raspberry Pi / Linux / macOS** (from a checkout):
|
|
36
|
+
```
|
|
37
|
+
./cli/install.sh
|
|
38
|
+
axiometa provision # one-time: arduino-cli + ESP32 core + libs (+ mpremote)
|
|
39
|
+
axiometa # detect a board and start building
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
**Windows / any OS** (pip):
|
|
43
|
+
```
|
|
44
|
+
pip install -e cli
|
|
45
|
+
axiometa provision
|
|
46
|
+
axiometa
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Config (env, all optional):
|
|
50
|
+
```
|
|
51
|
+
AXIOMETA_BACKEND=https://studio.axiometa.io # or your local backend
|
|
52
|
+
AXIOMETA_TOKEN=... # your auth (carried to the backend)
|
|
53
|
+
AXIOMETA_KNOWLEDGE=/path/to/backend/knowledge/boards # auto-found next to a repo checkout
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## The experience (`axiometa`)
|
|
57
|
+
|
|
58
|
+
Typing `axiometa` with no verb runs the guided loop:
|
|
59
|
+
|
|
60
|
+
1. **Detect** — lists connected boards, naming the silicon from its USB identity (ESP32-S3 vs RP2040).
|
|
61
|
+
2. **Target** — confirms the board + language, picks the port, writes `axiometa.json`.
|
|
62
|
+
3. **Toolchain** — if `arduino-cli`/`mpremote` is missing, offers `axiometa provision`.
|
|
63
|
+
4. **Chat** — describe what to build; Axie streams its reply, shows what it's reading, and writes the
|
|
64
|
+
project files. Refine in follow-ups ("make it blink faster").
|
|
65
|
+
5. **Deploy** — when the device program is (re)generated, offers to flash it (Arduino) or
|
|
66
|
+
run/install it (MicroPython), streaming the toolchain output.
|
|
67
|
+
6. **Monitor** — after a successful deploy, streams the serial channel so you see the device talk.
|
|
68
|
+
|
|
69
|
+
Mid-chat commands: `/flash` `/deploy` `/run` `/install` `/monitor` `/serve` `/board` `/help` `/exit`.
|
|
70
|
+
|
|
71
|
+
## Verbs (scripting / power use)
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
axiometa provision # install the local toolchain (one-time)
|
|
75
|
+
axiometa devices # list connected boards (with USB identity)
|
|
76
|
+
axiometa board pi-hat # set the target (writes axiometa.json)
|
|
77
|
+
axiometa prompt "blink the RGB LED on port 1 red" # one-shot: generate + write files
|
|
78
|
+
axiometa flash --firmware mp.uf2 # RP2040 over SWD: lay MicroPython on the chip (Pi HAT)
|
|
79
|
+
axiometa run # MicroPython: test in RAM (ephemeral)
|
|
80
|
+
axiometa install # MicroPython: persist to flash
|
|
81
|
+
axiometa deploy # Arduino: compile + flash
|
|
82
|
+
axiometa monitor # stream serial to the terminal
|
|
83
|
+
axiometa serve # serve browser/*.html on 127.0.0.1 (ssh -L hint when headless)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## The Pi HAT (RP2040 flashed over SWD, talked-to over UART)
|
|
87
|
+
|
|
88
|
+
The Pi HAT's RP2040 has **no USB** — the Raspberry Pi is both its programmer and its host, over the
|
|
89
|
+
40-pin header. So the Pi HAT path differs from a USB board, and the CLI handles it:
|
|
90
|
+
|
|
91
|
+
- **`axiometa flash --firmware <mp.uf2>`** lays MicroPython onto the chip over **SWD** (OpenOCD
|
|
92
|
+
bit-banging Pi GPIO24=SWDIO / GPIO25=SWCLK / GPIO23=RUN). A `.uf2` is auto-converted to the `.bin`
|
|
93
|
+
OpenOCD needs; `.elf`/`.bin` also work. This is one-time, and the brick-recovery path.
|
|
94
|
+
- **`run` / `install` / `monitor`** talk over the Pi's UART at **`/dev/serial0`** (RP2040 GP0/GP1 ↔
|
|
95
|
+
Pi GPIO15/14), not USB. The CLI ships `board_pins.py` + `boot.py` with each deploy; `boot.py` puts
|
|
96
|
+
the REPL on UART0 so `mpremote` can reach the board over `/dev/serial0`.
|
|
97
|
+
|
|
98
|
+
One-time Pi setup (handled by `axiometa provision`, or do it yourself):
|
|
99
|
+
```
|
|
100
|
+
sudo apt install openocd # SWD flashing
|
|
101
|
+
sudo raspi-config -> Serial Port: login shell over serial = NO, serial hardware = YES # frees /dev/serial0
|
|
102
|
+
sudo -E axiometa flash --firmware mp.uf2 # SWD needs GPIO/root; -E keeps your env
|
|
103
|
+
```
|
|
104
|
+
Notes: the flashed firmware must expose the **UART REPL** (freeze `boot.py` into the build, or use a
|
|
105
|
+
UART-REPL MicroPython) for first contact. `bcm2835gpio` (the default OpenOCD driver) is Pi 0-4 only;
|
|
106
|
+
on a **Pi 5** set `AXIOMETA_OPENOCD_DRIVER=linuxgpiod`. Override the whole OpenOCD interface with
|
|
107
|
+
`AXIOMETA_OPENOCD_CFG=/path/to.cfg` if your wiring differs.
|
|
108
|
+
|
|
109
|
+
## Where things live
|
|
110
|
+
|
|
111
|
+
A **project is a folder** with an `axiometa.json` manifest (board + substrate + modules), the device
|
|
112
|
+
program (`sketch.ino` / `main.py`), any `browser/*.html` surfaces, and `board_pins.py`. `cd` into a
|
|
113
|
+
folder and run `axiometa` — it is project-rooted, like `git`.
|
|
114
|
+
|
|
115
|
+
## Headless vs. desktop
|
|
116
|
+
|
|
117
|
+
The CLI detects where a visual surface can render and tells the backend (the `capability_profile`):
|
|
118
|
+
on a Pi with a display it serves the surface to a local browser; headless-over-SSH it prints an
|
|
119
|
+
`ssh -L` line to open it from your own machine; a bare terminal degrades a surface to a serial view.
|
|
120
|
+
|
|
121
|
+
## Status
|
|
122
|
+
|
|
123
|
+
The agentic loop is board-agnostic and built: detect → chat (SSE codegen) → flash/deploy → monitor,
|
|
124
|
+
plus a cross-platform `provision`. The Pi HAT's **SWD flash (OpenOCD)** and **UART (`/dev/serial0`)**
|
|
125
|
+
deploy/monitor path is wired and unit-tested (uf2→bin, config generation, dispatch) but **not yet
|
|
126
|
+
validated on real hardware** — exercise it on a connected Pi + RP2040. The one external dependency is
|
|
127
|
+
a MicroPython firmware that exposes the UART REPL.
|
|
128
|
+
|
|
129
|
+
The native **host-linux** runtime (the "whole Pi" brain: code using the host's own camera/files/GPIO)
|
|
130
|
+
is **not** wired here — it is approval-gated and waits on the trust model in
|
|
131
|
+
`backend/knowledge/runtimes/host-linux/contract.md`.
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# axiometa-cli
|
|
2
|
+
|
|
3
|
+
Prompt → hardware, in your terminal. The CLI is a thin client of the same backend Studio's browser
|
|
4
|
+
talks to: **codegen stays in the cloud** (Claude + the catalog + your auth/limits), and only the
|
|
5
|
+
**local half** — provision the toolchain, deploy, read the serial channel — runs on the machine the
|
|
6
|
+
board is plugged into. Same brain as Studio; the terminal (or a Pi) is the body instead of the browser.
|
|
7
|
+
|
|
8
|
+
It is one experience whether the board speaks Arduino or MicroPython — the board's facts decide the
|
|
9
|
+
deploy verb (`deploy` = compile+flash on Arduino; `run`/`install` on MicroPython).
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
**Raspberry Pi / Linux / macOS** (from a checkout):
|
|
14
|
+
```
|
|
15
|
+
./cli/install.sh
|
|
16
|
+
axiometa provision # one-time: arduino-cli + ESP32 core + libs (+ mpremote)
|
|
17
|
+
axiometa # detect a board and start building
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
**Windows / any OS** (pip):
|
|
21
|
+
```
|
|
22
|
+
pip install -e cli
|
|
23
|
+
axiometa provision
|
|
24
|
+
axiometa
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Config (env, all optional):
|
|
28
|
+
```
|
|
29
|
+
AXIOMETA_BACKEND=https://studio.axiometa.io # or your local backend
|
|
30
|
+
AXIOMETA_TOKEN=... # your auth (carried to the backend)
|
|
31
|
+
AXIOMETA_KNOWLEDGE=/path/to/backend/knowledge/boards # auto-found next to a repo checkout
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## The experience (`axiometa`)
|
|
35
|
+
|
|
36
|
+
Typing `axiometa` with no verb runs the guided loop:
|
|
37
|
+
|
|
38
|
+
1. **Detect** — lists connected boards, naming the silicon from its USB identity (ESP32-S3 vs RP2040).
|
|
39
|
+
2. **Target** — confirms the board + language, picks the port, writes `axiometa.json`.
|
|
40
|
+
3. **Toolchain** — if `arduino-cli`/`mpremote` is missing, offers `axiometa provision`.
|
|
41
|
+
4. **Chat** — describe what to build; Axie streams its reply, shows what it's reading, and writes the
|
|
42
|
+
project files. Refine in follow-ups ("make it blink faster").
|
|
43
|
+
5. **Deploy** — when the device program is (re)generated, offers to flash it (Arduino) or
|
|
44
|
+
run/install it (MicroPython), streaming the toolchain output.
|
|
45
|
+
6. **Monitor** — after a successful deploy, streams the serial channel so you see the device talk.
|
|
46
|
+
|
|
47
|
+
Mid-chat commands: `/flash` `/deploy` `/run` `/install` `/monitor` `/serve` `/board` `/help` `/exit`.
|
|
48
|
+
|
|
49
|
+
## Verbs (scripting / power use)
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
axiometa provision # install the local toolchain (one-time)
|
|
53
|
+
axiometa devices # list connected boards (with USB identity)
|
|
54
|
+
axiometa board pi-hat # set the target (writes axiometa.json)
|
|
55
|
+
axiometa prompt "blink the RGB LED on port 1 red" # one-shot: generate + write files
|
|
56
|
+
axiometa flash --firmware mp.uf2 # RP2040 over SWD: lay MicroPython on the chip (Pi HAT)
|
|
57
|
+
axiometa run # MicroPython: test in RAM (ephemeral)
|
|
58
|
+
axiometa install # MicroPython: persist to flash
|
|
59
|
+
axiometa deploy # Arduino: compile + flash
|
|
60
|
+
axiometa monitor # stream serial to the terminal
|
|
61
|
+
axiometa serve # serve browser/*.html on 127.0.0.1 (ssh -L hint when headless)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## The Pi HAT (RP2040 flashed over SWD, talked-to over UART)
|
|
65
|
+
|
|
66
|
+
The Pi HAT's RP2040 has **no USB** — the Raspberry Pi is both its programmer and its host, over the
|
|
67
|
+
40-pin header. So the Pi HAT path differs from a USB board, and the CLI handles it:
|
|
68
|
+
|
|
69
|
+
- **`axiometa flash --firmware <mp.uf2>`** lays MicroPython onto the chip over **SWD** (OpenOCD
|
|
70
|
+
bit-banging Pi GPIO24=SWDIO / GPIO25=SWCLK / GPIO23=RUN). A `.uf2` is auto-converted to the `.bin`
|
|
71
|
+
OpenOCD needs; `.elf`/`.bin` also work. This is one-time, and the brick-recovery path.
|
|
72
|
+
- **`run` / `install` / `monitor`** talk over the Pi's UART at **`/dev/serial0`** (RP2040 GP0/GP1 ↔
|
|
73
|
+
Pi GPIO15/14), not USB. The CLI ships `board_pins.py` + `boot.py` with each deploy; `boot.py` puts
|
|
74
|
+
the REPL on UART0 so `mpremote` can reach the board over `/dev/serial0`.
|
|
75
|
+
|
|
76
|
+
One-time Pi setup (handled by `axiometa provision`, or do it yourself):
|
|
77
|
+
```
|
|
78
|
+
sudo apt install openocd # SWD flashing
|
|
79
|
+
sudo raspi-config -> Serial Port: login shell over serial = NO, serial hardware = YES # frees /dev/serial0
|
|
80
|
+
sudo -E axiometa flash --firmware mp.uf2 # SWD needs GPIO/root; -E keeps your env
|
|
81
|
+
```
|
|
82
|
+
Notes: the flashed firmware must expose the **UART REPL** (freeze `boot.py` into the build, or use a
|
|
83
|
+
UART-REPL MicroPython) for first contact. `bcm2835gpio` (the default OpenOCD driver) is Pi 0-4 only;
|
|
84
|
+
on a **Pi 5** set `AXIOMETA_OPENOCD_DRIVER=linuxgpiod`. Override the whole OpenOCD interface with
|
|
85
|
+
`AXIOMETA_OPENOCD_CFG=/path/to.cfg` if your wiring differs.
|
|
86
|
+
|
|
87
|
+
## Where things live
|
|
88
|
+
|
|
89
|
+
A **project is a folder** with an `axiometa.json` manifest (board + substrate + modules), the device
|
|
90
|
+
program (`sketch.ino` / `main.py`), any `browser/*.html` surfaces, and `board_pins.py`. `cd` into a
|
|
91
|
+
folder and run `axiometa` — it is project-rooted, like `git`.
|
|
92
|
+
|
|
93
|
+
## Headless vs. desktop
|
|
94
|
+
|
|
95
|
+
The CLI detects where a visual surface can render and tells the backend (the `capability_profile`):
|
|
96
|
+
on a Pi with a display it serves the surface to a local browser; headless-over-SSH it prints an
|
|
97
|
+
`ssh -L` line to open it from your own machine; a bare terminal degrades a surface to a serial view.
|
|
98
|
+
|
|
99
|
+
## Status
|
|
100
|
+
|
|
101
|
+
The agentic loop is board-agnostic and built: detect → chat (SSE codegen) → flash/deploy → monitor,
|
|
102
|
+
plus a cross-platform `provision`. The Pi HAT's **SWD flash (OpenOCD)** and **UART (`/dev/serial0`)**
|
|
103
|
+
deploy/monitor path is wired and unit-tested (uf2→bin, config generation, dispatch) but **not yet
|
|
104
|
+
validated on real hardware** — exercise it on a connected Pi + RP2040. The one external dependency is
|
|
105
|
+
a MicroPython firmware that exposes the UART REPL.
|
|
106
|
+
|
|
107
|
+
The native **host-linux** runtime (the "whole Pi" brain: code using the host's own camera/files/GPIO)
|
|
108
|
+
is **not** wired here — it is approval-gated and waits on the trust model in
|
|
109
|
+
`backend/knowledge/runtimes/host-linux/contract.md`.
|
|
@@ -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,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
|
|
@@ -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
|