mirror of
https://github.com/HKUDS/CLI-Anything.git
synced 2026-09-17 17:38:11 +08:00
feat: add EEZ Studio CLI harness (#334)
* feat: add EEZ Studio CLI harness * Fix EEZ Studio REPL and backend tests
This commit is contained in:
@@ -100,6 +100,7 @@
|
|||||||
!/3MF/
|
!/3MF/
|
||||||
!/calibre/
|
!/calibre/
|
||||||
!/rekordbox/
|
!/rekordbox/
|
||||||
|
!/eez-studio/
|
||||||
!/live2d/
|
!/live2d/
|
||||||
!/tigris/
|
!/tigris/
|
||||||
!/cc-switch/
|
!/cc-switch/
|
||||||
@@ -211,6 +212,8 @@
|
|||||||
/calibre/.*
|
/calibre/.*
|
||||||
/rekordbox/*
|
/rekordbox/*
|
||||||
/rekordbox/.*
|
/rekordbox/.*
|
||||||
|
/eez-studio/*
|
||||||
|
/eez-studio/.*
|
||||||
/live2d/*
|
/live2d/*
|
||||||
/live2d/.*
|
/live2d/.*
|
||||||
/tigris/*
|
/tigris/*
|
||||||
@@ -285,6 +288,7 @@
|
|||||||
!/mailchimp/agent-harness/
|
!/mailchimp/agent-harness/
|
||||||
!/live2d/agent-harness/
|
!/live2d/agent-harness/
|
||||||
!/rekordbox/agent-harness/
|
!/rekordbox/agent-harness/
|
||||||
|
!/eez-studio/agent-harness/
|
||||||
!/tigris/agent-harness/
|
!/tigris/agent-harness/
|
||||||
!/cc-switch/agent-harness/
|
!/cc-switch/agent-harness/
|
||||||
!/siyuan/agent-harness/
|
!/siyuan/agent-harness/
|
||||||
|
|||||||
@@ -1166,6 +1166,13 @@ Each application received complete, production-ready CLI interfaces — not demo
|
|||||||
<td align="center">✅ 138</td>
|
<td align="center">✅ 138</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
<td align="center"><strong><a href="eez-studio/agent-harness/">EEZ Studio</a></strong></td>
|
||||||
|
<td>Embedded UI / Instrument Automation</td>
|
||||||
|
<td><code>cli-anything-eez-studio</code></td>
|
||||||
|
<td>.eez-project JSON + EEZ Studio Node backend</td>
|
||||||
|
<td align="center">✅ <a href="eez-studio/agent-harness/">New</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
<td align="center"><strong>⛓️ ETH2 QuickStart</strong></td>
|
<td align="center"><strong>⛓️ ETH2 QuickStart</strong></td>
|
||||||
<td>DevOps / Ethereum</td>
|
<td>DevOps / Ethereum</td>
|
||||||
<td><code>cli-anything-eth2-quickstart</code></td>
|
<td><code>cli-anything-eth2-quickstart</code></td>
|
||||||
@@ -1441,6 +1448,7 @@ cli-anything/
|
|||||||
├── 📞 zoom/agent-harness/ # Zoom CLI (22 tests)
|
├── 📞 zoom/agent-harness/ # Zoom CLI (22 tests)
|
||||||
├── 🎵 musescore/agent-harness/ # MuseScore CLI (56 tests)
|
├── 🎵 musescore/agent-harness/ # MuseScore CLI (56 tests)
|
||||||
├── 📐 drawio/agent-harness/ # Draw.io CLI (138 tests)
|
├── 📐 drawio/agent-harness/ # Draw.io CLI (138 tests)
|
||||||
|
├── 🧪 eez-studio/agent-harness/ # EEZ Studio CLI (project, LVGL, SCPI automation)
|
||||||
├── ⛓️ eth2-quickstart/agent-harness/ # ETH2 QuickStart CLI (18 unit, 3 e2e skipped)
|
├── ⛓️ eth2-quickstart/agent-harness/ # ETH2 QuickStart CLI (18 unit, 3 e2e skipped)
|
||||||
├── 🧜 mermaid/agent-harness/ # Mermaid Live Editor CLI (10 tests)
|
├── 🧜 mermaid/agent-harness/ # Mermaid Live Editor CLI (10 tests)
|
||||||
├── ✨ anygen/agent-harness/ # AnyGen CLI (50 tests)
|
├── ✨ anygen/agent-harness/ # AnyGen CLI (50 tests)
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# EEZ Studio CLI-Anything Harness
|
||||||
|
|
||||||
|
## Target
|
||||||
|
|
||||||
|
EEZ Studio is an Electron/Node application for embedded UI projects, LVGL code generation, SCPI instrument models, and flow-based automation. The authoritative upstream source is `https://github.com/eez-open/studio`.
|
||||||
|
|
||||||
|
## Native Surfaces
|
||||||
|
|
||||||
|
- Project files are JSON documents with the `.eez-project` extension.
|
||||||
|
- LVGL projects store display metadata under `settings.general`, build configuration under `settings.build`, screens under `userPages`, widgets inside page `components`, and SCPI command models under `scpi`.
|
||||||
|
- EEZ Studio source exports build functions in `packages/project-editor/build/build.ts` and LVGL simulator support in `packages/project-editor/lvgl/docker-build/docker-build-lib.ts`.
|
||||||
|
- The harness uses native EEZ marker templates such as `//${eez-studio LVGL_SCREENS_DEF}` in `settings.build.files`.
|
||||||
|
|
||||||
|
## Harness Architecture
|
||||||
|
|
||||||
|
```text
|
||||||
|
eez-studio/agent-harness/
|
||||||
|
├── setup.py
|
||||||
|
└── cli_anything/eez_studio/
|
||||||
|
├── eez_studio_cli.py
|
||||||
|
├── core/
|
||||||
|
│ ├── project.py
|
||||||
|
│ ├── scpi.py
|
||||||
|
│ ├── session.py
|
||||||
|
│ └── export.py
|
||||||
|
├── utils/
|
||||||
|
│ ├── eez_studio_backend.py
|
||||||
|
│ └── repl_skin.py
|
||||||
|
└── tests/
|
||||||
|
├── test_core.py
|
||||||
|
├── test_full_e2e.py
|
||||||
|
└── TEST.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backend Rules
|
||||||
|
|
||||||
|
Unit commands manipulate `.eez-project` JSON directly and require no EEZ Studio install. Backend commands require the real EEZ Studio source tree:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/eez-open/studio.git
|
||||||
|
cd studio
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
export EEZ_STUDIO_SOURCE=/absolute/path/to/studio
|
||||||
|
```
|
||||||
|
|
||||||
|
`lvgl backend-inspect` invokes the built upstream `docker-build-lib.js` to parse project metadata. `lvgl simulator-build` uses the same upstream library plus Docker to build and verify the LVGL simulator artifacts (`index.html`, `index.js`, `index.wasm`).
|
||||||
|
|
||||||
|
If a future EEZ Studio release exposes a documented headless code-generation command, set `EEZ_STUDIO_BUILD_COMMAND` and run `lvgl build-files`. The harness does not synthesize EEZ exports in Python.
|
||||||
|
|
||||||
|
## Command Groups
|
||||||
|
|
||||||
|
- `project`: create, open, save, validate, inspect, set build settings, list pages/widgets.
|
||||||
|
- `lvgl`: add pages, labels, buttons, prepare build directories, invoke real backend inspect/build.
|
||||||
|
- `scpi`: add/list subsystems, commands, and command parameters.
|
||||||
|
- `backend`: report EEZ Studio source/build availability.
|
||||||
|
- `session`: status, undo, redo, and saved session metadata.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Unit tests cover native project JSON operations and subprocess JSON output without the backend. The default E2E suite verifies backend status and the unavailable-backend error path; live EEZ Studio backend inspection is opt-in with `EEZ_STUDIO_RUN_LIVE_BACKEND=1` and a built `EEZ_STUDIO_SOURCE`.
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# cli-anything-eez-studio
|
||||||
|
|
||||||
|
CLI harness for **EEZ Studio** project, LVGL UI, and SCPI workflows.
|
||||||
|
|
||||||
|
The CLI edits native `.eez-project` JSON files and calls the real EEZ Studio source backend for build/export operations when configured.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Python 3.10+
|
||||||
|
- Node.js for EEZ Studio backend commands
|
||||||
|
- EEZ Studio source tree for real backend export:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/eez-open/studio.git
|
||||||
|
cd studio
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
export EEZ_STUDIO_SOURCE=/absolute/path/to/studio
|
||||||
|
```
|
||||||
|
|
||||||
|
Full LVGL simulator builds also require Docker.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd eez-studio/agent-harness
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start the REPL
|
||||||
|
cli-anything-eez-studio
|
||||||
|
|
||||||
|
# Create a native EEZ Studio LVGL project
|
||||||
|
cli-anything-eez-studio --json project new -o app.eez-project --name TestPanel
|
||||||
|
|
||||||
|
# Add LVGL widgets and save automatically
|
||||||
|
cli-anything-eez-studio --project app.eez-project lvgl add-label --text "Ready"
|
||||||
|
cli-anything-eez-studio --project app.eez-project lvgl add-button --text "Run"
|
||||||
|
|
||||||
|
# Add SCPI command metadata
|
||||||
|
cli-anything-eez-studio --project app.eez-project scpi subsystem-add SOURCE
|
||||||
|
cli-anything-eez-studio --project app.eez-project scpi command-add SOURCE :VOLTage?
|
||||||
|
|
||||||
|
# Inspect through the real EEZ Studio backend
|
||||||
|
cli-anything-eez-studio --json --project app.eez-project lvgl backend-inspect
|
||||||
|
```
|
||||||
|
|
||||||
|
## Command Reference
|
||||||
|
|
||||||
|
| Group | Commands |
|
||||||
|
| --- | --- |
|
||||||
|
| `project` | `new`, `open`, `save`, `info`, `validate`, `pages`, `widgets`, `set`, `set-destination`, `add-build-file` |
|
||||||
|
| `lvgl` | `add-page`, `add-label`, `add-button`, `ensure-destination`, `backend-inspect`, `build-files`, `simulator-build`, `verify-simulator` |
|
||||||
|
| `scpi` | `subsystem-list`, `subsystem-add`, `command-list`, `command-add`, `parameter-add` |
|
||||||
|
| `backend` | `status` |
|
||||||
|
| `session` | `status`, `undo`, `redo`, `save-state`, `list` |
|
||||||
|
|
||||||
|
## JSON Output
|
||||||
|
|
||||||
|
Every command supports `--json` at the root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cli-anything-eez-studio --json --project app.eez-project project info
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd eez-studio/agent-harness
|
||||||
|
python3 -m pytest cli_anything/eez_studio/tests/test_core.py -v
|
||||||
|
python3 -m pytest cli_anything/eez_studio/tests/test_full_e2e.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
The default E2E suite does not require a real EEZ Studio backend; it verifies backend status and the structured unavailable-backend error path. To run the live backend inspection test, set `EEZ_STUDIO_RUN_LIVE_BACKEND=1` and `EEZ_STUDIO_SOURCE` to a built EEZ Studio checkout.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""CLI-Anything harness for EEZ Studio."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from .eez_studio_cli import main
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Core project/session helpers for cli-anything-eez-studio."""
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Export/build orchestration for EEZ Studio projects."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from ..utils import eez_studio_backend
|
||||||
|
from . import project as project_mod
|
||||||
|
|
||||||
|
|
||||||
|
def inspect_with_backend(project_path: str, source: str | None = None) -> dict[str, Any]:
|
||||||
|
return eez_studio_backend.inspect_project(project_path, source=source)
|
||||||
|
|
||||||
|
|
||||||
|
def build_files(project_path: str, timeout: int = 300) -> dict[str, Any]:
|
||||||
|
"""Run a real configured EEZ Studio build-files command."""
|
||||||
|
return eez_studio_backend.run_custom_build_command(project_path, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def simulator_build(
|
||||||
|
project_path: str,
|
||||||
|
output_dir: str,
|
||||||
|
source: str | None = None,
|
||||||
|
repository_name: str = "eez-framework",
|
||||||
|
docker_volume_name: str = "eez-studio-cli-anything",
|
||||||
|
timeout: int = 900,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return eez_studio_backend.build_full_simulator(
|
||||||
|
project_path=project_path,
|
||||||
|
output_dir=output_dir,
|
||||||
|
source=source,
|
||||||
|
repository_name=repository_name,
|
||||||
|
docker_volume_name=docker_volume_name,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_simulator_output(output_dir: str) -> dict[str, Any]:
|
||||||
|
output = Path(output_dir)
|
||||||
|
required = ["index.html", "index.js", "index.wasm"]
|
||||||
|
files: dict[str, dict[str, Any]] = {}
|
||||||
|
for file_name in required:
|
||||||
|
path = output / file_name
|
||||||
|
if not path.is_file():
|
||||||
|
raise RuntimeError(f"missing simulator artifact: {path}")
|
||||||
|
files[file_name] = {"path": str(path), "bytes": path.stat().st_size}
|
||||||
|
if path.stat().st_size <= 0:
|
||||||
|
raise RuntimeError(f"empty simulator artifact: {path}")
|
||||||
|
with open(output / "index.html", "rb") as handle:
|
||||||
|
prefix = handle.read(32)
|
||||||
|
if b"<!DOCTYPE html" not in prefix[:32] and b"<html" not in prefix.lower():
|
||||||
|
raise RuntimeError("index.html does not look like HTML")
|
||||||
|
with open(output / "index.wasm", "rb") as handle:
|
||||||
|
magic = handle.read(4)
|
||||||
|
if magic != b"\x00asm":
|
||||||
|
raise RuntimeError("index.wasm does not have WebAssembly magic bytes")
|
||||||
|
return {"output_dir": os.path.abspath(output_dir), "files": files}
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_destination(project_path: str) -> dict[str, Any]:
|
||||||
|
project = project_mod.load_project(project_path)
|
||||||
|
return project_mod.ensure_destination_dir(project_path, project)
|
||||||
@@ -0,0 +1,589 @@
|
|||||||
|
"""Native .eez-project JSON helpers.
|
||||||
|
|
||||||
|
EEZ Studio stores projects as JSON in files ending with ``.eez-project``.
|
||||||
|
This module edits that native format directly and keeps the generated shape
|
||||||
|
close to the upstream project editor model: settings, build templates, pages,
|
||||||
|
LVGL widgets, SCPI subsystems, and optional feature collections.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_LVGL_VERSION = "9.2.2"
|
||||||
|
DEFAULT_DESTINATION = "src/ui"
|
||||||
|
PROJECT_EXTENSION = ".eez-project"
|
||||||
|
|
||||||
|
|
||||||
|
def new_obj_id() -> str:
|
||||||
|
return uuid.uuid4().hex
|
||||||
|
|
||||||
|
|
||||||
|
def _locked_save_json(path: str | os.PathLike[str], data: Any, **dump_kwargs: Any) -> None:
|
||||||
|
path = os.fspath(path)
|
||||||
|
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
||||||
|
try:
|
||||||
|
handle = open(path, "r+", encoding="utf-8")
|
||||||
|
except FileNotFoundError:
|
||||||
|
handle = open(path, "w", encoding="utf-8")
|
||||||
|
with handle:
|
||||||
|
locked = False
|
||||||
|
try:
|
||||||
|
import fcntl
|
||||||
|
|
||||||
|
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
||||||
|
locked = True
|
||||||
|
except (ImportError, OSError):
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
handle.seek(0)
|
||||||
|
handle.truncate()
|
||||||
|
json.dump(data, handle, **dump_kwargs)
|
||||||
|
handle.write("\n")
|
||||||
|
handle.flush()
|
||||||
|
finally:
|
||||||
|
if locked:
|
||||||
|
import fcntl
|
||||||
|
|
||||||
|
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_identifier(name: str, fallback: str = "screen") -> str:
|
||||||
|
identifier = re.sub(r"[^0-9a-zA-Z_]+", "_", name.strip()).strip("_").lower()
|
||||||
|
if not identifier:
|
||||||
|
identifier = fallback
|
||||||
|
if identifier[0].isdigit():
|
||||||
|
identifier = f"{fallback}_{identifier}"
|
||||||
|
return identifier
|
||||||
|
|
||||||
|
|
||||||
|
def _build_templates() -> list[dict[str, Any]]:
|
||||||
|
"""Return conservative EEZ Studio build templates using upstream markers."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"fileName": "ui.h",
|
||||||
|
"description": "Main LVGL UI declarations",
|
||||||
|
"template": "\n".join(
|
||||||
|
[
|
||||||
|
"#pragma once",
|
||||||
|
'#include "lvgl/lvgl.h"',
|
||||||
|
"//${eez-studio LVGL_INCLUDE}",
|
||||||
|
"//${eez-studio EEZ_FOR_LVGL_CHECK}",
|
||||||
|
"#ifdef __cplusplus",
|
||||||
|
'extern "C" {',
|
||||||
|
"#endif",
|
||||||
|
"//${eez-studio GUI_ASSETS_DECL}",
|
||||||
|
"//${eez-studio LVGL_SCREENS_DECL}",
|
||||||
|
"//${eez-studio LVGL_STYLES_DECL}",
|
||||||
|
"//${eez-studio LVGL_IMAGES_DECL}",
|
||||||
|
"//${eez-studio LVGL_FONTS_DECL}",
|
||||||
|
"//${eez-studio LVGL_ACTIONS_DECL}",
|
||||||
|
"//${eez-studio LVGL_VARS_DECL}",
|
||||||
|
"void ui_init(void);",
|
||||||
|
"void ui_tick(void);",
|
||||||
|
"#ifdef __cplusplus",
|
||||||
|
"}",
|
||||||
|
"#endif",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fileName": "ui.c",
|
||||||
|
"description": "Main LVGL UI definitions",
|
||||||
|
"template": "\n".join(
|
||||||
|
[
|
||||||
|
'#include "ui.h"',
|
||||||
|
"",
|
||||||
|
"//${eez-studio GUI_ASSETS_DEF}",
|
||||||
|
"//${eez-studio LVGL_STYLES_DEF}",
|
||||||
|
"//${eez-studio LVGL_IMAGES_DEF}",
|
||||||
|
"//${eez-studio LVGL_ACTIONS_ARRAY_DEF}",
|
||||||
|
"//${eez-studio LVGL_NATIVE_VARS_TABLE_DEF}",
|
||||||
|
"",
|
||||||
|
"void ui_init(void) {",
|
||||||
|
" //${eez-studio LVGL_LOAD_FIRST_SCREEN}",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
"void ui_tick(void) {",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fileName": "screens.c",
|
||||||
|
"description": "LVGL screen construction",
|
||||||
|
"template": "\n".join(
|
||||||
|
[
|
||||||
|
'#include "ui.h"',
|
||||||
|
"",
|
||||||
|
"//${eez-studio LVGL_SCREENS_DEF}",
|
||||||
|
"//${eez-studio LVGL_SCREENS_DEF_EXT}",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fileName": "screens.h",
|
||||||
|
"description": "LVGL screen declarations",
|
||||||
|
"template": "\n".join(
|
||||||
|
[
|
||||||
|
"#pragma once",
|
||||||
|
'#include "ui.h"',
|
||||||
|
"",
|
||||||
|
"//${eez-studio LVGL_SCREENS_DECL}",
|
||||||
|
"//${eez-studio LVGL_SCREENS_DECL_EXT}",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fileName": "vars.h",
|
||||||
|
"description": "Native LVGL variable declarations",
|
||||||
|
"template": "\n".join(["#pragma once", "//${eez-studio LVGL_VARS_DECL}", ""]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fileName": "actions.h",
|
||||||
|
"description": "LVGL action declarations",
|
||||||
|
"template": "\n".join(["#pragma once", "//${eez-studio LVGL_ACTIONS_DECL}", ""]),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _base_widget(widget_type: str, name: str, **props: Any) -> dict[str, Any]:
|
||||||
|
widget = {
|
||||||
|
"objID": new_obj_id(),
|
||||||
|
"type": widget_type,
|
||||||
|
"name": name,
|
||||||
|
"left": props.pop("x", props.pop("left", 0)),
|
||||||
|
"top": props.pop("y", props.pop("top", 0)),
|
||||||
|
"width": props.pop("width", 120),
|
||||||
|
"height": props.pop("height", 40),
|
||||||
|
"hidden": False,
|
||||||
|
"clickableFlag": True,
|
||||||
|
"checkedFlag": False,
|
||||||
|
"disabledFlag": False,
|
||||||
|
"children": [],
|
||||||
|
}
|
||||||
|
widget.update(props)
|
||||||
|
return widget
|
||||||
|
|
||||||
|
|
||||||
|
def _screen_widget(name: str, width: int, height: int) -> dict[str, Any]:
|
||||||
|
return _base_widget(
|
||||||
|
"LVGLScreenWidget",
|
||||||
|
f"{name}_root",
|
||||||
|
left=0,
|
||||||
|
top=0,
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
clickableFlag=False,
|
||||||
|
children=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _page(name: str, width: int, height: int, page_id: int) -> dict[str, Any]:
|
||||||
|
identifier = sanitize_identifier(name)
|
||||||
|
return {
|
||||||
|
"objID": new_obj_id(),
|
||||||
|
"name": name,
|
||||||
|
"id": page_id,
|
||||||
|
"identifier": identifier,
|
||||||
|
"description": "",
|
||||||
|
"width": width,
|
||||||
|
"height": height,
|
||||||
|
"components": [_screen_widget(name, width, height)],
|
||||||
|
"connectionLines": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_project(
|
||||||
|
name: str = "Untitled",
|
||||||
|
display_width: int = 800,
|
||||||
|
display_height: int = 480,
|
||||||
|
lvgl_version: str = DEFAULT_LVGL_VERSION,
|
||||||
|
destination: str = DEFAULT_DESTINATION,
|
||||||
|
flow_support: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if display_width <= 0 or display_height <= 0:
|
||||||
|
raise ValueError("display dimensions must be positive")
|
||||||
|
if not lvgl_version:
|
||||||
|
raise ValueError("lvgl_version is required")
|
||||||
|
|
||||||
|
now = int(time.time())
|
||||||
|
return {
|
||||||
|
"objID": new_obj_id(),
|
||||||
|
"settings": {
|
||||||
|
"objID": new_obj_id(),
|
||||||
|
"general": {
|
||||||
|
"projectName": name,
|
||||||
|
"projectType": "lvgl",
|
||||||
|
"projectVersion": "v3",
|
||||||
|
"lvglVersion": lvgl_version,
|
||||||
|
"flowSupport": bool(flow_support),
|
||||||
|
"displayWidth": display_width,
|
||||||
|
"displayHeight": display_height,
|
||||||
|
"colorBpp": "32",
|
||||||
|
"imports": [],
|
||||||
|
"extensions": [],
|
||||||
|
"masterProject": "",
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"configurations": [],
|
||||||
|
"files": _build_templates(),
|
||||||
|
"destinationFolder": destination,
|
||||||
|
"separateFolderForImagesAndFonts": False,
|
||||||
|
"lvglInclude": "lvgl/lvgl.h",
|
||||||
|
"screensLifetimeSupport": False,
|
||||||
|
"generateSourceCodeForEezFramework": False,
|
||||||
|
"compressFlowDefinition": False,
|
||||||
|
"executionQueueSize": 1000,
|
||||||
|
"expressionEvaluatorStackSize": 20,
|
||||||
|
"imageExportMode": "source",
|
||||||
|
"fontExportMode": "source",
|
||||||
|
"fileSystemPath": "",
|
||||||
|
"useDockerDesktop": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"variables": {"globalVariables": []},
|
||||||
|
"actions": [],
|
||||||
|
"userPages": [_page("Main", display_width, display_height, 1)],
|
||||||
|
"userWidgets": [],
|
||||||
|
"styles": [],
|
||||||
|
"lvglStyles": {"styles": []},
|
||||||
|
"lvglGroups": {
|
||||||
|
"groups": [],
|
||||||
|
"defaultGroupForEncoderInSimulator": "",
|
||||||
|
"defaultGroupForKeyboardInSimulator": "",
|
||||||
|
},
|
||||||
|
"fonts": [],
|
||||||
|
"bitmaps": [],
|
||||||
|
"texts": {"languages": [], "resources": []},
|
||||||
|
"scpi": {"subsystems": [], "enums": []},
|
||||||
|
"instrumentCommands": {"commands": []},
|
||||||
|
"shortcuts": {"shortcuts": []},
|
||||||
|
"micropython": {"code": ""},
|
||||||
|
"extensionDefinitions": [],
|
||||||
|
"changes": {"changes": []},
|
||||||
|
"readme": {"text": ""},
|
||||||
|
"colors": [],
|
||||||
|
"themes": [],
|
||||||
|
"themesVersion": 1,
|
||||||
|
"cliAnything": {
|
||||||
|
"harness": "cli-anything-eez-studio",
|
||||||
|
"createdAt": now,
|
||||||
|
"format": "eez-project-json",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_project(path: str | os.PathLike[str]) -> dict[str, Any]:
|
||||||
|
path = os.fspath(path)
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
raise FileNotFoundError(f"EEZ project file not found: {path}")
|
||||||
|
with open(path, "r", encoding="utf-8") as handle:
|
||||||
|
data = json.load(handle)
|
||||||
|
validate_project(data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def save_project(project: dict[str, Any], path: str | os.PathLike[str]) -> dict[str, Any]:
|
||||||
|
validate_project(project)
|
||||||
|
_locked_save_json(path, project, indent=2, sort_keys=False)
|
||||||
|
return {"path": os.path.abspath(os.fspath(path)), "bytes": os.path.getsize(path)}
|
||||||
|
|
||||||
|
|
||||||
|
def clone_project(project: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return copy.deepcopy(project)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_project(project: dict[str, Any]) -> list[str]:
|
||||||
|
errors: list[str] = []
|
||||||
|
if not isinstance(project, dict):
|
||||||
|
raise ValueError("EEZ project must be a JSON object")
|
||||||
|
settings = project.get("settings")
|
||||||
|
if not isinstance(settings, dict):
|
||||||
|
errors.append("missing settings object")
|
||||||
|
general = settings.get("general") if isinstance(settings, dict) else None
|
||||||
|
if not isinstance(general, dict):
|
||||||
|
errors.append("missing settings.general object")
|
||||||
|
else:
|
||||||
|
if general.get("projectType") not in {"lvgl", "dashboard", "firmware", "resource"}:
|
||||||
|
errors.append("settings.general.projectType is missing or unsupported")
|
||||||
|
if not general.get("lvglVersion") and general.get("projectType") == "lvgl":
|
||||||
|
errors.append("settings.general.lvglVersion is required for LVGL projects")
|
||||||
|
if int(general.get("displayWidth", 0) or 0) <= 0:
|
||||||
|
errors.append("settings.general.displayWidth must be positive")
|
||||||
|
if int(general.get("displayHeight", 0) or 0) <= 0:
|
||||||
|
errors.append("settings.general.displayHeight must be positive")
|
||||||
|
if not isinstance(project.get("userPages", []), list):
|
||||||
|
errors.append("userPages must be an array")
|
||||||
|
if not isinstance(project.get("scpi", {}), dict):
|
||||||
|
errors.append("scpi must be an object")
|
||||||
|
if errors:
|
||||||
|
raise ValueError("; ".join(errors))
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def get_general(project: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return project.setdefault("settings", {}).setdefault("general", {})
|
||||||
|
|
||||||
|
|
||||||
|
def get_build(project: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return project.setdefault("settings", {}).setdefault("build", {})
|
||||||
|
|
||||||
|
|
||||||
|
def set_general(project: dict[str, Any], key: str, value: Any) -> dict[str, Any]:
|
||||||
|
allowed = {
|
||||||
|
"projectName",
|
||||||
|
"lvglVersion",
|
||||||
|
"flowSupport",
|
||||||
|
"displayWidth",
|
||||||
|
"displayHeight",
|
||||||
|
"colorBpp",
|
||||||
|
}
|
||||||
|
if key not in allowed:
|
||||||
|
raise ValueError(f"unsupported settings.general key: {key}")
|
||||||
|
if key in {"displayWidth", "displayHeight"}:
|
||||||
|
value = int(value)
|
||||||
|
if value <= 0:
|
||||||
|
raise ValueError(f"{key} must be positive")
|
||||||
|
if key == "flowSupport":
|
||||||
|
value = _coerce_bool(value)
|
||||||
|
get_general(project)[key] = value
|
||||||
|
return project_info(project)
|
||||||
|
|
||||||
|
|
||||||
|
def set_build_destination(project: dict[str, Any], destination: str) -> dict[str, Any]:
|
||||||
|
if not destination:
|
||||||
|
raise ValueError("destination must not be empty")
|
||||||
|
get_build(project)["destinationFolder"] = destination.replace("\\", "/")
|
||||||
|
return project_info(project)
|
||||||
|
|
||||||
|
|
||||||
|
def add_build_file(
|
||||||
|
project: dict[str, Any],
|
||||||
|
file_name: str,
|
||||||
|
template: str,
|
||||||
|
description: str = "",
|
||||||
|
replace: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not file_name:
|
||||||
|
raise ValueError("file_name is required")
|
||||||
|
files = get_build(project).setdefault("files", [])
|
||||||
|
existing = next((entry for entry in files if entry.get("fileName") == file_name), None)
|
||||||
|
if existing and not replace:
|
||||||
|
raise ValueError(f"build file already exists: {file_name}")
|
||||||
|
entry = {"fileName": file_name, "description": description, "template": template}
|
||||||
|
if existing:
|
||||||
|
existing.update(entry)
|
||||||
|
else:
|
||||||
|
files.append(entry)
|
||||||
|
return {"fileName": file_name, "count": len(files)}
|
||||||
|
|
||||||
|
|
||||||
|
def project_info(project: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
general = get_general(project)
|
||||||
|
build = get_build(project)
|
||||||
|
pages = project.get("userPages") or []
|
||||||
|
widgets = list_widgets(project)
|
||||||
|
scpi = project.get("scpi") or {}
|
||||||
|
subsystems = scpi.get("subsystems") or []
|
||||||
|
command_count = sum(len(s.get("commands") or []) for s in subsystems)
|
||||||
|
return {
|
||||||
|
"project_name": general.get("projectName") or "<unnamed>",
|
||||||
|
"project_type": general.get("projectType"),
|
||||||
|
"project_version": general.get("projectVersion"),
|
||||||
|
"lvgl_version": general.get("lvglVersion"),
|
||||||
|
"flow_support": bool(general.get("flowSupport")),
|
||||||
|
"display": {
|
||||||
|
"width": general.get("displayWidth"),
|
||||||
|
"height": general.get("displayHeight"),
|
||||||
|
"color_bpp": general.get("colorBpp"),
|
||||||
|
},
|
||||||
|
"destination_folder": build.get("destinationFolder"),
|
||||||
|
"build_file_count": len(build.get("files") or []),
|
||||||
|
"page_count": len(pages),
|
||||||
|
"widget_count": len(widgets),
|
||||||
|
"scpi_subsystems": len(subsystems),
|
||||||
|
"scpi_commands": command_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_pages(project: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
pages = []
|
||||||
|
for index, page in enumerate(project.get("userPages") or []):
|
||||||
|
widgets = _page_widgets(page)
|
||||||
|
pages.append(
|
||||||
|
{
|
||||||
|
"index": index,
|
||||||
|
"id": page.get("id"),
|
||||||
|
"name": page.get("name"),
|
||||||
|
"identifier": page.get("identifier"),
|
||||||
|
"width": page.get("width"),
|
||||||
|
"height": page.get("height"),
|
||||||
|
"widget_count": len(widgets),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return pages
|
||||||
|
|
||||||
|
|
||||||
|
def add_page(project: dict[str, Any], name: str, width: int | None = None, height: int | None = None) -> dict[str, Any]:
|
||||||
|
pages = project.setdefault("userPages", [])
|
||||||
|
general = get_general(project)
|
||||||
|
page = _page(
|
||||||
|
name,
|
||||||
|
int(width or general.get("displayWidth") or 800),
|
||||||
|
int(height or general.get("displayHeight") or 480),
|
||||||
|
len(pages) + 1,
|
||||||
|
)
|
||||||
|
pages.append(page)
|
||||||
|
return {"page": page.get("name"), "index": len(pages) - 1, "id": page.get("id")}
|
||||||
|
|
||||||
|
|
||||||
|
def _page_widgets(page: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
widgets: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
def walk(widget: dict[str, Any], parent: str | None = None) -> None:
|
||||||
|
item = dict(widget)
|
||||||
|
item["_parent"] = parent
|
||||||
|
widgets.append(item)
|
||||||
|
for child in widget.get("children") or []:
|
||||||
|
if isinstance(child, dict):
|
||||||
|
walk(child, widget.get("objID"))
|
||||||
|
|
||||||
|
for component in page.get("components") or []:
|
||||||
|
if isinstance(component, dict):
|
||||||
|
walk(component)
|
||||||
|
return widgets
|
||||||
|
|
||||||
|
|
||||||
|
def list_widgets(project: dict[str, Any], page_name: str | None = None) -> list[dict[str, Any]]:
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
for page in project.get("userPages") or []:
|
||||||
|
if page_name and page.get("name") != page_name:
|
||||||
|
continue
|
||||||
|
for widget in _page_widgets(page):
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"page": page.get("name"),
|
||||||
|
"objID": widget.get("objID"),
|
||||||
|
"type": widget.get("type"),
|
||||||
|
"name": widget.get("name"),
|
||||||
|
"left": widget.get("left"),
|
||||||
|
"top": widget.get("top"),
|
||||||
|
"width": widget.get("width"),
|
||||||
|
"height": widget.get("height"),
|
||||||
|
"text": widget.get("text"),
|
||||||
|
"parent": widget.get("_parent"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def find_page(project: dict[str, Any], page_name: str) -> dict[str, Any]:
|
||||||
|
for page in project.get("userPages") or []:
|
||||||
|
if page.get("name") == page_name:
|
||||||
|
return page
|
||||||
|
raise ValueError(f"page not found: {page_name}")
|
||||||
|
|
||||||
|
|
||||||
|
def _screen_root(page: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
components = page.setdefault("components", [])
|
||||||
|
if not components:
|
||||||
|
components.append(_screen_widget(page.get("name", "Main"), page.get("width", 800), page.get("height", 480)))
|
||||||
|
return components[0]
|
||||||
|
|
||||||
|
|
||||||
|
def add_label(
|
||||||
|
project: dict[str, Any],
|
||||||
|
page_name: str,
|
||||||
|
text: str,
|
||||||
|
name: str | None = None,
|
||||||
|
x: int = 20,
|
||||||
|
y: int = 20,
|
||||||
|
width: int = 160,
|
||||||
|
height: int = 32,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
page = find_page(project, page_name)
|
||||||
|
widget = _base_widget(
|
||||||
|
"LVGLLabelWidget",
|
||||||
|
name or sanitize_identifier(text, "label"),
|
||||||
|
x=x,
|
||||||
|
y=y,
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
text=text,
|
||||||
|
clickableFlag=False,
|
||||||
|
)
|
||||||
|
_screen_root(page).setdefault("children", []).append(widget)
|
||||||
|
return {"page": page_name, "objID": widget["objID"], "type": widget["type"], "name": widget["name"]}
|
||||||
|
|
||||||
|
|
||||||
|
def add_button(
|
||||||
|
project: dict[str, Any],
|
||||||
|
page_name: str,
|
||||||
|
text: str,
|
||||||
|
name: str | None = None,
|
||||||
|
x: int = 20,
|
||||||
|
y: int = 72,
|
||||||
|
width: int = 140,
|
||||||
|
height: int = 48,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
page = find_page(project, page_name)
|
||||||
|
button_name = name or sanitize_identifier(text, "button")
|
||||||
|
label = _base_widget(
|
||||||
|
"LVGLLabelWidget",
|
||||||
|
f"{button_name}_label",
|
||||||
|
x=0,
|
||||||
|
y=0,
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
text=text,
|
||||||
|
clickableFlag=False,
|
||||||
|
)
|
||||||
|
button = _base_widget(
|
||||||
|
"LVGLButtonWidget",
|
||||||
|
button_name,
|
||||||
|
x=x,
|
||||||
|
y=y,
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
children=[label],
|
||||||
|
)
|
||||||
|
_screen_root(page).setdefault("children", []).append(button)
|
||||||
|
return {"page": page_name, "objID": button["objID"], "type": button["type"], "name": button["name"]}
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_destination_dir(project_path: str | os.PathLike[str], project: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
destination = get_build(project).get("destinationFolder") or DEFAULT_DESTINATION
|
||||||
|
absolute = Path(project_path).resolve().parent / destination
|
||||||
|
absolute.mkdir(parents=True, exist_ok=True)
|
||||||
|
return {"destination": str(absolute), "exists": absolute.is_dir()}
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_bool(value: Any) -> bool:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
if value.lower() in {"1", "true", "yes", "on"}:
|
||||||
|
return True
|
||||||
|
if value.lower() in {"0", "false", "no", "off"}:
|
||||||
|
return False
|
||||||
|
return bool(value)
|
||||||
|
|
||||||
|
|
||||||
|
def write_template_project(path: str | os.PathLike[str], **kwargs: Any) -> dict[str, Any]:
|
||||||
|
project = create_project(**kwargs)
|
||||||
|
result = save_project(project, path)
|
||||||
|
result.update(project_info(project))
|
||||||
|
return result
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""SCPI helpers for native EEZ project JSON."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def _scpi(project: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return project.setdefault("scpi", {"subsystems": [], "enums": []})
|
||||||
|
|
||||||
|
|
||||||
|
def list_subsystems(project: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
rows = []
|
||||||
|
for subsystem in _scpi(project).setdefault("subsystems", []):
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"name": subsystem.get("name"),
|
||||||
|
"description": subsystem.get("description", ""),
|
||||||
|
"command_count": len(subsystem.get("commands") or []),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def add_subsystem(project: dict[str, Any], name: str, description: str = "") -> dict[str, Any]:
|
||||||
|
if not name:
|
||||||
|
raise ValueError("subsystem name is required")
|
||||||
|
subsystems = _scpi(project).setdefault("subsystems", [])
|
||||||
|
if any(item.get("name") == name for item in subsystems):
|
||||||
|
raise ValueError(f"SCPI subsystem already exists: {name}")
|
||||||
|
entry = {"name": name, "description": description, "helpLink": "", "commands": []}
|
||||||
|
subsystems.append(entry)
|
||||||
|
return {"name": name, "command_count": 0}
|
||||||
|
|
||||||
|
|
||||||
|
def find_subsystem(project: dict[str, Any], name: str) -> dict[str, Any]:
|
||||||
|
for subsystem in _scpi(project).setdefault("subsystems", []):
|
||||||
|
if subsystem.get("name") == name:
|
||||||
|
return subsystem
|
||||||
|
raise ValueError(f"SCPI subsystem not found: {name}")
|
||||||
|
|
||||||
|
|
||||||
|
def list_commands(project: dict[str, Any], subsystem_name: str | None = None) -> list[dict[str, Any]]:
|
||||||
|
rows = []
|
||||||
|
subsystems = _scpi(project).setdefault("subsystems", [])
|
||||||
|
for subsystem in subsystems:
|
||||||
|
if subsystem_name and subsystem.get("name") != subsystem_name:
|
||||||
|
continue
|
||||||
|
for command in subsystem.get("commands") or []:
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"subsystem": subsystem.get("name"),
|
||||||
|
"name": command.get("name"),
|
||||||
|
"query": str(command.get("name", "")).endswith("?"),
|
||||||
|
"description": command.get("description", ""),
|
||||||
|
"parameters": len(command.get("parameters") or []),
|
||||||
|
"response": command.get("response", {}).get("type", []),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def add_command(
|
||||||
|
project: dict[str, Any],
|
||||||
|
subsystem_name: str,
|
||||||
|
name: str,
|
||||||
|
description: str = "",
|
||||||
|
response_type: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not name:
|
||||||
|
raise ValueError("command name is required")
|
||||||
|
subsystem = find_subsystem(project, subsystem_name)
|
||||||
|
commands = subsystem.setdefault("commands", [])
|
||||||
|
if any(command.get("name") == name for command in commands):
|
||||||
|
raise ValueError(f"SCPI command already exists in {subsystem_name}: {name}")
|
||||||
|
command: dict[str, Any] = {
|
||||||
|
"name": name,
|
||||||
|
"description": description,
|
||||||
|
"helpLink": "",
|
||||||
|
"usedIn": [],
|
||||||
|
"parameters": [],
|
||||||
|
"sendsBackDataBlock": False,
|
||||||
|
}
|
||||||
|
if name.endswith("?"):
|
||||||
|
command["response"] = {
|
||||||
|
"type": [{"type": response_type or "quoted-string"}],
|
||||||
|
"description": description,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
command["response"] = {}
|
||||||
|
commands.append(command)
|
||||||
|
return {"subsystem": subsystem_name, "name": name, "query": name.endswith("?")}
|
||||||
|
|
||||||
|
|
||||||
|
def add_parameter(
|
||||||
|
project: dict[str, Any],
|
||||||
|
subsystem_name: str,
|
||||||
|
command_name: str,
|
||||||
|
name: str,
|
||||||
|
parameter_type: str = "nr1",
|
||||||
|
optional: bool = False,
|
||||||
|
description: str = "",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
subsystem = find_subsystem(project, subsystem_name)
|
||||||
|
for command in subsystem.get("commands") or []:
|
||||||
|
if command.get("name") == command_name:
|
||||||
|
parameters = command.setdefault("parameters", [])
|
||||||
|
if any(parameter.get("name") == name for parameter in parameters):
|
||||||
|
raise ValueError(f"SCPI parameter already exists: {name}")
|
||||||
|
parameters.append(
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"type": [{"type": parameter_type}],
|
||||||
|
"isOptional": bool(optional),
|
||||||
|
"description": description,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"subsystem": subsystem_name,
|
||||||
|
"command": command_name,
|
||||||
|
"name": name,
|
||||||
|
"type": parameter_type,
|
||||||
|
}
|
||||||
|
raise ValueError(f"SCPI command not found: {command_name}")
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"""Session state and undo/redo for EEZ Studio project editing."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from . import project as project_mod
|
||||||
|
|
||||||
|
|
||||||
|
SESSION_DIR = Path.home() / ".eez-studio-cli" / "sessions"
|
||||||
|
MAX_UNDO_DEPTH = 50
|
||||||
|
|
||||||
|
|
||||||
|
class Session:
|
||||||
|
def __init__(self, session_id: str | None = None):
|
||||||
|
self.session_id = session_id or f"session_{int(time.time())}"
|
||||||
|
self.project_path: str | None = None
|
||||||
|
self.project: dict[str, Any] | None = None
|
||||||
|
self._undo_stack: list[dict[str, Any]] = []
|
||||||
|
self._redo_stack: list[dict[str, Any]] = []
|
||||||
|
self._modified = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_open(self) -> bool:
|
||||||
|
return self.project is not None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_modified(self) -> bool:
|
||||||
|
return self._modified
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
if not self.project:
|
||||||
|
return ""
|
||||||
|
return project_mod.get_general(self.project).get("projectName") or ""
|
||||||
|
|
||||||
|
def set_project(self, project: dict[str, Any], path: str | None = None) -> None:
|
||||||
|
self.project = project
|
||||||
|
self.project_path = os.path.abspath(path) if path else None
|
||||||
|
self._undo_stack.clear()
|
||||||
|
self._redo_stack.clear()
|
||||||
|
self._modified = False
|
||||||
|
|
||||||
|
def open_project(self, path: str) -> None:
|
||||||
|
self.set_project(project_mod.load_project(path), path)
|
||||||
|
|
||||||
|
def save_project(self, path: str | None = None) -> dict[str, Any]:
|
||||||
|
if self.project is None:
|
||||||
|
raise RuntimeError("no project is open")
|
||||||
|
target = path or self.project_path
|
||||||
|
if not target:
|
||||||
|
raise RuntimeError("no save path specified")
|
||||||
|
result = project_mod.save_project(self.project, target)
|
||||||
|
self.project_path = result["path"]
|
||||||
|
self._modified = False
|
||||||
|
return result
|
||||||
|
|
||||||
|
def checkpoint(self) -> None:
|
||||||
|
if self.project is None:
|
||||||
|
raise RuntimeError("no project is open")
|
||||||
|
self._undo_stack.append(project_mod.clone_project(self.project))
|
||||||
|
if len(self._undo_stack) > MAX_UNDO_DEPTH:
|
||||||
|
self._undo_stack.pop(0)
|
||||||
|
self._redo_stack.clear()
|
||||||
|
self._modified = True
|
||||||
|
|
||||||
|
def undo(self) -> bool:
|
||||||
|
if self.project is None or not self._undo_stack:
|
||||||
|
return False
|
||||||
|
self._redo_stack.append(project_mod.clone_project(self.project))
|
||||||
|
self.project = self._undo_stack.pop()
|
||||||
|
self._modified = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
def redo(self) -> bool:
|
||||||
|
if self.project is None or not self._redo_stack:
|
||||||
|
return False
|
||||||
|
self._undo_stack.append(project_mod.clone_project(self.project))
|
||||||
|
self.project = self._redo_stack.pop()
|
||||||
|
self._modified = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
def status(self) -> dict[str, Any]:
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"session_id": self.session_id,
|
||||||
|
"project_open": self.is_open,
|
||||||
|
"project_path": self.project_path,
|
||||||
|
"modified": self._modified,
|
||||||
|
"undo_available": len(self._undo_stack),
|
||||||
|
"redo_available": len(self._redo_stack),
|
||||||
|
}
|
||||||
|
if self.project is not None:
|
||||||
|
result.update(project_mod.project_info(self.project))
|
||||||
|
return result
|
||||||
|
|
||||||
|
def save_state(self) -> str:
|
||||||
|
SESSION_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
state = self.status()
|
||||||
|
state["timestamp"] = time.time()
|
||||||
|
path = SESSION_DIR / f"{self.session_id}.json"
|
||||||
|
project_mod._locked_save_json(path, state, indent=2, sort_keys=True)
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def list_states(cls) -> list[dict[str, Any]]:
|
||||||
|
SESSION_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
states: list[dict[str, Any]] = []
|
||||||
|
for path in SESSION_DIR.glob("*.json"):
|
||||||
|
try:
|
||||||
|
states.append(json.loads(path.read_text(encoding="utf-8")))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
continue
|
||||||
|
states.sort(key=lambda item: item.get("timestamp", 0), reverse=True)
|
||||||
|
return states
|
||||||
@@ -0,0 +1,612 @@
|
|||||||
|
"""cli-anything-eez-studio: CLI harness for EEZ Studio.
|
||||||
|
|
||||||
|
The CLI edits native .eez-project JSON and calls the real EEZ Studio source
|
||||||
|
backend for build/export workflows when configured.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from cli_anything.eez_studio import __version__
|
||||||
|
from cli_anything.eez_studio.core import export as export_mod
|
||||||
|
from cli_anything.eez_studio.core import project as project_mod
|
||||||
|
from cli_anything.eez_studio.core import scpi as scpi_mod
|
||||||
|
from cli_anything.eez_studio.core.session import Session
|
||||||
|
from cli_anything.eez_studio.utils import eez_studio_backend
|
||||||
|
from cli_anything.eez_studio.utils.repl_skin import ReplSkin
|
||||||
|
|
||||||
|
|
||||||
|
_session: Session | None = None
|
||||||
|
_json_output = False
|
||||||
|
_repl_mode = False
|
||||||
|
|
||||||
|
|
||||||
|
def get_session() -> Session:
|
||||||
|
global _session
|
||||||
|
if _session is None:
|
||||||
|
_session = Session()
|
||||||
|
return _session
|
||||||
|
|
||||||
|
|
||||||
|
def _same_project_path(session: Session, project_path: str) -> bool:
|
||||||
|
return session.project_path == os.path.abspath(project_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _configure_session(session_id: str | None, project_path: str | None) -> None:
|
||||||
|
global _session
|
||||||
|
if _repl_mode:
|
||||||
|
session = get_session()
|
||||||
|
if session_id and session.session_id != session_id:
|
||||||
|
session.session_id = session_id
|
||||||
|
if project_path and not _same_project_path(session, project_path):
|
||||||
|
session.open_project(project_path)
|
||||||
|
return
|
||||||
|
|
||||||
|
_session = Session(session_id)
|
||||||
|
if project_path:
|
||||||
|
_session.open_project(project_path)
|
||||||
|
|
||||||
|
|
||||||
|
def output(data: Any, message: str = "") -> None:
|
||||||
|
if _json_output:
|
||||||
|
click.echo(json.dumps(data, indent=2, default=str))
|
||||||
|
return
|
||||||
|
if message:
|
||||||
|
click.echo(message)
|
||||||
|
_pretty(data)
|
||||||
|
|
||||||
|
|
||||||
|
def _pretty(data: Any, indent: int = 0) -> None:
|
||||||
|
prefix = " " * indent
|
||||||
|
if isinstance(data, dict):
|
||||||
|
for key, value in data.items():
|
||||||
|
if isinstance(value, (dict, list)):
|
||||||
|
click.echo(f"{prefix}{key}:")
|
||||||
|
_pretty(value, indent + 1)
|
||||||
|
else:
|
||||||
|
click.echo(f"{prefix}{key}: {value}")
|
||||||
|
elif isinstance(data, list):
|
||||||
|
for index, item in enumerate(data):
|
||||||
|
if isinstance(item, dict):
|
||||||
|
click.echo(f"{prefix}[{index}]")
|
||||||
|
_pretty(item, indent + 1)
|
||||||
|
else:
|
||||||
|
click.echo(f"{prefix}- {item}")
|
||||||
|
else:
|
||||||
|
click.echo(f"{prefix}{data}")
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_error(exc: Exception) -> None:
|
||||||
|
if _json_output:
|
||||||
|
click.echo(
|
||||||
|
json.dumps({"error": str(exc), "type": type(exc).__name__}, indent=2),
|
||||||
|
err=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
click.echo(f"Error: {exc}", err=True)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_error(func):
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
try:
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
except (FileNotFoundError, FileExistsError, ValueError, RuntimeError, OSError) as exc:
|
||||||
|
_emit_error(exc)
|
||||||
|
if not _repl_mode:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
wrapper.__name__ = func.__name__
|
||||||
|
wrapper.__doc__ = func.__doc__
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def require_project() -> Session:
|
||||||
|
session = get_session()
|
||||||
|
if session.project is None:
|
||||||
|
raise RuntimeError("no project is open; use --project PATH or `project new -o PATH`")
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_changed(session: Session) -> None:
|
||||||
|
session._modified = True
|
||||||
|
|
||||||
|
|
||||||
|
@click.group(invoke_without_command=True)
|
||||||
|
@click.option("--json", "json_mode", is_flag=True, help="Output machine-readable JSON.")
|
||||||
|
@click.option("--project", "-p", "project_path", default=None, help="Open a .eez-project file.")
|
||||||
|
@click.option("--session", "session_id", default=None, help="Session ID to use.")
|
||||||
|
@click.option("--dry-run", is_flag=True, help="Do not auto-save modified project files.")
|
||||||
|
@click.pass_context
|
||||||
|
def cli(ctx: click.Context, json_mode: bool, project_path: str | None, session_id: str | None, dry_run: bool) -> None:
|
||||||
|
"""EEZ Studio CLI harness for project, LVGL, and SCPI workflows.
|
||||||
|
|
||||||
|
Run without a subcommand to enter the interactive REPL.
|
||||||
|
"""
|
||||||
|
global _json_output
|
||||||
|
_json_output = json_mode
|
||||||
|
_configure_session(session_id, project_path)
|
||||||
|
ctx.ensure_object(dict)
|
||||||
|
ctx.obj["project_path"] = project_path
|
||||||
|
ctx.obj["json"] = json_mode
|
||||||
|
ctx.obj["dry_run"] = dry_run
|
||||||
|
|
||||||
|
@ctx.call_on_close
|
||||||
|
def _auto_save() -> None:
|
||||||
|
if dry_run or _repl_mode:
|
||||||
|
return
|
||||||
|
session = get_session()
|
||||||
|
if session.project is not None and session.is_modified and session.project_path:
|
||||||
|
session.save_project()
|
||||||
|
|
||||||
|
if ctx.invoked_subcommand is None:
|
||||||
|
ctx.invoke(repl, project_path=None)
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.option("--project", "-p", "project_path", default=None, help="Project file to open in REPL.")
|
||||||
|
@click.pass_context
|
||||||
|
def repl(ctx: click.Context, project_path: str | None) -> None:
|
||||||
|
"""Start the interactive REPL."""
|
||||||
|
global _repl_mode
|
||||||
|
_repl_mode = True
|
||||||
|
skin = ReplSkin("eez-studio", version=__version__)
|
||||||
|
skin.print_banner()
|
||||||
|
|
||||||
|
session = get_session()
|
||||||
|
if project_path:
|
||||||
|
try:
|
||||||
|
session.open_project(project_path)
|
||||||
|
except Exception as exc:
|
||||||
|
skin.error(str(exc))
|
||||||
|
|
||||||
|
commands = {
|
||||||
|
"project new -o app.eez-project": "Create an LVGL project",
|
||||||
|
"project info": "Show project summary",
|
||||||
|
"project save": "Save current project",
|
||||||
|
"project widgets": "List LVGL widgets",
|
||||||
|
"lvgl add-label --text Hello": "Add a label to a screen",
|
||||||
|
"lvgl add-button --text Run": "Add a button to a screen",
|
||||||
|
"lvgl ensure-destination": "Create the build destination directory",
|
||||||
|
"lvgl backend-inspect": "Inspect through EEZ Studio backend",
|
||||||
|
"lvgl simulator-build out": "Run full LVGL simulator backend",
|
||||||
|
"scpi subsystem-add SOURCE": "Add a SCPI subsystem",
|
||||||
|
"scpi command-add SOURCE :VOLTage?": "Add a SCPI command",
|
||||||
|
"session undo / redo": "Undo or redo project mutations",
|
||||||
|
"backend status": "Check EEZ Studio backend availability",
|
||||||
|
"help": "Show this help",
|
||||||
|
"quit / exit": "Exit",
|
||||||
|
}
|
||||||
|
pt_session = skin.create_prompt_session()
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
line = skin.get_input(pt_session, project_name=session.name, modified=session.is_modified)
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
break
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
if line.lower() in {"quit", "exit", "q"}:
|
||||||
|
if session.is_modified:
|
||||||
|
skin.warning("Unsaved changes. Use `project save` before exiting.")
|
||||||
|
break
|
||||||
|
if line.lower() in {"help", "h", "?"}:
|
||||||
|
skin.help(commands)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
args = shlex.split(line)
|
||||||
|
if session.project_path and "--project" not in args and "-p" not in args:
|
||||||
|
args = ["--project", session.project_path] + args
|
||||||
|
if ctx.obj and ctx.obj.get("json"):
|
||||||
|
args = ["--json"] + args
|
||||||
|
cli.main(args=args, standalone_mode=False)
|
||||||
|
except SystemExit:
|
||||||
|
pass
|
||||||
|
except Exception as exc:
|
||||||
|
skin.error(str(exc))
|
||||||
|
skin.print_goodbye()
|
||||||
|
_repl_mode = False
|
||||||
|
|
||||||
|
|
||||||
|
@cli.group()
|
||||||
|
def project() -> None:
|
||||||
|
"""Project management and native .eez-project inspection."""
|
||||||
|
|
||||||
|
|
||||||
|
@project.command("new")
|
||||||
|
@click.option("--name", "-n", default="Untitled", help="Project name.")
|
||||||
|
@click.option("--width", type=int, default=800, help="LVGL display width.")
|
||||||
|
@click.option("--height", type=int, default=480, help="LVGL display height.")
|
||||||
|
@click.option("--lvgl-version", default=project_mod.DEFAULT_LVGL_VERSION, help="LVGL version.")
|
||||||
|
@click.option("--destination", default=project_mod.DEFAULT_DESTINATION, help="Build destination folder.")
|
||||||
|
@click.option("--flow-support", is_flag=True, help="Enable EEZ Flow support flag.")
|
||||||
|
@click.option("--output", "-o", "output_path", required=True, help="Output .eez-project path.")
|
||||||
|
@handle_error
|
||||||
|
def project_new(
|
||||||
|
name: str,
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
lvgl_version: str,
|
||||||
|
destination: str,
|
||||||
|
flow_support: bool,
|
||||||
|
output_path: str,
|
||||||
|
) -> None:
|
||||||
|
"""Create a new native EEZ Studio LVGL project."""
|
||||||
|
project_data = project_mod.create_project(
|
||||||
|
name=name,
|
||||||
|
display_width=width,
|
||||||
|
display_height=height,
|
||||||
|
lvgl_version=lvgl_version,
|
||||||
|
destination=destination,
|
||||||
|
flow_support=flow_support,
|
||||||
|
)
|
||||||
|
session = get_session()
|
||||||
|
session.set_project(project_data, output_path)
|
||||||
|
result = session.save_project(output_path)
|
||||||
|
result.update(project_mod.project_info(project_data))
|
||||||
|
output(result, f"Created EEZ Studio project: {result['path']}")
|
||||||
|
|
||||||
|
|
||||||
|
@project.command("open")
|
||||||
|
@click.argument("path")
|
||||||
|
@handle_error
|
||||||
|
def project_open(path: str) -> None:
|
||||||
|
"""Open a .eez-project file."""
|
||||||
|
session = get_session()
|
||||||
|
session.open_project(path)
|
||||||
|
output(session.status(), f"Opened: {path}")
|
||||||
|
|
||||||
|
|
||||||
|
@project.command("save")
|
||||||
|
@click.argument("path", required=False)
|
||||||
|
@handle_error
|
||||||
|
def project_save(path: str | None) -> None:
|
||||||
|
"""Save the current project."""
|
||||||
|
session = require_project()
|
||||||
|
result = session.save_project(path)
|
||||||
|
output(result, f"Saved: {result['path']}")
|
||||||
|
|
||||||
|
|
||||||
|
@project.command("info")
|
||||||
|
@handle_error
|
||||||
|
def project_info() -> None:
|
||||||
|
"""Show project summary."""
|
||||||
|
session = require_project()
|
||||||
|
output(project_mod.project_info(session.project or {}), "Project info:")
|
||||||
|
|
||||||
|
|
||||||
|
@project.command("validate")
|
||||||
|
@click.argument("path", required=False)
|
||||||
|
@handle_error
|
||||||
|
def project_validate(path: str | None) -> None:
|
||||||
|
"""Validate native EEZ project structure."""
|
||||||
|
data = project_mod.load_project(path) if path else (require_project().project or {})
|
||||||
|
project_mod.validate_project(data)
|
||||||
|
output({"valid": True, **project_mod.project_info(data)}, "Project is valid.")
|
||||||
|
|
||||||
|
|
||||||
|
@project.command("pages")
|
||||||
|
@handle_error
|
||||||
|
def project_pages() -> None:
|
||||||
|
"""List LVGL pages/screens."""
|
||||||
|
session = require_project()
|
||||||
|
output(project_mod.list_pages(session.project or {}), "Pages:")
|
||||||
|
|
||||||
|
|
||||||
|
@project.command("widgets")
|
||||||
|
@click.option("--page", "page_name", default=None, help="Filter by page name.")
|
||||||
|
@handle_error
|
||||||
|
def project_widgets(page_name: str | None) -> None:
|
||||||
|
"""List LVGL widgets."""
|
||||||
|
session = require_project()
|
||||||
|
output(project_mod.list_widgets(session.project or {}, page_name), "Widgets:")
|
||||||
|
|
||||||
|
|
||||||
|
@project.command("set")
|
||||||
|
@click.argument("key")
|
||||||
|
@click.argument("value")
|
||||||
|
@handle_error
|
||||||
|
def project_set(key: str, value: str) -> None:
|
||||||
|
"""Set a supported settings.general key."""
|
||||||
|
session = require_project()
|
||||||
|
session.checkpoint()
|
||||||
|
result = project_mod.set_general(session.project or {}, key, value)
|
||||||
|
output(result, f"Set settings.general.{key}")
|
||||||
|
|
||||||
|
|
||||||
|
@project.command("set-destination")
|
||||||
|
@click.argument("destination")
|
||||||
|
@handle_error
|
||||||
|
def project_set_destination(destination: str) -> None:
|
||||||
|
"""Set settings.build.destinationFolder."""
|
||||||
|
session = require_project()
|
||||||
|
session.checkpoint()
|
||||||
|
result = project_mod.set_build_destination(session.project or {}, destination)
|
||||||
|
output(result, f"Set build destination: {destination}")
|
||||||
|
|
||||||
|
|
||||||
|
@project.command("add-build-file")
|
||||||
|
@click.argument("file_name")
|
||||||
|
@click.option("--template", "-t", required=True, help="Template text with EEZ Studio markers.")
|
||||||
|
@click.option("--description", default="", help="Build file description.")
|
||||||
|
@click.option("--replace", is_flag=True, help="Replace an existing build file.")
|
||||||
|
@handle_error
|
||||||
|
def project_add_build_file(file_name: str, template: str, description: str, replace: bool) -> None:
|
||||||
|
"""Add or replace a settings.build.files template."""
|
||||||
|
session = require_project()
|
||||||
|
session.checkpoint()
|
||||||
|
result = project_mod.add_build_file(session.project or {}, file_name, template, description, replace)
|
||||||
|
output(result, f"Updated build file: {file_name}")
|
||||||
|
|
||||||
|
|
||||||
|
@cli.group()
|
||||||
|
def lvgl() -> None:
|
||||||
|
"""LVGL screen/widget and real backend build commands."""
|
||||||
|
|
||||||
|
|
||||||
|
@lvgl.command("add-page")
|
||||||
|
@click.argument("name")
|
||||||
|
@click.option("--width", type=int, default=None, help="Screen width.")
|
||||||
|
@click.option("--height", type=int, default=None, help="Screen height.")
|
||||||
|
@handle_error
|
||||||
|
def lvgl_add_page(name: str, width: int | None, height: int | None) -> None:
|
||||||
|
"""Add a LVGL page/screen."""
|
||||||
|
session = require_project()
|
||||||
|
session.checkpoint()
|
||||||
|
result = project_mod.add_page(session.project or {}, name, width, height)
|
||||||
|
output(result, f"Added page: {name}")
|
||||||
|
|
||||||
|
|
||||||
|
@lvgl.command("add-label")
|
||||||
|
@click.option("--page", "page_name", default="Main", help="Page name.")
|
||||||
|
@click.option("--text", required=True, help="Label text.")
|
||||||
|
@click.option("--name", default=None, help="Widget name.")
|
||||||
|
@click.option("--x", type=int, default=20)
|
||||||
|
@click.option("--y", type=int, default=20)
|
||||||
|
@click.option("--width", type=int, default=160)
|
||||||
|
@click.option("--height", type=int, default=32)
|
||||||
|
@handle_error
|
||||||
|
def lvgl_add_label(page_name: str, text: str, name: str | None, x: int, y: int, width: int, height: int) -> None:
|
||||||
|
"""Add a LVGL label widget."""
|
||||||
|
session = require_project()
|
||||||
|
session.checkpoint()
|
||||||
|
result = project_mod.add_label(session.project or {}, page_name, text, name, x, y, width, height)
|
||||||
|
output(result, f"Added label: {result['name']}")
|
||||||
|
|
||||||
|
|
||||||
|
@lvgl.command("add-button")
|
||||||
|
@click.option("--page", "page_name", default="Main", help="Page name.")
|
||||||
|
@click.option("--text", required=True, help="Button label.")
|
||||||
|
@click.option("--name", default=None, help="Widget name.")
|
||||||
|
@click.option("--x", type=int, default=20)
|
||||||
|
@click.option("--y", type=int, default=72)
|
||||||
|
@click.option("--width", type=int, default=140)
|
||||||
|
@click.option("--height", type=int, default=48)
|
||||||
|
@handle_error
|
||||||
|
def lvgl_add_button(page_name: str, text: str, name: str | None, x: int, y: int, width: int, height: int) -> None:
|
||||||
|
"""Add a LVGL button widget with child label."""
|
||||||
|
session = require_project()
|
||||||
|
session.checkpoint()
|
||||||
|
result = project_mod.add_button(session.project or {}, page_name, text, name, x, y, width, height)
|
||||||
|
output(result, f"Added button: {result['name']}")
|
||||||
|
|
||||||
|
|
||||||
|
@lvgl.command("ensure-destination")
|
||||||
|
@handle_error
|
||||||
|
def lvgl_ensure_destination() -> None:
|
||||||
|
"""Create the build destination directory beside the project."""
|
||||||
|
session = require_project()
|
||||||
|
if not session.project_path:
|
||||||
|
raise RuntimeError("project must be saved before creating destination directory")
|
||||||
|
result = export_mod.ensure_destination(session.project_path)
|
||||||
|
output(result, f"Destination ready: {result['destination']}")
|
||||||
|
|
||||||
|
|
||||||
|
@lvgl.command("backend-inspect")
|
||||||
|
@click.option("--source", default=None, help="EEZ Studio source tree; defaults to EEZ_STUDIO_SOURCE.")
|
||||||
|
@click.option("--path", "project_path", default=None, help="Project path; defaults to open project.")
|
||||||
|
@handle_error
|
||||||
|
def lvgl_backend_inspect(source: str | None, project_path: str | None) -> None:
|
||||||
|
"""Inspect project metadata through EEZ Studio's real Node backend."""
|
||||||
|
session = get_session()
|
||||||
|
path = project_path or session.project_path
|
||||||
|
if not path:
|
||||||
|
raise RuntimeError("project path required")
|
||||||
|
result = export_mod.inspect_with_backend(path, source=source)
|
||||||
|
output(result, "EEZ backend project info:")
|
||||||
|
|
||||||
|
|
||||||
|
@lvgl.command("build-files")
|
||||||
|
@click.option("--path", "project_path", default=None, help="Project path; defaults to open project.")
|
||||||
|
@click.option("--timeout", type=int, default=300)
|
||||||
|
@handle_error
|
||||||
|
def lvgl_build_files(project_path: str | None, timeout: int) -> None:
|
||||||
|
"""Run a configured real EEZ Studio build-files command."""
|
||||||
|
session = get_session()
|
||||||
|
path = project_path or session.project_path
|
||||||
|
if not path:
|
||||||
|
raise RuntimeError("project path required")
|
||||||
|
result = export_mod.build_files(path, timeout=timeout)
|
||||||
|
output(result, "EEZ build command finished:")
|
||||||
|
|
||||||
|
|
||||||
|
@lvgl.command("simulator-build")
|
||||||
|
@click.argument("output_dir")
|
||||||
|
@click.option("--source", default=None, help="EEZ Studio source tree; defaults to EEZ_STUDIO_SOURCE.")
|
||||||
|
@click.option("--path", "project_path", default=None, help="Project path; defaults to open project.")
|
||||||
|
@click.option("--repository-name", default="eez-framework", help="Repository used by EEZ docker build.")
|
||||||
|
@click.option("--docker-volume", default="eez-studio-cli-anything", help="Docker volume name.")
|
||||||
|
@click.option("--timeout", type=int, default=900)
|
||||||
|
@handle_error
|
||||||
|
def lvgl_simulator_build(
|
||||||
|
output_dir: str,
|
||||||
|
source: str | None,
|
||||||
|
project_path: str | None,
|
||||||
|
repository_name: str,
|
||||||
|
docker_volume: str,
|
||||||
|
timeout: int,
|
||||||
|
) -> None:
|
||||||
|
"""Run EEZ Studio's full LVGL simulator Docker backend."""
|
||||||
|
session = get_session()
|
||||||
|
path = project_path or session.project_path
|
||||||
|
if not path:
|
||||||
|
raise RuntimeError("project path required")
|
||||||
|
result = export_mod.simulator_build(
|
||||||
|
project_path=path,
|
||||||
|
output_dir=output_dir,
|
||||||
|
source=source,
|
||||||
|
repository_name=repository_name,
|
||||||
|
docker_volume_name=docker_volume,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
result["verification"] = export_mod.verify_simulator_output(output_dir)
|
||||||
|
output(result, f"Simulator built: {os.path.abspath(output_dir)}")
|
||||||
|
|
||||||
|
|
||||||
|
@lvgl.command("verify-simulator")
|
||||||
|
@click.argument("output_dir")
|
||||||
|
@handle_error
|
||||||
|
def lvgl_verify_simulator(output_dir: str) -> None:
|
||||||
|
"""Verify simulator output artifacts by structure and magic bytes."""
|
||||||
|
result = export_mod.verify_simulator_output(output_dir)
|
||||||
|
output(result, f"Verified simulator output: {result['output_dir']}")
|
||||||
|
|
||||||
|
|
||||||
|
@cli.group()
|
||||||
|
def scpi() -> None:
|
||||||
|
"""SCPI subsystem and command model editing."""
|
||||||
|
|
||||||
|
|
||||||
|
@scpi.command("subsystem-list")
|
||||||
|
@handle_error
|
||||||
|
def scpi_subsystem_list() -> None:
|
||||||
|
"""List SCPI subsystems."""
|
||||||
|
session = require_project()
|
||||||
|
output(scpi_mod.list_subsystems(session.project or {}), "SCPI subsystems:")
|
||||||
|
|
||||||
|
|
||||||
|
@scpi.command("subsystem-add")
|
||||||
|
@click.argument("name")
|
||||||
|
@click.option("--description", "-d", default="", help="Subsystem description.")
|
||||||
|
@handle_error
|
||||||
|
def scpi_subsystem_add(name: str, description: str) -> None:
|
||||||
|
"""Add a SCPI subsystem."""
|
||||||
|
session = require_project()
|
||||||
|
session.checkpoint()
|
||||||
|
result = scpi_mod.add_subsystem(session.project or {}, name, description)
|
||||||
|
output(result, f"Added SCPI subsystem: {name}")
|
||||||
|
|
||||||
|
|
||||||
|
@scpi.command("command-list")
|
||||||
|
@click.option("--subsystem", "-s", default=None, help="Filter by subsystem.")
|
||||||
|
@handle_error
|
||||||
|
def scpi_command_list(subsystem: str | None) -> None:
|
||||||
|
"""List SCPI commands."""
|
||||||
|
session = require_project()
|
||||||
|
output(scpi_mod.list_commands(session.project or {}, subsystem), "SCPI commands:")
|
||||||
|
|
||||||
|
|
||||||
|
@scpi.command("command-add")
|
||||||
|
@click.argument("subsystem")
|
||||||
|
@click.argument("name")
|
||||||
|
@click.option("--description", "-d", default="", help="Command description.")
|
||||||
|
@click.option("--response-type", default=None, help="Query response type.")
|
||||||
|
@handle_error
|
||||||
|
def scpi_command_add(subsystem: str, name: str, description: str, response_type: str | None) -> None:
|
||||||
|
"""Add a SCPI command to a subsystem."""
|
||||||
|
session = require_project()
|
||||||
|
session.checkpoint()
|
||||||
|
result = scpi_mod.add_command(session.project or {}, subsystem, name, description, response_type)
|
||||||
|
output(result, f"Added SCPI command: {name}")
|
||||||
|
|
||||||
|
|
||||||
|
@scpi.command("parameter-add")
|
||||||
|
@click.argument("subsystem")
|
||||||
|
@click.argument("command")
|
||||||
|
@click.argument("name")
|
||||||
|
@click.option("--type", "parameter_type", default="nr1", help="SCPI parameter type.")
|
||||||
|
@click.option("--optional", is_flag=True, help="Mark parameter optional.")
|
||||||
|
@click.option("--description", "-d", default="", help="Parameter description.")
|
||||||
|
@handle_error
|
||||||
|
def scpi_parameter_add(
|
||||||
|
subsystem: str,
|
||||||
|
command: str,
|
||||||
|
name: str,
|
||||||
|
parameter_type: str,
|
||||||
|
optional: bool,
|
||||||
|
description: str,
|
||||||
|
) -> None:
|
||||||
|
"""Add a parameter to a SCPI command."""
|
||||||
|
session = require_project()
|
||||||
|
session.checkpoint()
|
||||||
|
result = scpi_mod.add_parameter(session.project or {}, subsystem, command, name, parameter_type, optional, description)
|
||||||
|
output(result, f"Added SCPI parameter: {name}")
|
||||||
|
|
||||||
|
|
||||||
|
@cli.group()
|
||||||
|
def backend() -> None:
|
||||||
|
"""Backend detection and version probes."""
|
||||||
|
|
||||||
|
|
||||||
|
@backend.command("status")
|
||||||
|
@click.option("--source", default=None, help="EEZ Studio source tree; defaults to EEZ_STUDIO_SOURCE.")
|
||||||
|
@handle_error
|
||||||
|
def backend_status(source: str | None) -> None:
|
||||||
|
"""Show backend availability."""
|
||||||
|
result = eez_studio_backend.backend_status(source)
|
||||||
|
output(result, "Backend status:")
|
||||||
|
|
||||||
|
|
||||||
|
@cli.group()
|
||||||
|
def session() -> None:
|
||||||
|
"""Session management and undo/redo."""
|
||||||
|
|
||||||
|
|
||||||
|
@session.command("status")
|
||||||
|
@handle_error
|
||||||
|
def session_status() -> None:
|
||||||
|
"""Show session status."""
|
||||||
|
output(get_session().status(), "Session status:")
|
||||||
|
|
||||||
|
|
||||||
|
@session.command("undo")
|
||||||
|
@handle_error
|
||||||
|
def session_undo() -> None:
|
||||||
|
"""Undo the last mutation."""
|
||||||
|
ok = get_session().undo()
|
||||||
|
output({"undone": ok, **get_session().status()}, "Undo" if ok else "Nothing to undo")
|
||||||
|
|
||||||
|
|
||||||
|
@session.command("redo")
|
||||||
|
@handle_error
|
||||||
|
def session_redo() -> None:
|
||||||
|
"""Redo the last undone mutation."""
|
||||||
|
ok = get_session().redo()
|
||||||
|
output({"redone": ok, **get_session().status()}, "Redo" if ok else "Nothing to redo")
|
||||||
|
|
||||||
|
|
||||||
|
@session.command("save-state")
|
||||||
|
@handle_error
|
||||||
|
def session_save_state() -> None:
|
||||||
|
"""Save session metadata to disk."""
|
||||||
|
path = get_session().save_state()
|
||||||
|
output({"path": path}, f"Saved session state: {path}")
|
||||||
|
|
||||||
|
|
||||||
|
@session.command("list")
|
||||||
|
@handle_error
|
||||||
|
def session_list() -> None:
|
||||||
|
"""List saved session metadata."""
|
||||||
|
output(Session.list_states(), "Saved sessions:")
|
||||||
|
|
||||||
|
|
||||||
|
main = cli
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
name: "cli-anything-eez-studio"
|
||||||
|
description: "Use EEZ Studio from the command line: inspect and modify native .eez-project files, edit LVGL screens/widgets, manage SCPI commands, and invoke the real EEZ Studio backend for LVGL builds."
|
||||||
|
---
|
||||||
|
|
||||||
|
# cli-anything-eez-studio
|
||||||
|
|
||||||
|
Use this skill when you need to automate EEZ Studio projects, LVGL UI scaffolding, SCPI instrument command metadata, or backend LVGL simulator builds.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd eez-studio/agent-harness
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
Backend commands require the real EEZ Studio source tree:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/eez-open/studio.git
|
||||||
|
cd studio
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
export EEZ_STUDIO_SOURCE=/absolute/path/to/studio
|
||||||
|
```
|
||||||
|
|
||||||
|
## Core Usage
|
||||||
|
|
||||||
|
Always use `--json` for agent-readable output.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cli-anything-eez-studio --json project new -o panel.eez-project --name Panel
|
||||||
|
cli-anything-eez-studio --json --project panel.eez-project lvgl add-label --text "Ready"
|
||||||
|
cli-anything-eez-studio --json --project panel.eez-project lvgl add-button --text "Run"
|
||||||
|
cli-anything-eez-studio --json --project panel.eez-project project widgets
|
||||||
|
```
|
||||||
|
|
||||||
|
## SCPI Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cli-anything-eez-studio --json --project panel.eez-project scpi subsystem-add SOURCE
|
||||||
|
cli-anything-eez-studio --json --project panel.eez-project scpi command-add SOURCE :VOLTage?
|
||||||
|
cli-anything-eez-studio --json --project panel.eez-project scpi parameter-add SOURCE :VOLTage? channel --type nr1 --optional
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backend Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cli-anything-eez-studio --json backend status
|
||||||
|
cli-anything-eez-studio --json --project panel.eez-project lvgl ensure-destination
|
||||||
|
cli-anything-eez-studio --json --project panel.eez-project lvgl backend-inspect
|
||||||
|
cli-anything-eez-studio --json --project panel.eez-project lvgl simulator-build build/sim
|
||||||
|
```
|
||||||
|
|
||||||
|
`lvgl backend-inspect` and `lvgl simulator-build` call EEZ Studio's built Node modules. They should fail loudly if the backend is missing.
|
||||||
|
|
||||||
|
## State
|
||||||
|
|
||||||
|
Use `session undo`, `session redo`, and `session status` during REPL or scripted sessions. One-shot commands with `--project` auto-save project mutations unless `--dry-run` is provided.
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# EEZ Studio Harness Test Plan
|
||||||
|
|
||||||
|
## Test Inventory Plan
|
||||||
|
|
||||||
|
- `test_core.py`: 8 unit tests planned.
|
||||||
|
- `test_full_e2e.py`: 4 end-to-end tests planned.
|
||||||
|
|
||||||
|
## Unit Test Plan
|
||||||
|
|
||||||
|
### `core.project`
|
||||||
|
|
||||||
|
- Create a native LVGL `.eez-project` JSON document.
|
||||||
|
- Validate required EEZ Studio sections: `settings.general`, `settings.build`, `userPages`, `scpi`.
|
||||||
|
- Save/load round trip.
|
||||||
|
- Mutate general settings and build destination.
|
||||||
|
- Add pages and LVGL widgets.
|
||||||
|
|
||||||
|
### `core.scpi`
|
||||||
|
|
||||||
|
- Add SCPI subsystems, commands, and parameters.
|
||||||
|
- Preserve query response metadata for commands ending with `?`.
|
||||||
|
|
||||||
|
### `core.session`
|
||||||
|
|
||||||
|
- Track undo/redo snapshots across native project mutations.
|
||||||
|
|
||||||
|
### CLI JSON
|
||||||
|
|
||||||
|
- Use Click/subprocess execution to verify `--json project new` emits machine-readable output and creates a valid project.
|
||||||
|
|
||||||
|
## E2E Test Plan
|
||||||
|
|
||||||
|
### Native CLI Workflow
|
||||||
|
|
||||||
|
- Create a saved `.eez-project`.
|
||||||
|
- Add a label, button, SCPI subsystem, and SCPI query command through the installed CLI.
|
||||||
|
- Reload and validate JSON structure.
|
||||||
|
|
||||||
|
### Backend Status Probe
|
||||||
|
|
||||||
|
- Call `backend status --json` to verify the CLI reports backend availability as structured JSON.
|
||||||
|
|
||||||
|
### Real Backend Inspect
|
||||||
|
|
||||||
|
- Default run: call `lvgl backend-inspect` without `EEZ_STUDIO_SOURCE` and verify the CLI returns a structured unavailable-backend error with setup instructions.
|
||||||
|
- Opt-in live run: when `EEZ_STUDIO_RUN_LIVE_BACKEND=1` and `EEZ_STUDIO_SOURCE` points to a built checkout, call `lvgl backend-inspect` through the CLI and verify structured project info from EEZ Studio's built `docker-build-lib.js`.
|
||||||
|
|
||||||
|
### Simulator Output Verification
|
||||||
|
|
||||||
|
- Validate generated simulator output by checking `index.html`, `index.js`, and WebAssembly magic bytes for `index.wasm`.
|
||||||
|
|
||||||
|
## Realistic Workflow Scenarios
|
||||||
|
|
||||||
|
**Embedded panel scaffold**
|
||||||
|
|
||||||
|
- Simulates: embedded GUI developer scaffolding a panel project.
|
||||||
|
- Operations chained: create project, add LVGL widgets, save, validate.
|
||||||
|
- Verified: native JSON structure, page/widget counts, output file presence.
|
||||||
|
|
||||||
|
**SCPI instrument command model**
|
||||||
|
|
||||||
|
- Simulates: test engineer adding a measurable instrument command surface.
|
||||||
|
- Operations chained: add subsystem, add query, add parameter when applicable.
|
||||||
|
- Verified: subsystem and command arrays match EEZ Studio SCPI model.
|
||||||
|
|
||||||
|
**Backend LVGL project inspection**
|
||||||
|
|
||||||
|
- Simulates: build automation reading EEZ project settings before a manufacturing/test export.
|
||||||
|
- Operations chained: prepare destination directory, verify default unavailable-backend behavior, optionally invoke real EEZ Studio Node backend.
|
||||||
|
- Verified: structured unavailable-backend error by default; backend exit code and structured project info from `docker-build-lib.js` when opted in.
|
||||||
|
|
||||||
|
## Test Results
|
||||||
|
|
||||||
|
Commands run from `eez-studio/agent-harness`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m json.tool ../../registry.json
|
||||||
|
python3 -m compileall cli_anything/eez_studio
|
||||||
|
python3 -m pip install -e .
|
||||||
|
python3 -m pytest cli_anything/eez_studio/tests/test_core.py -v
|
||||||
|
env -u EEZ_STUDIO_SOURCE -u EEZ_STUDIO_RUN_LIVE_BACKEND python3 -m pytest cli_anything/eez_studio/tests/test_full_e2e.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Unit test result:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cli_anything/eez_studio/tests/test_core.py::test_create_project_has_native_sections PASSED
|
||||||
|
cli_anything/eez_studio/tests/test_core.py::test_save_load_round_trip PASSED
|
||||||
|
cli_anything/eez_studio/tests/test_core.py::test_set_general_and_destination PASSED
|
||||||
|
cli_anything/eez_studio/tests/test_core.py::test_add_page_and_widgets PASSED
|
||||||
|
cli_anything/eez_studio/tests/test_core.py::test_scpi_subsystem_command_parameter PASSED
|
||||||
|
cli_anything/eez_studio/tests/test_core.py::test_session_undo_redo PASSED
|
||||||
|
cli_anything/eez_studio/tests/test_core.py::test_cli_json_project_new PASSED
|
||||||
|
cli_anything/eez_studio/tests/test_core.py::test_cli_json_mutation_autosaves PASSED
|
||||||
|
cli_anything/eez_studio/tests/test_core.py::test_repl_dispatch_preserves_open_session_mutation_and_undo PASSED
|
||||||
|
cli_anything/eez_studio/tests/test_core.py::test_custom_build_command_uses_shlex_for_quoted_args PASSED
|
||||||
|
|
||||||
|
10 passed in 0.11s
|
||||||
|
```
|
||||||
|
|
||||||
|
Full E2E result:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cli_anything/eez_studio/tests/test_full_e2e.py::TestCLISubprocessE2E::test_help PASSED
|
||||||
|
cli_anything/eez_studio/tests/test_full_e2e.py::TestCLISubprocessE2E::test_native_project_scpi_workflow PASSED
|
||||||
|
cli_anything/eez_studio/tests/test_full_e2e.py::TestCLISubprocessE2E::test_backend_status_json PASSED
|
||||||
|
cli_anything/eez_studio/tests/test_full_e2e.py::TestCLISubprocessE2E::test_backend_inspect_reports_unavailable_without_source PASSED
|
||||||
|
cli_anything/eez_studio/tests/test_full_e2e.py::TestCLISubprocessE2E::test_real_backend_inspect_required SKIPPED
|
||||||
|
|
||||||
|
4 passed, 1 skipped in 1.12s
|
||||||
|
```
|
||||||
|
|
||||||
|
Unavailable backend evidence:
|
||||||
|
|
||||||
|
```text
|
||||||
|
stderr:
|
||||||
|
{
|
||||||
|
"error": "EEZ Studio backend is not available.\n\nInstall/build the real target software and point this harness at it:\n git clone https://github.com/eez-open/studio.git\n cd studio\n npm install\n npm run build\n export EEZ_STUDIO_SOURCE=/absolute/path/to/studio\n\nFor full LVGL simulator builds, Docker must also be installed and running.\n",
|
||||||
|
"type": "RuntimeError"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Summary Statistics
|
||||||
|
|
||||||
|
- Unit tests: 10 passed, 0 failed.
|
||||||
|
- Full E2E tests: 4 passed, 1 skipped by default because live backend inspection is opt-in.
|
||||||
|
- Registry JSON validation: passed.
|
||||||
|
- Python compile validation: passed.
|
||||||
|
- Editable install: passed.
|
||||||
|
|
||||||
|
## Coverage Notes
|
||||||
|
|
||||||
|
The native `.eez-project` editing path, REPL session reuse, session undo/redo, SCPI model edits, CLI subprocess execution, JSON output, unavailable-backend handling, and quoted custom build command parsing are covered. The real EEZ Studio backend path is implemented as an opt-in E2E gate for environments with `EEZ_STUDIO_RUN_LIVE_BACKEND=1` and a built `EEZ_STUDIO_SOURCE`.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Tests for cli-anything-eez-studio."""
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
from cli_anything.eez_studio import eez_studio_cli
|
||||||
|
from cli_anything.eez_studio.core import project as project_mod
|
||||||
|
from cli_anything.eez_studio.core import scpi as scpi_mod
|
||||||
|
from cli_anything.eez_studio.core.session import Session
|
||||||
|
from cli_anything.eez_studio.utils import eez_studio_backend
|
||||||
|
from cli_anything.eez_studio.eez_studio_cli import cli
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_project_has_native_sections():
|
||||||
|
project = project_mod.create_project(name="Panel", display_width=480, display_height=272)
|
||||||
|
info = project_mod.project_info(project)
|
||||||
|
assert project["settings"]["general"]["projectType"] == "lvgl"
|
||||||
|
assert project["settings"]["general"]["lvglVersion"] == project_mod.DEFAULT_LVGL_VERSION
|
||||||
|
assert project["settings"]["build"]["destinationFolder"] == "src/ui"
|
||||||
|
assert project["userPages"][0]["components"][0]["type"] == "LVGLScreenWidget"
|
||||||
|
assert info["page_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_load_round_trip(tmp_path):
|
||||||
|
path = tmp_path / "panel.eez-project"
|
||||||
|
project = project_mod.create_project(name="RoundTrip")
|
||||||
|
result = project_mod.save_project(project, path)
|
||||||
|
loaded = project_mod.load_project(path)
|
||||||
|
assert result["bytes"] > 0
|
||||||
|
assert loaded["settings"]["general"]["projectName"] == "RoundTrip"
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_general_and_destination():
|
||||||
|
project = project_mod.create_project()
|
||||||
|
project_mod.set_general(project, "displayWidth", "1024")
|
||||||
|
project_mod.set_build_destination(project, "firmware/ui")
|
||||||
|
assert project["settings"]["general"]["displayWidth"] == 1024
|
||||||
|
assert project["settings"]["build"]["destinationFolder"] == "firmware/ui"
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_page_and_widgets():
|
||||||
|
project = project_mod.create_project()
|
||||||
|
project_mod.add_page(project, "Settings")
|
||||||
|
project_mod.add_label(project, "Settings", "Voltage", name="voltage_label")
|
||||||
|
project_mod.add_button(project, "Settings", "Apply", name="apply_button")
|
||||||
|
widgets = project_mod.list_widgets(project, "Settings")
|
||||||
|
assert len(project_mod.list_pages(project)) == 2
|
||||||
|
assert any(widget["type"] == "LVGLLabelWidget" for widget in widgets)
|
||||||
|
assert any(widget["type"] == "LVGLButtonWidget" for widget in widgets)
|
||||||
|
|
||||||
|
|
||||||
|
def test_scpi_subsystem_command_parameter():
|
||||||
|
project = project_mod.create_project()
|
||||||
|
scpi_mod.add_subsystem(project, "SOURCE")
|
||||||
|
scpi_mod.add_command(project, "SOURCE", ":VOLTage?", response_type="nr3")
|
||||||
|
scpi_mod.add_parameter(project, "SOURCE", ":VOLTage?", "channel", "nr1", optional=True)
|
||||||
|
commands = scpi_mod.list_commands(project, "SOURCE")
|
||||||
|
assert commands[0]["query"] is True
|
||||||
|
assert commands[0]["parameters"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_undo_redo():
|
||||||
|
session = Session("test")
|
||||||
|
session.set_project(project_mod.create_project(), None)
|
||||||
|
session.checkpoint()
|
||||||
|
project_mod.add_label(session.project, "Main", "Ready")
|
||||||
|
assert len(project_mod.list_widgets(session.project)) == 2
|
||||||
|
assert session.undo() is True
|
||||||
|
assert len(project_mod.list_widgets(session.project)) == 1
|
||||||
|
assert session.redo() is True
|
||||||
|
assert len(project_mod.list_widgets(session.project)) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_json_project_new(tmp_path):
|
||||||
|
path = tmp_path / "cli.eez-project"
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, ["--json", "project", "new", "-o", str(path), "--name", "CLI"])
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
data = json.loads(result.output)
|
||||||
|
assert data["project_name"] == "CLI"
|
||||||
|
assert path.is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_json_mutation_autosaves(tmp_path):
|
||||||
|
path = tmp_path / "auto.eez-project"
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, ["--json", "project", "new", "-o", str(path)])
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
result = runner.invoke(cli, ["--json", "--project", str(path), "lvgl", "add-label", "--text", "Ready"])
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
loaded = project_mod.load_project(path)
|
||||||
|
assert any(widget["text"] == "Ready" for widget in project_mod.list_widgets(loaded))
|
||||||
|
|
||||||
|
|
||||||
|
def test_repl_dispatch_preserves_open_session_mutation_and_undo(tmp_path):
|
||||||
|
path = tmp_path / "repl.eez-project"
|
||||||
|
runner = CliRunner()
|
||||||
|
session = Session("repl-test")
|
||||||
|
eez_studio_cli._session = session
|
||||||
|
eez_studio_cli._repl_mode = True
|
||||||
|
eez_studio_cli._json_output = False
|
||||||
|
try:
|
||||||
|
result = runner.invoke(cli, ["--json", "project", "new", "-o", str(path), "--name", "REPL"])
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert eez_studio_cli.get_session() is session
|
||||||
|
assert session.project_path == str(path.resolve())
|
||||||
|
|
||||||
|
result = runner.invoke(cli, ["--json", "--project", str(path), "lvgl", "add-label", "--text", "Unsaved"])
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert session.is_modified is True
|
||||||
|
assert any(widget["text"] == "Unsaved" for widget in project_mod.list_widgets(session.project or {}))
|
||||||
|
assert not any(widget.get("text") == "Unsaved" for widget in project_mod.list_widgets(project_mod.load_project(path)))
|
||||||
|
|
||||||
|
result = runner.invoke(cli, ["--json", "--project", str(path), "session", "undo"])
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
data = json.loads(result.output)
|
||||||
|
assert data["undone"] is True
|
||||||
|
assert not any(widget.get("text") == "Unsaved" for widget in project_mod.list_widgets(session.project or {}))
|
||||||
|
finally:
|
||||||
|
eez_studio_cli._session = None
|
||||||
|
eez_studio_cli._repl_mode = False
|
||||||
|
eez_studio_cli._json_output = False
|
||||||
|
|
||||||
|
|
||||||
|
def test_custom_build_command_uses_shlex_for_quoted_args(tmp_path, monkeypatch):
|
||||||
|
project_path = tmp_path / "quoted project.eez-project"
|
||||||
|
project_path.write_text("{}", encoding="utf-8")
|
||||||
|
tool_path = tmp_path / "tool with spaces"
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
class Result:
|
||||||
|
returncode = 0
|
||||||
|
stdout = "ok"
|
||||||
|
stderr = ""
|
||||||
|
|
||||||
|
def fake_run(args, capture_output, text, timeout):
|
||||||
|
captured["args"] = args
|
||||||
|
captured["capture_output"] = capture_output
|
||||||
|
captured["text"] = text
|
||||||
|
captured["timeout"] = timeout
|
||||||
|
return Result()
|
||||||
|
|
||||||
|
monkeypatch.setenv("EEZ_STUDIO_BUILD_COMMAND", f'"{tool_path}" --flag "two words"')
|
||||||
|
monkeypatch.setattr(eez_studio_backend.subprocess, "run", fake_run)
|
||||||
|
|
||||||
|
result = eez_studio_backend.run_custom_build_command(str(project_path), timeout=12)
|
||||||
|
|
||||||
|
assert captured["args"] == [str(tool_path), "--flag", "two words", str(project_path.resolve())]
|
||||||
|
assert captured["capture_output"] is True
|
||||||
|
assert captured["text"] is True
|
||||||
|
assert captured["timeout"] == 12
|
||||||
|
assert result["command"] == captured["args"]
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from cli_anything.eez_studio.core import project as project_mod
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_cli(name):
|
||||||
|
force = os.environ.get("CLI_ANYTHING_FORCE_INSTALLED", "").strip() == "1"
|
||||||
|
path = shutil.which(name)
|
||||||
|
if path:
|
||||||
|
print(f"[_resolve_cli] Using installed command: {path}")
|
||||||
|
return [path]
|
||||||
|
if force:
|
||||||
|
raise RuntimeError(f"{name} not found in PATH. Install with: pip install -e .")
|
||||||
|
module = "cli_anything.eez_studio.eez_studio_cli"
|
||||||
|
print(f"[_resolve_cli] Falling back to: {sys.executable} -m {module}")
|
||||||
|
return [sys.executable, "-m", module]
|
||||||
|
|
||||||
|
|
||||||
|
class TestCLISubprocessE2E:
|
||||||
|
CLI_BASE = _resolve_cli("cli-anything-eez-studio")
|
||||||
|
|
||||||
|
def _run(self, args, check=True):
|
||||||
|
result = subprocess.run(self.CLI_BASE + args, capture_output=True, text=True)
|
||||||
|
if check and result.returncode != 0:
|
||||||
|
raise AssertionError(
|
||||||
|
f"command failed: {self.CLI_BASE + args}\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def test_help(self):
|
||||||
|
result = self._run(["--help"])
|
||||||
|
assert result.returncode == 0
|
||||||
|
assert "EEZ Studio CLI harness" in result.stdout
|
||||||
|
|
||||||
|
def test_native_project_scpi_workflow(self, tmp_path):
|
||||||
|
project_path = tmp_path / "workflow.eez-project"
|
||||||
|
result = self._run(["--json", "project", "new", "-o", str(project_path), "--name", "Workflow"])
|
||||||
|
data = json.loads(result.stdout)
|
||||||
|
assert data["project_name"] == "Workflow"
|
||||||
|
self._run(["--project", str(project_path), "lvgl", "add-label", "--text", "Ready"])
|
||||||
|
self._run(["--project", str(project_path), "lvgl", "add-button", "--text", "Run"])
|
||||||
|
self._run(["--project", str(project_path), "scpi", "subsystem-add", "SOURCE"])
|
||||||
|
self._run(["--project", str(project_path), "scpi", "command-add", "SOURCE", ":VOLTage?"])
|
||||||
|
loaded = project_mod.load_project(project_path)
|
||||||
|
info = project_mod.project_info(loaded)
|
||||||
|
assert info["widget_count"] >= 3
|
||||||
|
assert info["scpi_commands"] == 1
|
||||||
|
print(f"\n EEZ project: {project_path} ({project_path.stat().st_size:,} bytes)")
|
||||||
|
|
||||||
|
def test_backend_status_json(self):
|
||||||
|
result = self._run(["--json", "backend", "status"])
|
||||||
|
data = json.loads(result.stdout)
|
||||||
|
assert "available" in data
|
||||||
|
|
||||||
|
def test_backend_inspect_reports_unavailable_without_source(self, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.delenv("EEZ_STUDIO_SOURCE", raising=False)
|
||||||
|
project_path = tmp_path / "backend-unavailable.eez-project"
|
||||||
|
self._run(["--json", "project", "new", "-o", str(project_path), "--name", "NoBackend"])
|
||||||
|
result = self._run(
|
||||||
|
["--json", "--project", str(project_path), "lvgl", "backend-inspect"],
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
assert result.returncode != 0
|
||||||
|
data = json.loads(result.stderr)
|
||||||
|
assert data["type"] == "RuntimeError"
|
||||||
|
assert "EEZ Studio backend is not available" in data["error"]
|
||||||
|
assert "EEZ_STUDIO_SOURCE" in data["error"]
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
os.environ.get("EEZ_STUDIO_RUN_LIVE_BACKEND") != "1",
|
||||||
|
reason="set EEZ_STUDIO_RUN_LIVE_BACKEND=1 with a built EEZ_STUDIO_SOURCE to run live backend tests",
|
||||||
|
)
|
||||||
|
def test_real_backend_inspect_required(self, tmp_path):
|
||||||
|
project_path = tmp_path / "backend.eez-project"
|
||||||
|
self._run(["--json", "project", "new", "-o", str(project_path), "--name", "Backend"])
|
||||||
|
self._run(["--project", str(project_path), "lvgl", "ensure-destination"])
|
||||||
|
result = self._run(
|
||||||
|
["--json", "--project", str(project_path), "lvgl", "backend-inspect"],
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, (
|
||||||
|
"EEZ Studio backend is required for this E2E test.\n"
|
||||||
|
"Set EEZ_STUDIO_SOURCE to a built https://github.com/eez-open/studio checkout.\n"
|
||||||
|
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
||||||
|
)
|
||||||
|
data = json.loads(result.stdout)
|
||||||
|
assert data["ok"] is True
|
||||||
|
assert data["projectInfo"]["displayWidth"] == 800
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Utility helpers for cli-anything-eez-studio."""
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
"""Backend wrapper for the real EEZ Studio source/Electron internals.
|
||||||
|
|
||||||
|
The harness edits native ``.eez-project`` JSON directly. Build/export commands
|
||||||
|
must call a real EEZ Studio checkout or executable. This module looks for a
|
||||||
|
built source tree via ``EEZ_STUDIO_SOURCE`` and invokes upstream Node modules.
|
||||||
|
If the backend is not installed, commands fail with concrete setup steps.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
INSTALL_MESSAGE = """EEZ Studio backend is not available.
|
||||||
|
|
||||||
|
Install/build the real target software and point this harness at it:
|
||||||
|
git clone https://github.com/eez-open/studio.git
|
||||||
|
cd studio
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
export EEZ_STUDIO_SOURCE=/absolute/path/to/studio
|
||||||
|
|
||||||
|
For full LVGL simulator builds, Docker must also be installed and running.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def find_node() -> str:
|
||||||
|
node = os.environ.get("EEZ_STUDIO_NODE") or shutil.which("node")
|
||||||
|
if not node:
|
||||||
|
raise RuntimeError("Node.js is required for EEZ Studio backend scripts.\n" + INSTALL_MESSAGE)
|
||||||
|
return node
|
||||||
|
|
||||||
|
|
||||||
|
def find_source_tree(source: str | None = None) -> Path:
|
||||||
|
candidates = []
|
||||||
|
if source:
|
||||||
|
candidates.append(Path(source))
|
||||||
|
if os.environ.get("EEZ_STUDIO_SOURCE"):
|
||||||
|
candidates.append(Path(os.environ["EEZ_STUDIO_SOURCE"]))
|
||||||
|
for candidate in candidates:
|
||||||
|
package_json = candidate / "package.json"
|
||||||
|
if package_json.is_file():
|
||||||
|
try:
|
||||||
|
package = json.loads(package_json.read_text(encoding="utf-8"))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
package = {}
|
||||||
|
if package.get("name") == "eezstudio":
|
||||||
|
return candidate.resolve()
|
||||||
|
raise RuntimeError(INSTALL_MESSAGE)
|
||||||
|
|
||||||
|
|
||||||
|
def backend_status(source: str | None = None) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
source_tree = find_source_tree(source)
|
||||||
|
package = json.loads((source_tree / "package.json").read_text(encoding="utf-8"))
|
||||||
|
build_dir = source_tree / "build"
|
||||||
|
docker_lib = build_dir / "project-editor" / "lvgl" / "docker-build" / "docker-build-lib.js"
|
||||||
|
return {
|
||||||
|
"available": True,
|
||||||
|
"source": str(source_tree),
|
||||||
|
"version": package.get("version"),
|
||||||
|
"node": find_node(),
|
||||||
|
"build_dir": str(build_dir),
|
||||||
|
"docker_build_lib": str(docker_lib),
|
||||||
|
"docker_build_lib_exists": docker_lib.is_file(),
|
||||||
|
}
|
||||||
|
except RuntimeError as exc:
|
||||||
|
return {"available": False, "error": str(exc).strip()}
|
||||||
|
|
||||||
|
|
||||||
|
def _write_runner(script: str) -> str:
|
||||||
|
handle = tempfile.NamedTemporaryFile("w", suffix=".js", delete=False, encoding="utf-8")
|
||||||
|
try:
|
||||||
|
handle.write(script)
|
||||||
|
return handle.name
|
||||||
|
finally:
|
||||||
|
handle.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _run_node(script: str, args: list[str], source: Path, timeout: int) -> dict[str, Any]:
|
||||||
|
node = find_node()
|
||||||
|
runner = _write_runner(script)
|
||||||
|
env = os.environ.copy()
|
||||||
|
node_path_parts = [
|
||||||
|
str(source / "build"),
|
||||||
|
str(source / "node_modules"),
|
||||||
|
env.get("NODE_PATH", ""),
|
||||||
|
]
|
||||||
|
env["NODE_PATH"] = os.pathsep.join(part for part in node_path_parts if part)
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[node, runner] + args,
|
||||||
|
cwd=str(source),
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(runner)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
stdout = result.stdout.strip()
|
||||||
|
stderr = result.stderr.strip()
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
"EEZ Studio backend command failed "
|
||||||
|
f"(exit {result.returncode}).\nstdout:\n{stdout[-2000:]}\nstderr:\n{stderr[-2000:]}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return json.loads(stdout.splitlines()[-1])
|
||||||
|
except (json.JSONDecodeError, IndexError) as exc:
|
||||||
|
raise RuntimeError(f"EEZ Studio backend returned non-JSON output:\n{stdout[-2000:]}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def inspect_project(project_path: str, source: str | None = None, timeout: int = 60) -> dict[str, Any]:
|
||||||
|
"""Use EEZ Studio's docker build library to parse project build metadata."""
|
||||||
|
source_tree = find_source_tree(source)
|
||||||
|
docker_lib = source_tree / "build" / "project-editor" / "lvgl" / "docker-build" / "docker-build-lib.js"
|
||||||
|
if not docker_lib.is_file():
|
||||||
|
raise RuntimeError(
|
||||||
|
"EEZ Studio is present but not built: "
|
||||||
|
f"{docker_lib} does not exist.\nRun `npm run build` in EEZ_STUDIO_SOURCE."
|
||||||
|
)
|
||||||
|
script = r"""
|
||||||
|
const path = require("path");
|
||||||
|
const lib = require(path.join(process.cwd(), "build/project-editor/lvgl/docker-build/docker-build-lib.js"));
|
||||||
|
const projectPath = process.argv[2];
|
||||||
|
const logs = [];
|
||||||
|
function log(message, type) { logs.push({ message, type: type || "info" }); }
|
||||||
|
(async () => {
|
||||||
|
const info = await lib.readProjectFile(projectPath, log);
|
||||||
|
console.log(JSON.stringify({ ok: true, projectInfo: info, logs }));
|
||||||
|
})().catch(err => {
|
||||||
|
console.error(err && err.stack ? err.stack : String(err));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
"""
|
||||||
|
return _run_node(script, [os.path.abspath(project_path)], source_tree, timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def build_full_simulator(
|
||||||
|
project_path: str,
|
||||||
|
output_dir: str,
|
||||||
|
repository_name: str = "eez-framework",
|
||||||
|
docker_volume_name: str = "eez-studio-cli-anything",
|
||||||
|
docker_build_path: str | None = None,
|
||||||
|
source: str | None = None,
|
||||||
|
timeout: int = 900,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Build the EEZ LVGL full simulator through upstream docker-build-lib."""
|
||||||
|
source_tree = find_source_tree(source)
|
||||||
|
docker_lib = source_tree / "build" / "project-editor" / "lvgl" / "docker-build" / "docker-build-lib.js"
|
||||||
|
if not docker_lib.is_file():
|
||||||
|
raise RuntimeError(
|
||||||
|
"EEZ Studio is present but not built: "
|
||||||
|
f"{docker_lib} does not exist.\nRun `npm run build` in EEZ_STUDIO_SOURCE."
|
||||||
|
)
|
||||||
|
docker_build = docker_build_path or str(source_tree / "packages" / "project-editor" / "lvgl" / "docker-build")
|
||||||
|
script = r"""
|
||||||
|
const path = require("path");
|
||||||
|
const fs = require("fs");
|
||||||
|
const lib = require(path.join(process.cwd(), "build/project-editor/lvgl/docker-build/docker-build-lib.js"));
|
||||||
|
const [projectPath, outputDir, repositoryName, dockerVolumeName, dockerBuildPath] = process.argv.slice(2);
|
||||||
|
const logs = [];
|
||||||
|
function log(message, type) { logs.push({ message, type: type || "info" }); }
|
||||||
|
(async () => {
|
||||||
|
const config = { repositoryName, dockerVolumeName, dockerBuildPath };
|
||||||
|
lib.resetAbort();
|
||||||
|
const info = await lib.readProjectFile(projectPath, log);
|
||||||
|
const dockerOk = await lib.checkDocker(log);
|
||||||
|
if (!dockerOk) throw new Error("Docker is required and not ready");
|
||||||
|
const setup = await lib.setupProject(info, config, log);
|
||||||
|
await lib.buildProject(info, config, log, setup.skipEmcmakeCmake);
|
||||||
|
await lib.extractBuild(outputDir, config, log);
|
||||||
|
const required = ["index.html", "index.js", "index.wasm"];
|
||||||
|
for (const file of required) {
|
||||||
|
const full = path.join(outputDir, file);
|
||||||
|
if (!fs.existsSync(full) || fs.statSync(full).size <= 0) {
|
||||||
|
throw new Error(`missing simulator artifact: ${file}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(JSON.stringify({ ok: true, outputDir, projectInfo: info, logs }));
|
||||||
|
})().catch(err => {
|
||||||
|
console.error(err && err.stack ? err.stack : String(err));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
"""
|
||||||
|
return _run_node(
|
||||||
|
script,
|
||||||
|
[
|
||||||
|
os.path.abspath(project_path),
|
||||||
|
os.path.abspath(output_dir),
|
||||||
|
repository_name,
|
||||||
|
docker_volume_name,
|
||||||
|
docker_build,
|
||||||
|
],
|
||||||
|
source_tree,
|
||||||
|
timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_custom_build_command(project_path: str, timeout: int = 300) -> dict[str, Any]:
|
||||||
|
"""Run an explicitly provided EEZ build command.
|
||||||
|
|
||||||
|
This supports future or locally patched EEZ Studio builds that expose a
|
||||||
|
documented headless command. The command receives the project path appended
|
||||||
|
as its final argument.
|
||||||
|
"""
|
||||||
|
command = os.environ.get("EEZ_STUDIO_BUILD_COMMAND")
|
||||||
|
if not command:
|
||||||
|
raise RuntimeError(
|
||||||
|
"No native EEZ build command configured. Set EEZ_STUDIO_BUILD_COMMAND "
|
||||||
|
"or use `lvgl simulator-build` with EEZ_STUDIO_SOURCE.\n" + INSTALL_MESSAGE
|
||||||
|
)
|
||||||
|
args = shlex.split(command) + [os.path.abspath(project_path)]
|
||||||
|
result = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"EEZ_STUDIO_BUILD_COMMAND failed (exit {result.returncode}).\n"
|
||||||
|
f"stdout:\n{result.stdout[-2000:]}\nstderr:\n{result.stderr[-2000:]}"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"method": "EEZ_STUDIO_BUILD_COMMAND",
|
||||||
|
"command": args,
|
||||||
|
"stdout": result.stdout,
|
||||||
|
"stderr": result.stderr,
|
||||||
|
}
|
||||||
@@ -0,0 +1,567 @@
|
|||||||
|
"""cli-anything REPL Skin — Unified terminal interface for all CLI harnesses.
|
||||||
|
|
||||||
|
Copy this file into your CLI package at:
|
||||||
|
cli_anything/<software>/utils/repl_skin.py
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from cli_anything.<software>.utils.repl_skin import ReplSkin
|
||||||
|
|
||||||
|
skin = ReplSkin("shotcut", version="1.0.0")
|
||||||
|
skin.print_banner() # auto-detects repo-root or packaged SKILL.md
|
||||||
|
prompt_text = skin.prompt(project_name="my_video.mlt", modified=True)
|
||||||
|
skin.success("Project saved")
|
||||||
|
skin.error("File not found")
|
||||||
|
skin.warning("Unsaved changes")
|
||||||
|
skin.info("Processing 24 clips...")
|
||||||
|
skin.status("Track 1", "3 clips, 00:02:30")
|
||||||
|
skin.table(headers, rows)
|
||||||
|
skin.print_goodbye()
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# ── ANSI color codes (no external deps for core styling) ──────────────
|
||||||
|
|
||||||
|
_RESET = "\033[0m"
|
||||||
|
_BOLD = "\033[1m"
|
||||||
|
_DIM = "\033[2m"
|
||||||
|
_ITALIC = "\033[3m"
|
||||||
|
_UNDERLINE = "\033[4m"
|
||||||
|
|
||||||
|
# Brand colors
|
||||||
|
_CYAN = "\033[38;5;80m" # cli-anything brand cyan
|
||||||
|
_CYAN_BG = "\033[48;5;80m"
|
||||||
|
_WHITE = "\033[97m"
|
||||||
|
_GRAY = "\033[38;5;245m"
|
||||||
|
_DARK_GRAY = "\033[38;5;240m"
|
||||||
|
_LIGHT_GRAY = "\033[38;5;250m"
|
||||||
|
|
||||||
|
# Software accent colors — each software gets a unique accent
|
||||||
|
_ACCENT_COLORS = {
|
||||||
|
"gimp": "\033[38;5;214m", # warm orange
|
||||||
|
"blender": "\033[38;5;208m", # deep orange
|
||||||
|
"inkscape": "\033[38;5;39m", # bright blue
|
||||||
|
"audacity": "\033[38;5;33m", # navy blue
|
||||||
|
"libreoffice": "\033[38;5;40m", # green
|
||||||
|
"obs_studio": "\033[38;5;55m", # purple
|
||||||
|
"kdenlive": "\033[38;5;69m", # slate blue
|
||||||
|
"shotcut": "\033[38;5;35m", # teal green
|
||||||
|
}
|
||||||
|
_DEFAULT_ACCENT = "\033[38;5;75m" # default sky blue
|
||||||
|
|
||||||
|
# Status colors
|
||||||
|
_GREEN = "\033[38;5;78m"
|
||||||
|
_YELLOW = "\033[38;5;220m"
|
||||||
|
_RED = "\033[38;5;196m"
|
||||||
|
_BLUE = "\033[38;5;75m"
|
||||||
|
_MAGENTA = "\033[38;5;176m"
|
||||||
|
|
||||||
|
_SKILL_SOURCE_REPO = os.environ.get("CLI_ANYTHING_SKILL_REPO", "HKUDS/CLI-Anything")
|
||||||
|
|
||||||
|
# ── Brand icon ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# The cli-anything icon: a small colored diamond/chevron mark
|
||||||
|
_ICON = f"{_CYAN}{_BOLD}◆{_RESET}"
|
||||||
|
_ICON_SMALL = f"{_CYAN}▸{_RESET}"
|
||||||
|
|
||||||
|
# ── Box drawing characters ────────────────────────────────────────────
|
||||||
|
|
||||||
|
_H_LINE = "─"
|
||||||
|
_V_LINE = "│"
|
||||||
|
_TL = "╭"
|
||||||
|
_TR = "╮"
|
||||||
|
_BL = "╰"
|
||||||
|
_BR = "╯"
|
||||||
|
_T_DOWN = "┬"
|
||||||
|
_T_UP = "┴"
|
||||||
|
_T_RIGHT = "├"
|
||||||
|
_T_LEFT = "┤"
|
||||||
|
_CROSS = "┼"
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_ansi(text: str) -> str:
|
||||||
|
"""Remove ANSI escape codes for length calculation."""
|
||||||
|
import re
|
||||||
|
return re.sub(r"\033\[[^m]*m", "", text)
|
||||||
|
|
||||||
|
|
||||||
|
def _visible_len(text: str) -> int:
|
||||||
|
"""Get visible length of text (excluding ANSI codes)."""
|
||||||
|
return len(_strip_ansi(text))
|
||||||
|
|
||||||
|
|
||||||
|
def _display_home_path(path: str) -> str:
|
||||||
|
"""Display a path relative to the home directory when possible."""
|
||||||
|
expanded = Path(path).expanduser().resolve()
|
||||||
|
home = Path.home().resolve()
|
||||||
|
try:
|
||||||
|
relative = expanded.relative_to(home)
|
||||||
|
return f"~/{relative.as_posix()}"
|
||||||
|
except ValueError:
|
||||||
|
return str(expanded)
|
||||||
|
|
||||||
|
|
||||||
|
class ReplSkin:
|
||||||
|
"""Unified REPL skin for cli-anything CLIs.
|
||||||
|
|
||||||
|
Provides consistent branding, prompts, and message formatting
|
||||||
|
across all CLI harnesses built with the cli-anything methodology.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, software: str, version: str = "1.0.0",
|
||||||
|
history_file: str | None = None, skill_path: str | None = None):
|
||||||
|
"""Initialize the REPL skin.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
software: Software name (e.g., "gimp", "shotcut", "blender").
|
||||||
|
version: CLI version string.
|
||||||
|
history_file: Path for persistent command history.
|
||||||
|
Defaults to ~/.cli-anything-<software>/history
|
||||||
|
skill_path: Path to the SKILL.md file for agent discovery.
|
||||||
|
Auto-detected from the repo-root skills/ tree when present,
|
||||||
|
otherwise from the package's skills/ directory.
|
||||||
|
Displayed in banner for AI agents to know where to read skill info.
|
||||||
|
"""
|
||||||
|
self.software = software.lower().replace("-", "_")
|
||||||
|
self.display_name = software.replace("_", " ").title()
|
||||||
|
self.version = version
|
||||||
|
software_aliases = {"iterm2_ctl": "iterm2"}
|
||||||
|
self.skill_slug = software_aliases.get(self.software, self.software).replace("_", "-")
|
||||||
|
self.skill_id = f"cli-anything-{self.skill_slug}"
|
||||||
|
self.skill_install_cmd = (
|
||||||
|
f"npx skills add {_SKILL_SOURCE_REPO} --skill {self.skill_id} -g -y"
|
||||||
|
)
|
||||||
|
global_skill_root = Path(
|
||||||
|
os.environ.get("CLI_ANYTHING_GLOBAL_SKILLS_DIR", str(Path.home() / ".agents" / "skills"))
|
||||||
|
).expanduser()
|
||||||
|
self.global_skill_path = str(global_skill_root / self.skill_id / "SKILL.md")
|
||||||
|
|
||||||
|
# Prefer repo-root canonical skills/<skill-id>/SKILL.md when running
|
||||||
|
# inside the CLI-Anything monorepo. Fall back to the packaged
|
||||||
|
# cli_anything/<software>/skills/SKILL.md for installed harnesses.
|
||||||
|
if skill_path is None:
|
||||||
|
package_skill = Path(__file__).resolve().parent.parent / "skills" / "SKILL.md"
|
||||||
|
repo_skill = None
|
||||||
|
for parent in Path(__file__).resolve().parents:
|
||||||
|
candidate = parent / "skills" / self.skill_id / "SKILL.md"
|
||||||
|
if candidate.is_file():
|
||||||
|
repo_skill = candidate
|
||||||
|
break
|
||||||
|
if repo_skill and repo_skill.is_file():
|
||||||
|
skill_path = str(repo_skill)
|
||||||
|
elif package_skill.is_file():
|
||||||
|
skill_path = str(package_skill)
|
||||||
|
self.skill_path = skill_path
|
||||||
|
self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT)
|
||||||
|
|
||||||
|
# History file
|
||||||
|
if history_file is None:
|
||||||
|
hist_dir = Path.home() / f".cli-anything-{self.software}"
|
||||||
|
hist_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.history_file = str(hist_dir / "history")
|
||||||
|
else:
|
||||||
|
self.history_file = history_file
|
||||||
|
|
||||||
|
# Detect terminal capabilities
|
||||||
|
self._color = self._detect_color_support()
|
||||||
|
|
||||||
|
def _detect_color_support(self) -> bool:
|
||||||
|
"""Check if terminal supports color."""
|
||||||
|
if os.environ.get("NO_COLOR"):
|
||||||
|
return False
|
||||||
|
if os.environ.get("CLI_ANYTHING_NO_COLOR"):
|
||||||
|
return False
|
||||||
|
if not hasattr(sys.stdout, "isatty"):
|
||||||
|
return False
|
||||||
|
return sys.stdout.isatty()
|
||||||
|
|
||||||
|
def _c(self, code: str, text: str) -> str:
|
||||||
|
"""Apply color code if colors are supported."""
|
||||||
|
if not self._color:
|
||||||
|
return text
|
||||||
|
return f"{code}{text}{_RESET}"
|
||||||
|
|
||||||
|
# ── Banner ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def print_banner(self):
|
||||||
|
"""Print the startup banner with branding."""
|
||||||
|
import textwrap
|
||||||
|
|
||||||
|
inner = 72
|
||||||
|
|
||||||
|
def _box_line(content: str) -> str:
|
||||||
|
"""Wrap content in box drawing, padding to inner width."""
|
||||||
|
pad = inner - _visible_len(content)
|
||||||
|
vl = self._c(_DARK_GRAY, _V_LINE)
|
||||||
|
return f"{vl}{content}{' ' * max(0, pad)}{vl}"
|
||||||
|
|
||||||
|
def _meta_lines(label: str, value: str) -> list[str]:
|
||||||
|
"""Wrap a metadata line for the banner box."""
|
||||||
|
icon = self._c(_MAGENTA, "◇")
|
||||||
|
label_text = self._c(_DARK_GRAY, label)
|
||||||
|
prefix = f" {icon} {label_text} "
|
||||||
|
available = max(12, inner - _visible_len(prefix))
|
||||||
|
wrapped = textwrap.wrap(
|
||||||
|
value,
|
||||||
|
width=available,
|
||||||
|
break_long_words=True,
|
||||||
|
break_on_hyphens=False,
|
||||||
|
) or [""]
|
||||||
|
lines = [f"{prefix}{self._c(_LIGHT_GRAY, wrapped[0])}"]
|
||||||
|
continuation_prefix = " " * _visible_len(prefix)
|
||||||
|
for chunk in wrapped[1:]:
|
||||||
|
lines.append(f"{continuation_prefix}{self._c(_LIGHT_GRAY, chunk)}")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}")
|
||||||
|
bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}")
|
||||||
|
|
||||||
|
# Title: ◆ cli-anything · Shotcut
|
||||||
|
icon = self._c(_CYAN + _BOLD, "◆")
|
||||||
|
brand = self._c(_CYAN + _BOLD, "cli-anything")
|
||||||
|
dot = self._c(_DARK_GRAY, "·")
|
||||||
|
name = self._c(self.accent + _BOLD, self.display_name)
|
||||||
|
title = f" {icon} {brand} {dot} {name}"
|
||||||
|
|
||||||
|
ver = f" {self._c(_DARK_GRAY, f' v{self.version}')}"
|
||||||
|
tip = f" {self._c(_DARK_GRAY, ' Type help for commands, quit to exit')}"
|
||||||
|
empty = ""
|
||||||
|
|
||||||
|
meta_lines: list[str] = []
|
||||||
|
meta_lines.extend(_meta_lines("Install:", self.skill_install_cmd))
|
||||||
|
meta_lines.extend(_meta_lines("Global skill:", _display_home_path(self.global_skill_path)))
|
||||||
|
print(top)
|
||||||
|
print(_box_line(title))
|
||||||
|
print(_box_line(ver))
|
||||||
|
for line in meta_lines:
|
||||||
|
print(_box_line(line))
|
||||||
|
print(_box_line(empty))
|
||||||
|
print(_box_line(tip))
|
||||||
|
print(bot)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# ── Prompt ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def prompt(self, project_name: str = "", modified: bool = False,
|
||||||
|
context: str = "") -> str:
|
||||||
|
"""Build a styled prompt string for prompt_toolkit or input().
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_name: Current project name (empty if none open).
|
||||||
|
modified: Whether the project has unsaved changes.
|
||||||
|
context: Optional extra context to show in prompt.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted prompt string.
|
||||||
|
"""
|
||||||
|
parts = []
|
||||||
|
|
||||||
|
# Icon
|
||||||
|
if self._color:
|
||||||
|
parts.append(f"{_CYAN}◆{_RESET} ")
|
||||||
|
else:
|
||||||
|
parts.append("> ")
|
||||||
|
|
||||||
|
# Software name
|
||||||
|
parts.append(self._c(self.accent + _BOLD, self.software))
|
||||||
|
|
||||||
|
# Project context
|
||||||
|
if project_name or context:
|
||||||
|
ctx = context or project_name
|
||||||
|
mod = "*" if modified else ""
|
||||||
|
parts.append(f" {self._c(_DARK_GRAY, '[')}")
|
||||||
|
parts.append(self._c(_LIGHT_GRAY, f"{ctx}{mod}"))
|
||||||
|
parts.append(self._c(_DARK_GRAY, ']'))
|
||||||
|
|
||||||
|
parts.append(self._c(_GRAY, " ❯ "))
|
||||||
|
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
def prompt_tokens(self, project_name: str = "", modified: bool = False,
|
||||||
|
context: str = ""):
|
||||||
|
"""Build prompt_toolkit formatted text tokens for the prompt.
|
||||||
|
|
||||||
|
Use with prompt_toolkit's FormattedText for proper ANSI handling.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list of (style, text) tuples for prompt_toolkit.
|
||||||
|
"""
|
||||||
|
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
|
||||||
|
tokens = []
|
||||||
|
|
||||||
|
tokens.append(("class:icon", "◆ "))
|
||||||
|
tokens.append(("class:software", self.software))
|
||||||
|
|
||||||
|
if project_name or context:
|
||||||
|
ctx = context or project_name
|
||||||
|
mod = "*" if modified else ""
|
||||||
|
tokens.append(("class:bracket", " ["))
|
||||||
|
tokens.append(("class:context", f"{ctx}{mod}"))
|
||||||
|
tokens.append(("class:bracket", "]"))
|
||||||
|
|
||||||
|
tokens.append(("class:arrow", " ❯ "))
|
||||||
|
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
def get_prompt_style(self):
|
||||||
|
"""Get a prompt_toolkit Style object matching the skin.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
prompt_toolkit.styles.Style
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from prompt_toolkit.styles import Style
|
||||||
|
except ImportError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
|
||||||
|
|
||||||
|
return Style.from_dict({
|
||||||
|
"icon": "#5fdfdf bold", # cyan brand color
|
||||||
|
"software": f"{accent_hex} bold",
|
||||||
|
"bracket": "#585858",
|
||||||
|
"context": "#bcbcbc",
|
||||||
|
"arrow": "#808080",
|
||||||
|
# Completion menu
|
||||||
|
"completion-menu.completion": "bg:#303030 #bcbcbc",
|
||||||
|
"completion-menu.completion.current": f"bg:{accent_hex} #000000",
|
||||||
|
"completion-menu.meta.completion": "bg:#303030 #808080",
|
||||||
|
"completion-menu.meta.completion.current": f"bg:{accent_hex} #000000",
|
||||||
|
# Auto-suggest
|
||||||
|
"auto-suggest": "#585858",
|
||||||
|
# Bottom toolbar
|
||||||
|
"bottom-toolbar": "bg:#1c1c1c #808080",
|
||||||
|
"bottom-toolbar.text": "#808080",
|
||||||
|
})
|
||||||
|
|
||||||
|
# ── Messages ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def success(self, message: str):
|
||||||
|
"""Print a success message with green checkmark."""
|
||||||
|
icon = self._c(_GREEN + _BOLD, "✓")
|
||||||
|
print(f" {icon} {self._c(_GREEN, message)}")
|
||||||
|
|
||||||
|
def error(self, message: str):
|
||||||
|
"""Print an error message with red cross."""
|
||||||
|
icon = self._c(_RED + _BOLD, "✗")
|
||||||
|
print(f" {icon} {self._c(_RED, message)}", file=sys.stderr)
|
||||||
|
|
||||||
|
def warning(self, message: str):
|
||||||
|
"""Print a warning message with yellow triangle."""
|
||||||
|
icon = self._c(_YELLOW + _BOLD, "⚠")
|
||||||
|
print(f" {icon} {self._c(_YELLOW, message)}")
|
||||||
|
|
||||||
|
def info(self, message: str):
|
||||||
|
"""Print an info message with blue dot."""
|
||||||
|
icon = self._c(_BLUE, "●")
|
||||||
|
print(f" {icon} {self._c(_LIGHT_GRAY, message)}")
|
||||||
|
|
||||||
|
def hint(self, message: str):
|
||||||
|
"""Print a subtle hint message."""
|
||||||
|
print(f" {self._c(_DARK_GRAY, message)}")
|
||||||
|
|
||||||
|
def section(self, title: str):
|
||||||
|
"""Print a section header."""
|
||||||
|
print()
|
||||||
|
print(f" {self._c(self.accent + _BOLD, title)}")
|
||||||
|
print(f" {self._c(_DARK_GRAY, _H_LINE * len(title))}")
|
||||||
|
|
||||||
|
# ── Status display ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def status(self, label: str, value: str):
|
||||||
|
"""Print a key-value status line."""
|
||||||
|
lbl = self._c(_GRAY, f" {label}:")
|
||||||
|
val = self._c(_WHITE, f" {value}")
|
||||||
|
print(f"{lbl}{val}")
|
||||||
|
|
||||||
|
def status_block(self, items: dict[str, str], title: str = ""):
|
||||||
|
"""Print a block of status key-value pairs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
items: Dict of label -> value pairs.
|
||||||
|
title: Optional title for the block.
|
||||||
|
"""
|
||||||
|
if title:
|
||||||
|
self.section(title)
|
||||||
|
|
||||||
|
max_key = max(len(k) for k in items) if items else 0
|
||||||
|
for label, value in items.items():
|
||||||
|
lbl = self._c(_GRAY, f" {label:<{max_key}}")
|
||||||
|
val = self._c(_WHITE, f" {value}")
|
||||||
|
print(f"{lbl}{val}")
|
||||||
|
|
||||||
|
def progress(self, current: int, total: int, label: str = ""):
|
||||||
|
"""Print a simple progress indicator.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
current: Current step number.
|
||||||
|
total: Total number of steps.
|
||||||
|
label: Optional label for the progress.
|
||||||
|
"""
|
||||||
|
pct = int(current / total * 100) if total > 0 else 0
|
||||||
|
bar_width = 20
|
||||||
|
filled = int(bar_width * current / total) if total > 0 else 0
|
||||||
|
bar = "█" * filled + "░" * (bar_width - filled)
|
||||||
|
text = f" {self._c(_CYAN, bar)} {self._c(_GRAY, f'{pct:3d}%')}"
|
||||||
|
if label:
|
||||||
|
text += f" {self._c(_LIGHT_GRAY, label)}"
|
||||||
|
print(text)
|
||||||
|
|
||||||
|
# ── Table display ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def table(self, headers: list[str], rows: list[list[str]],
|
||||||
|
max_col_width: int = 40):
|
||||||
|
"""Print a formatted table with box-drawing characters.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
headers: Column header strings.
|
||||||
|
rows: List of rows, each a list of cell strings.
|
||||||
|
max_col_width: Maximum column width before truncation.
|
||||||
|
"""
|
||||||
|
if not headers:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Calculate column widths
|
||||||
|
col_widths = [min(len(h), max_col_width) for h in headers]
|
||||||
|
for row in rows:
|
||||||
|
for i, cell in enumerate(row):
|
||||||
|
if i < len(col_widths):
|
||||||
|
col_widths[i] = min(
|
||||||
|
max(col_widths[i], len(str(cell))), max_col_width
|
||||||
|
)
|
||||||
|
|
||||||
|
def pad(text: str, width: int) -> str:
|
||||||
|
t = str(text)[:width]
|
||||||
|
return t + " " * (width - len(t))
|
||||||
|
|
||||||
|
# Header
|
||||||
|
header_cells = [
|
||||||
|
self._c(_CYAN + _BOLD, pad(h, col_widths[i]))
|
||||||
|
for i, h in enumerate(headers)
|
||||||
|
]
|
||||||
|
sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
|
||||||
|
header_line = f" {sep.join(header_cells)}"
|
||||||
|
print(header_line)
|
||||||
|
|
||||||
|
# Separator
|
||||||
|
sep_parts = [self._c(_DARK_GRAY, _H_LINE * w) for w in col_widths]
|
||||||
|
sep_line = self._c(_DARK_GRAY, f" {'───'.join([_H_LINE * w for w in col_widths])}")
|
||||||
|
print(sep_line)
|
||||||
|
|
||||||
|
# Rows
|
||||||
|
for row in rows:
|
||||||
|
cells = []
|
||||||
|
for i, cell in enumerate(row):
|
||||||
|
if i < len(col_widths):
|
||||||
|
cells.append(self._c(_LIGHT_GRAY, pad(str(cell), col_widths[i])))
|
||||||
|
row_sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
|
||||||
|
print(f" {row_sep.join(cells)}")
|
||||||
|
|
||||||
|
# ── Help display ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def help(self, commands: dict[str, str]):
|
||||||
|
"""Print a formatted help listing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
commands: Dict of command -> description pairs.
|
||||||
|
"""
|
||||||
|
self.section("Commands")
|
||||||
|
max_cmd = max(len(c) for c in commands) if commands else 0
|
||||||
|
for cmd, desc in commands.items():
|
||||||
|
cmd_styled = self._c(self.accent, f" {cmd:<{max_cmd}}")
|
||||||
|
desc_styled = self._c(_GRAY, f" {desc}")
|
||||||
|
print(f"{cmd_styled}{desc_styled}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# ── Goodbye ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def print_goodbye(self):
|
||||||
|
"""Print a styled goodbye message."""
|
||||||
|
print(f"\n {_ICON_SMALL} {self._c(_GRAY, 'Goodbye!')}\n")
|
||||||
|
|
||||||
|
# ── Prompt toolkit session factory ────────────────────────────────
|
||||||
|
|
||||||
|
def create_prompt_session(self):
|
||||||
|
"""Create a prompt_toolkit PromptSession with skin styling.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A configured PromptSession, or None if prompt_toolkit unavailable.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from prompt_toolkit import PromptSession
|
||||||
|
from prompt_toolkit.history import FileHistory
|
||||||
|
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
|
||||||
|
from prompt_toolkit.formatted_text import FormattedText
|
||||||
|
|
||||||
|
style = self.get_prompt_style()
|
||||||
|
|
||||||
|
session = PromptSession(
|
||||||
|
history=FileHistory(self.history_file),
|
||||||
|
auto_suggest=AutoSuggestFromHistory(),
|
||||||
|
style=style,
|
||||||
|
enable_history_search=True,
|
||||||
|
)
|
||||||
|
return session
|
||||||
|
except ImportError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_input(self, pt_session, project_name: str = "",
|
||||||
|
modified: bool = False, context: str = "") -> str:
|
||||||
|
"""Get input from user using prompt_toolkit or fallback.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pt_session: A prompt_toolkit PromptSession (or None).
|
||||||
|
project_name: Current project name.
|
||||||
|
modified: Whether project has unsaved changes.
|
||||||
|
context: Optional context string.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User input string (stripped).
|
||||||
|
"""
|
||||||
|
if pt_session is not None:
|
||||||
|
from prompt_toolkit.formatted_text import FormattedText
|
||||||
|
tokens = self.prompt_tokens(project_name, modified, context)
|
||||||
|
return pt_session.prompt(FormattedText(tokens)).strip()
|
||||||
|
else:
|
||||||
|
raw_prompt = self.prompt(project_name, modified, context)
|
||||||
|
return input(raw_prompt).strip()
|
||||||
|
|
||||||
|
# ── Toolbar builder ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def bottom_toolbar(self, items: dict[str, str]):
|
||||||
|
"""Create a bottom toolbar callback for prompt_toolkit.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
items: Dict of label -> value pairs to show in toolbar.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A callable that returns FormattedText for the toolbar.
|
||||||
|
"""
|
||||||
|
def toolbar():
|
||||||
|
from prompt_toolkit.formatted_text import FormattedText
|
||||||
|
parts = []
|
||||||
|
for i, (k, v) in enumerate(items.items()):
|
||||||
|
if i > 0:
|
||||||
|
parts.append(("class:bottom-toolbar.text", " │ "))
|
||||||
|
parts.append(("class:bottom-toolbar.text", f" {k}: "))
|
||||||
|
parts.append(("class:bottom-toolbar", v))
|
||||||
|
return FormattedText(parts)
|
||||||
|
return toolbar
|
||||||
|
|
||||||
|
|
||||||
|
# ── ANSI 256-color to hex mapping (for prompt_toolkit styles) ─────────
|
||||||
|
|
||||||
|
_ANSI_256_TO_HEX = {
|
||||||
|
"\033[38;5;33m": "#0087ff", # audacity navy blue
|
||||||
|
"\033[38;5;35m": "#00af5f", # shotcut teal
|
||||||
|
"\033[38;5;39m": "#00afff", # inkscape bright blue
|
||||||
|
"\033[38;5;40m": "#00d700", # libreoffice green
|
||||||
|
"\033[38;5;55m": "#5f00af", # obs purple
|
||||||
|
"\033[38;5;69m": "#5f87ff", # kdenlive slate blue
|
||||||
|
"\033[38;5;75m": "#5fafff", # default sky blue
|
||||||
|
"\033[38;5;80m": "#5fd7d7", # brand cyan
|
||||||
|
"\033[38;5;208m": "#ff8700", # blender deep orange
|
||||||
|
"\033[38;5;214m": "#ffaf00", # gimp warm orange
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from setuptools import find_namespace_packages, setup
|
||||||
|
|
||||||
|
|
||||||
|
README = Path("cli_anything/eez_studio/README.md")
|
||||||
|
|
||||||
|
|
||||||
|
setup(
|
||||||
|
name="cli-anything-eez-studio",
|
||||||
|
version="0.1.0",
|
||||||
|
description="Agent-friendly CLI harness for EEZ Studio project, LVGL, and SCPI workflows",
|
||||||
|
long_description=README.read_text(encoding="utf-8") if README.exists() else "",
|
||||||
|
long_description_content_type="text/markdown",
|
||||||
|
author="cli-anything contributors",
|
||||||
|
python_requires=">=3.10",
|
||||||
|
packages=find_namespace_packages(include=["cli_anything.*"]),
|
||||||
|
package_data={
|
||||||
|
"cli_anything.eez_studio": ["skills/*.md"],
|
||||||
|
},
|
||||||
|
include_package_data=True,
|
||||||
|
install_requires=[
|
||||||
|
"click>=8.0.0",
|
||||||
|
"prompt-toolkit>=3.0.0",
|
||||||
|
],
|
||||||
|
extras_require={
|
||||||
|
"dev": ["pytest>=7.0.0"],
|
||||||
|
},
|
||||||
|
entry_points={
|
||||||
|
"console_scripts": [
|
||||||
|
"cli-anything-eez-studio=cli_anything.eez_studio.eez_studio_cli:main",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
classifiers=[
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
|
||||||
|
"Topic :: Scientific/Engineering",
|
||||||
|
"Topic :: Software Development :: Embedded Systems",
|
||||||
|
],
|
||||||
|
)
|
||||||
@@ -233,6 +233,25 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "eez-studio",
|
||||||
|
"display_name": "EEZ Studio",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "EEZ Studio project, LVGL UI, and SCPI command automation via native .eez-project JSON and real EEZ Studio backend hooks",
|
||||||
|
"requires": "EEZ Studio source checkout built with npm run build; Docker for full LVGL simulator builds",
|
||||||
|
"homepage": "https://www.envox.eu/studio/studio-introduction/",
|
||||||
|
"source_url": null,
|
||||||
|
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=eez-studio/agent-harness",
|
||||||
|
"entry_point": "cli-anything-eez-studio",
|
||||||
|
"skill_md": "skills/cli-anything-eez-studio/SKILL.md",
|
||||||
|
"category": "automation",
|
||||||
|
"contributors": [
|
||||||
|
{
|
||||||
|
"name": "CLI-Anything-Team",
|
||||||
|
"url": "https://github.com/HKUDS/CLI-Anything"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "eth2-quickstart",
|
"name": "eth2-quickstart",
|
||||||
"display_name": "ETH2 QuickStart",
|
"display_name": "ETH2 QuickStart",
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
name: "cli-anything-eez-studio"
|
||||||
|
description: "Use EEZ Studio from the command line: inspect and modify native .eez-project files, edit LVGL screens/widgets, manage SCPI commands, and invoke the real EEZ Studio backend for LVGL builds."
|
||||||
|
---
|
||||||
|
|
||||||
|
# cli-anything-eez-studio
|
||||||
|
|
||||||
|
Use this skill when you need to automate EEZ Studio projects, LVGL UI scaffolding, SCPI instrument command metadata, or backend LVGL simulator builds.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd eez-studio/agent-harness
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
Backend commands require the real EEZ Studio source tree:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/eez-open/studio.git
|
||||||
|
cd studio
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
export EEZ_STUDIO_SOURCE=/absolute/path/to/studio
|
||||||
|
```
|
||||||
|
|
||||||
|
## Core Usage
|
||||||
|
|
||||||
|
Always use `--json` for agent-readable output.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cli-anything-eez-studio --json project new -o panel.eez-project --name Panel
|
||||||
|
cli-anything-eez-studio --json --project panel.eez-project lvgl add-label --text "Ready"
|
||||||
|
cli-anything-eez-studio --json --project panel.eez-project lvgl add-button --text "Run"
|
||||||
|
cli-anything-eez-studio --json --project panel.eez-project project widgets
|
||||||
|
```
|
||||||
|
|
||||||
|
## SCPI Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cli-anything-eez-studio --json --project panel.eez-project scpi subsystem-add SOURCE
|
||||||
|
cli-anything-eez-studio --json --project panel.eez-project scpi command-add SOURCE :VOLTage?
|
||||||
|
cli-anything-eez-studio --json --project panel.eez-project scpi parameter-add SOURCE :VOLTage? channel --type nr1 --optional
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backend Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cli-anything-eez-studio --json backend status
|
||||||
|
cli-anything-eez-studio --json --project panel.eez-project lvgl ensure-destination
|
||||||
|
cli-anything-eez-studio --json --project panel.eez-project lvgl backend-inspect
|
||||||
|
cli-anything-eez-studio --json --project panel.eez-project lvgl simulator-build build/sim
|
||||||
|
```
|
||||||
|
|
||||||
|
`lvgl backend-inspect` and `lvgl simulator-build` call EEZ Studio's built Node modules. They should fail loudly if the backend is missing.
|
||||||
|
|
||||||
|
## State
|
||||||
|
|
||||||
|
Use `session undo`, `session redo`, and `session status` during REPL or scripted sessions. One-shot commands with `--project` auto-save project mutations unless `--dry-run` is provided.
|
||||||
Reference in New Issue
Block a user