analogcli 0.1.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,421 @@
1
+ """Shared utilities and display helpers for CLI commands."""
2
+
3
+ import csv
4
+ import datetime
5
+ import enum
6
+ import functools
7
+ import locale
8
+ import sys
9
+ import time
10
+ from typing import Any
11
+
12
+ import click
13
+ from rich.console import Console
14
+ from rich.panel import Panel
15
+ from rich.progress import Progress, SpinnerColumn, TextColumn
16
+ from rich.syntax import Syntax
17
+ from rich.table import Table
18
+ import json
19
+
20
+ from .client import AnalogCloudClient, AnalogCloudAPIError, TERMINAL_TASK_STATES
21
+ from .client import StateConflictError
22
+ from anabrid.cloud.models.api.v1 import (
23
+ TaskStatusResponse,
24
+ DeviceResponse,
25
+ SystemStatusResponse,
26
+ )
27
+
28
+ console = Console()
29
+
30
+
31
+ try:
32
+ locale.setlocale(locale.LC_TIME, "")
33
+ except locale.Error:
34
+ pass
35
+
36
+
37
+ def format_timestamp(value: Any) -> str:
38
+ """Render *value* as a locale-aware date+time string for human display.
39
+
40
+ Server timestamps are stored as naive UTC; this helper treats a naive
41
+ ``datetime`` as UTC, converts it to the user's local time zone, drops
42
+ the microsecond component (sub-second precision is noise for a human),
43
+ and renders via ``strftime("%c")`` so the output respects the
44
+ ``LC_TIME`` environment locale (e.g. ``Fr 08 Mai 2026 17:24:25 CEST``
45
+ on a German system). Anything that is not a ``datetime`` is returned
46
+ via ``str(value)`` so callers can pass partial / missing fields
47
+ straight through.
48
+ """
49
+ if not isinstance(value, datetime.datetime):
50
+ return str(value) if value is not None else ""
51
+ if value.tzinfo is None:
52
+ value = value.replace(tzinfo=datetime.timezone.utc)
53
+ return value.astimezone().replace(microsecond=0).strftime("%c")
54
+
55
+
56
+ def format_uptime(seconds: int) -> str:
57
+ """Render an uptime *seconds* count as a ``?d ?h ?m ?s`` string.
58
+
59
+ Units above the largest non-zero one are dropped (``225`` becomes
60
+ ``3m 45s``, not ``0d 0h 3m 45s``), while interior and trailing zero
61
+ units are kept once a larger unit is present (``3600`` becomes
62
+ ``1h 0m 0s``) so the components stay aligned. A zero or negative
63
+ count - the latter being a nonsensical clock skew - clamps to
64
+ ``0s`` rather than rendering empty.
65
+ """
66
+ seconds = max(int(seconds), 0)
67
+ days, rem = divmod(seconds, 86_400)
68
+ hours, rem = divmod(rem, 3_600)
69
+ minutes, secs = divmod(rem, 60)
70
+
71
+ parts = [(days, "d"), (hours, "h"), (minutes, "m"), (secs, "s")]
72
+ # Drop leading zero units; keep everything from the first non-zero
73
+ # unit down to seconds. ``next`` defaults to the last index so an
74
+ # all-zero uptime still renders the trailing ``0s``.
75
+ first = next(
76
+ (i for i, (value, _) in enumerate(parts) if value), len(parts) - 1
77
+ )
78
+ return " ".join(f"{value}{label}" for value, label in parts[first:])
79
+
80
+
81
+ class OutputMode(enum.Enum):
82
+ """Selects how a CLI command renders its primary output."""
83
+
84
+ RICH = "rich"
85
+ TEXT = "text"
86
+ JSON = "json"
87
+
88
+
89
+ def _stdout_is_tty() -> bool:
90
+ """Return True if ``sys.stdout`` reports being attached to a TTY."""
91
+ isatty = getattr(sys.stdout, "isatty", None)
92
+ if isatty is None:
93
+ return False
94
+ try:
95
+ return bool(isatty())
96
+ except Exception:
97
+ return False
98
+
99
+
100
+ def pick_output_mode(
101
+ ctx: click.Context | None,
102
+ format: str | None,
103
+ follow: bool,
104
+ ) -> OutputMode:
105
+ """Select the output mode for a CLI command.
106
+
107
+ The CLI is used in two distinct contexts: an interactive terminal
108
+ (humans reading) and a pipeline such as ``analogcli task list | jq``
109
+ or a CI script (machines parsing). Rich panels and spinners help in
110
+ the first context and actively break the second — box-drawing
111
+ characters confuse ``grep``, colour escape sequences confuse
112
+ ``jq``. This helper centralises that decision so each command stays
113
+ short: derive ``OutputMode`` once from
114
+ ``(--format, --follow, sys.stdout.isatty())`` and dispatch to a Rich
115
+ or plain helper. The helpers themselves do not re-check.
116
+
117
+ The precedence captures "explicit user intent beats heuristics, an
118
+ active TTY means a human is watching, an inherited pipe means a
119
+ machine is watching":
120
+
121
+ - ``format == "json"`` → ``OutputMode.JSON`` (explicit wins)
122
+ - ``format == "text"`` → ``OutputMode.TEXT`` (explicit wins)
123
+ - ``format is None`` (auto-detect):
124
+ - ``follow`` is True → ``OutputMode.RICH`` (the user has opted
125
+ into an interactive watch loop, so Rich spinners/panels are
126
+ the right choice)
127
+ - else if stdout is a TTY → ``OutputMode.TEXT`` (human is
128
+ watching; structured but plain output)
129
+ - else → ``OutputMode.JSON`` (output is being piped; emit
130
+ machine-parsable JSON by default).
131
+ """
132
+ if format == "json":
133
+ return OutputMode.JSON
134
+ if format == "text":
135
+ return OutputMode.TEXT
136
+ if format is not None:
137
+ raise click.UsageError(
138
+ f"Unknown --format value: {format!r}. Expected 'text' or 'json'."
139
+ )
140
+ if follow:
141
+ return OutputMode.RICH
142
+ if _stdout_is_tty():
143
+ return OutputMode.TEXT
144
+ return OutputMode.JSON
145
+
146
+
147
+ def handle_api_error(func):
148
+ """Decorator mapping known exceptions to CLI exit codes.
149
+
150
+ Mapping:
151
+ - ``StateConflictError`` → exit 4
152
+ - ``AnalogCloudAPIError`` → exit 3
153
+ - ``TimeoutError`` → exit 124
154
+ - any other exception propagates unchanged
155
+ """
156
+
157
+ @functools.wraps(func)
158
+ def wrapper(*args, **kwargs):
159
+ try:
160
+ return func(*args, **kwargs)
161
+ except StateConflictError as exc:
162
+ click.echo(f"Error: {exc}", err=True)
163
+ sys.exit(4)
164
+ except AnalogCloudAPIError as exc:
165
+ click.echo(f"Error: {exc}", err=True)
166
+ sys.exit(3)
167
+ except TimeoutError as exc:
168
+ click.echo(f"Error: timed out: {exc}", err=True)
169
+ sys.exit(124)
170
+
171
+ return wrapper
172
+
173
+
174
+ def print_task_logs_plain(lines: list[str]) -> None:
175
+ """Emit task log lines as raw newline-separated text on stdout."""
176
+ if not lines:
177
+ return
178
+ click.echo("\n".join(lines))
179
+
180
+
181
+ def print_task_table(tasks: list) -> None:
182
+ """Render a table of task summaries for TTY consumption."""
183
+ if not tasks:
184
+ console.print("(no tasks)")
185
+ return
186
+
187
+ table = Table(title="Tasks")
188
+ table.add_column("ID", style="cyan", no_wrap=True)
189
+ table.add_column("Type", style="magenta")
190
+ table.add_column("State", style="green")
191
+ table.add_column("Created At", style="white")
192
+
193
+ def _field(item, name: str) -> Any:
194
+ if isinstance(item, dict):
195
+ return item.get(name)
196
+ return getattr(item, name, None)
197
+
198
+ for task in tasks:
199
+ raw_id = _field(task, "id")
200
+ id_str = str(raw_id) if raw_id is not None else ""
201
+ type_val = _field(task, "type")
202
+ type_str = type_val.value if hasattr(type_val, "value") else str(type_val)
203
+ state_val = _field(task, "state")
204
+ state_str = state_val.value if hasattr(state_val, "value") else str(state_val)
205
+ created_str = format_timestamp(_field(task, "created_at"))
206
+ table.add_row(id_str, type_str, state_str, created_str)
207
+
208
+ console.print(table)
209
+
210
+
211
+ def dump_task_json(obj: Any) -> None:
212
+ """Serialise *obj* to stdout as a single JSON document."""
213
+
214
+ def _to_jsonable(value: Any) -> Any:
215
+ if hasattr(value, "model_dump"):
216
+ return value.model_dump(mode="json")
217
+ if isinstance(value, dict):
218
+ return {key: _to_jsonable(item) for key, item in value.items()}
219
+ if isinstance(value, list):
220
+ return [_to_jsonable(item) for item in value]
221
+ if isinstance(value, datetime.datetime):
222
+ return value.isoformat()
223
+ return value
224
+
225
+ payload = _to_jsonable(obj)
226
+ click.echo(json.dumps(payload, indent=2, default=str))
227
+
228
+
229
+ def print_search_table(rows: list[dict], columns: list[str]) -> None:
230
+ """Render search-result *rows* as a Rich table over the given *columns*.
231
+
232
+ Each row is a flat ``dict`` keyed by column name; missing keys render as an
233
+ empty cell. Prints ``(no tasks)`` when *rows* is empty. Used by
234
+ ``analogcli task search`` to surface the per-job ``user_id`` (and, with
235
+ ``--recursive``, device metadata) that the plain task table omits.
236
+
237
+ A wide console (no fixed column limit) is used so that cell content is not
238
+ truncated with "…" regardless of how many columns are rendered.
239
+ """
240
+ if not rows:
241
+ console.print("(no tasks)")
242
+ return
243
+
244
+ wide = Console(width=max(200, 20 * len(columns)))
245
+ table = Table()
246
+ for col in columns:
247
+ table.add_column(col)
248
+
249
+ for row in rows:
250
+ table.add_row(*[str(row.get(col, "")) for col in columns])
251
+
252
+ wide.print(table)
253
+
254
+
255
+ def write_task_csv(rows: list[dict], path: str, columns: list[str]) -> None:
256
+ """Write search-result *rows* to *path* as CSV with a *columns* header.
257
+
258
+ Always emits the header row, so an empty *rows* yields a header-only file.
259
+ Cells absent from a row are written blank. Column order follows *columns*.
260
+ """
261
+ with open(path, "w", newline="") as fh:
262
+ writer = csv.DictWriter(fh, fieldnames=columns, extrasaction="ignore")
263
+ writer.writeheader()
264
+ for row in rows:
265
+ writer.writerow(row)
266
+
267
+
268
+ def format_json_output(data: dict) -> str:
269
+ """Return *data* as an indented JSON string."""
270
+ return json.dumps(data, indent=2)
271
+
272
+
273
+ def display_system_info(
274
+ system_info: SystemStatusResponse,
275
+ devices: list[DeviceResponse],
276
+ ) -> None:
277
+ """Render system status and device tables to the console."""
278
+ system_table = Table(title="System Status", show_header=False)
279
+ system_table.add_column("Property", style="cyan")
280
+ system_table.add_column("Value", style="white")
281
+
282
+ system_table.add_row("Domain", system_info.domain)
283
+ system_table.add_row("Version", system_info.version)
284
+ system_table.add_row("Uptime", format_uptime(system_info.uptime))
285
+
286
+ console.print(system_table)
287
+ console.print()
288
+
289
+ if not devices:
290
+ console.print("[yellow]No devices connected[/yellow]")
291
+ return
292
+
293
+ available = [d for d in devices if d.available]
294
+ unavailable = [d for d in devices if not d.available]
295
+
296
+ if available:
297
+ avail_table = Table(title="Available Devices")
298
+ avail_table.add_column("Name", style="green")
299
+ avail_table.add_column("Type", style="cyan")
300
+ avail_table.add_column("Queue", style="yellow")
301
+
302
+ for device in available:
303
+ avail_table.add_row(
304
+ device.name, device.type.value, str(device.queue_length)
305
+ )
306
+
307
+ console.print(avail_table)
308
+ console.print()
309
+
310
+ if unavailable:
311
+ unavail_table = Table(title="Unavailable Devices")
312
+ unavail_table.add_column("Name", style="red")
313
+ unavail_table.add_column("Type", style="cyan")
314
+ unavail_table.add_column("Queue", style="yellow")
315
+
316
+ for device in unavailable:
317
+ unavail_table.add_row(
318
+ device.name, device.type.value, str(device.queue_length)
319
+ )
320
+
321
+ console.print(unavail_table)
322
+
323
+
324
+ def display_task_logs(lines: list[str]) -> None:
325
+ """Render a list of log lines in a bordered panel."""
326
+ if not lines:
327
+ console.print("[yellow]No logs available[/yellow]")
328
+ return
329
+
330
+ console.print(Panel("\n".join(lines), title="Task Logs", border_style="blue"))
331
+
332
+
333
+ def display_task_output(output_data: Any) -> None:
334
+ """Render task output as formatted JSON."""
335
+ if output_data is None:
336
+ console.print("[yellow]No output data available[/yellow]")
337
+ return
338
+
339
+ json_str = (
340
+ format_json_output(output_data)
341
+ if isinstance(output_data, dict)
342
+ else json.dumps(output_data, indent=2)
343
+ )
344
+ syntax = Syntax(json_str, "json", theme="monokai", line_numbers=True)
345
+ console.print(Panel(syntax, title="Task Output", border_style="green"))
346
+
347
+
348
+ def wait_for_task_with_progress(
349
+ client: AnalogCloudClient,
350
+ task_id: str,
351
+ task_description: str = "Running task",
352
+ ) -> TaskStatusResponse:
353
+ """Poll *task_id* with a Rich spinner until it reaches a terminal state."""
354
+
355
+ with Progress(
356
+ SpinnerColumn(),
357
+ TextColumn("[progress.description]{task.description}"),
358
+ console=console,
359
+ transient=True,
360
+ ) as progress:
361
+ ptask = progress.add_task(
362
+ description=f"{task_description} (ID: {str(task_id)[:8]}...)", total=None
363
+ )
364
+
365
+ while True:
366
+ try:
367
+ status = client.get_task_status(task_id)
368
+
369
+ progress.update(
370
+ ptask,
371
+ description=f"{task_description} — {status.state.value} (ID: {str(task_id)[:8]}...)",
372
+ )
373
+
374
+ if status.state in TERMINAL_TASK_STATES:
375
+ break
376
+
377
+ time.sleep(1.0)
378
+
379
+ except AnalogCloudAPIError as exc:
380
+ progress.update(ptask, description=f"Error checking task status: {exc}")
381
+ raise
382
+
383
+ return status
384
+
385
+
386
+ def validate_json_file(file_path: str) -> dict:
387
+ """Load and validate a JSON file, printing user-friendly errors on failure."""
388
+ try:
389
+ from .client import load_json_file
390
+
391
+ return load_json_file(file_path)
392
+ except FileNotFoundError:
393
+ console.print(f"[red]Error: File not found: {file_path}[/red]")
394
+ raise click.Abort()
395
+ except ValueError as exc:
396
+ console.print(f"[red]Error: {exc}[/red]")
397
+ raise click.Abort()
398
+ except Exception as exc:
399
+ console.print(f"[red]Error reading file: {exc}[/red]")
400
+ raise click.Abort()
401
+
402
+
403
+ def load_binary_file(file_path: str) -> bytes:
404
+ """Read a binary file and return its contents.
405
+
406
+ Raises ``click.Abort`` with a user-friendly message on failure.
407
+ """
408
+ import os
409
+
410
+ if not os.path.exists(file_path):
411
+ console.print(f"[red]Error: File not found: {file_path}[/red]")
412
+ raise click.Abort()
413
+
414
+ try:
415
+ with open(file_path, "rb") as f:
416
+ return f.read()
417
+ except Exception as exc:
418
+ console.print(f"[red]Error reading file: {exc}[/red]")
419
+ raise click.Abort()
420
+
421
+
@@ -0,0 +1,276 @@
1
+ Metadata-Version: 2.1
2
+ Name: analogcli
3
+ Version: 0.1.4
4
+ Summary: Command-line interface for the Analog Cloud system
5
+ Author-Email: Anabrid <info@anabrid.com>
6
+ Requires-Python: <4.0,>=3.11
7
+ Requires-Dist: click<9.0.0,>=8.1.7
8
+ Requires-Dist: httpx<1.0.0,>=0.25.0
9
+ Requires-Dist: analogcloud-common==0.1.4
10
+ Requires-Dist: numpy>=2.4
11
+ Requires-Dist: pydantic<3.0.0,>=2.0.0
12
+ Requires-Dist: rich<14.0.0,>=13.0.0
13
+ Requires-Dist: sympy>=1.14.0
14
+ Description-Content-Type: text/markdown
15
+
16
+ # Analog Cloud CLI
17
+
18
+ Command-line interface for the Analog Cloud analog computing system.
19
+
20
+ ## Overview
21
+
22
+ The Analog Cloud CLI provides a convenient command-line interface to interact with Analog Cloud's analog computing devices through the REST API. You can submit tasks, monitor their progress, and retrieve results directly from your terminal.
23
+
24
+ ## Installation
25
+
26
+ 1. Navigate to the CLI directory:
27
+ ```bash
28
+ cd components/cli
29
+ ```
30
+
31
+ 2. Install using pip:
32
+ ```bash
33
+ pip install analogcli
34
+ ```
35
+
36
+ 3. The CLI tool will be available as `analogcli` command after installation.
37
+
38
+ ## Usage
39
+
40
+ ### Basic Commands
41
+
42
+ The CLI connects to an Analog Cloud API server. By default, it connects to `localhost:8000`, but you can specify different host and port:
43
+
44
+ ```bash
45
+ # Connect to default server
46
+ analogcli status
47
+
48
+ # Connect to different server
49
+ analogcli -h 192.168.1.100 -p 8080 status
50
+ ```
51
+
52
+ ### Available Commands
53
+
54
+ #### 1. System Status
55
+ Get system information and connected devices:
56
+ ```bash
57
+ analogcli status
58
+ ```
59
+
60
+ #### 2. Run Tasks
61
+ Execute a configuration on physical LUCIDAC device:
62
+ ```bash
63
+ # Basic run
64
+ analogcli run config.json
65
+
66
+ # With custom parameters
67
+ analogcli run config.json --sample-rate 20000 --op-time 0.5 --device device1
68
+ ```
69
+
70
+ Options:
71
+ - `--sample-rate`: Number of samples per second (default: 10000)
72
+ - `--op-time`: Integration time in seconds (default: 0.1)
73
+ - `--device`: Specific device ID (auto-detected if not specified)
74
+
75
+ #### 3. Simulate Tasks
76
+ Execute a configuration on the built-in simulator:
77
+ ```bash
78
+ # Basic simulation
79
+ analogcli simulate config.json
80
+
81
+ # With simulator options
82
+ analogcli simulate config.json --k0 1000 --with-limits --op-time 1.0
83
+ ```
84
+
85
+ Options:
86
+ - `--sample-rate`: Number of samples per second (default: 10000)
87
+ - `--op-time`: Integration time in seconds (default: 0.1)
88
+ - `--k0`: Acceleration factor (choices: 1, 10, 100, 1000, 10000; default: 10000)
89
+ - `--with-limits`: Enable integrator capacity limits
90
+ - `--device`: Device context (auto-detected if not specified)
91
+
92
+ #### 4. Compile Tasks
93
+ Compile an ODE specification to device configuration:
94
+ ```bash
95
+ # Basic compilation
96
+ analogcli compiler ode.json
97
+ ```
98
+
99
+ #### 5. Async submission with `--follow / --no-follow`
100
+
101
+ `compile` and `run` are interactive by default (`--follow`): the CLI
102
+ submits the task, polls the server, and prints logs and results in
103
+ place. For pipelines and long-running jobs use `--no-follow`:
104
+
105
+ ```bash
106
+ # Fire and forget — print only the UUID on stdout
107
+ TASK_ID=$(analogcli run config.json --no-follow)
108
+
109
+ # Use the id with task subcommands later
110
+ analogcli task wait "$TASK_ID"
111
+ analogcli task result "$TASK_ID" -o result.json
112
+ ```
113
+
114
+ In `--no-follow` mode stdout is **strictly** the UUID; the "Task
115
+ submitted" notice goes to stderr so it does not corrupt pipelines.
116
+
117
+ ### Task management — `analogcli task`
118
+
119
+ The `task` subcommand group inspects, awaits, and withdraws tasks
120
+ asynchronously. Output is TTY-aware: interactive terminals get Rich
121
+ panels and tables, pipes get JSON (or raw text for `task logs`).
122
+
123
+ ```bash
124
+ analogcli task list [--state S] [--type T] [--limit N] [--offset N]
125
+ [--ids-only] [--format text|json]
126
+ analogcli task info <id> [--format text|json]
127
+ analogcli task status <id> [--format text|json]
128
+ analogcli task logs <id> [--follow] [--format text|json]
129
+ analogcli task result <id> [-o FILE]
130
+ analogcli task wait <id> [--poll-interval S] [--timeout S]
131
+ analogcli task revoke <id>
132
+ analogcli task delete <id>
133
+ ```
134
+
135
+ - `task list` enumerates tasks visible to your API key. Use
136
+ `--ids-only` to emit one UUID per line for shell loops; use
137
+ `--format json` for a structured dump.
138
+ - `task info` shows the full detail record (type-specific fields for
139
+ compile vs run).
140
+ - `task status` shows the current state and the state-transition
141
+ history.
142
+ - `task logs` prints the captured log output. With `--follow` the
143
+ command polls every second and emits only newly appended lines until
144
+ the task reaches a terminal state. Off a TTY the snapshot defaults
145
+ to raw text so `grep` works.
146
+ - `task result` writes the task's result payload. Compile tasks
147
+ produce binary device configurations (written to `-o FILE` or to
148
+ binary stdout); run tasks produce JSON (pretty-printed, written to
149
+ `-o FILE` or stdout).
150
+ - `task wait` blocks until the task reaches a terminal state and
151
+ carries the outcome via the exit code — stdout is left empty so the
152
+ shell can branch on `$?` without parsing output.
153
+ - `task revoke` withdraws a `QUEUED` task. It does *not* abort a task
154
+ that is already running (the worker keeps it). The wire-level
155
+ endpoint remains `POST /tasks/{id}/cancel`; the CLI verb is named
156
+ `revoke` because that better reflects the user-visible semantic.
157
+ - `task delete` removes a task and its blobs. The server refuses
158
+ `DELETE` on an `INPROGRESS` task whose worker run is less than two
159
+ minutes old, to protect the worker.
160
+
161
+ #### Exit-code contract
162
+
163
+ Shell scripts can rely on the following exit codes:
164
+
165
+ | Code | Meaning |
166
+ |------|---------|
167
+ | 0 | success / `SUCCEEDED` |
168
+ | 1 | task `FAILED` (for `wait`) |
169
+ | 2 | task `CANCELLED` (for `wait`) |
170
+ | 3 | transport / API error (connection refused, 5xx, etc.) |
171
+ | 4 | state conflict — e.g. `revoke` on a non-`QUEUED` task, or `delete` inside the worker grace window (HTTP 409) |
172
+ | 124 | timeout (matches GNU `timeout`); emitted by `task wait --timeout S` |
173
+
174
+ Example: revoke a queued task only if it is still queued, otherwise
175
+ fall through to waiting on it:
176
+
177
+ ```bash
178
+ if ! analogcli task revoke "$TASK_ID" 2>/dev/null; then
179
+ case $? in
180
+ 4) analogcli task wait "$TASK_ID" ;; # already running — wait it out
181
+ *) exit $? ;;
182
+ esac
183
+ fi
184
+ ```
185
+
186
+ ### File Formats
187
+
188
+ #### Configuration Files (for run/simulate)
189
+ JSON files containing analog circuit configurations:
190
+ ```json
191
+ {
192
+ "integrators": {
193
+ "int0": {"initial_value": 1.0}
194
+ },
195
+ "constants": {
196
+ "k1": 2.5
197
+ },
198
+ "connections": []
199
+ }
200
+ ```
201
+
202
+ #### ODE Files (for compiler)
203
+ JSON files containing ODE specifications:
204
+ ```json
205
+ {
206
+ "variables": ["x", "y"],
207
+ "equations": {
208
+ "x": "-y",
209
+ "y": "x"
210
+ },
211
+ "initial_conditions": {
212
+ "x": 1.0,
213
+ "y": 0.0
214
+ }
215
+ }
216
+ ```
217
+
218
+ ### Example Workflow
219
+
220
+ 1. Check system status:
221
+ ```bash
222
+ analogcli status
223
+ ```
224
+
225
+ 2. Compile an ODE to configuration:
226
+ ```bash
227
+ analogcli compiler my_ode.json
228
+ # Save the output to compiled_config.json when prompted
229
+ ```
230
+
231
+ 3. Run the compiled configuration:
232
+ ```bash
233
+ analogcli run compiled_config.json --sample-rate 25000 --op-time 0.2
234
+ ```
235
+
236
+ 4. Or simulate instead:
237
+ ```bash
238
+ analogcli simulate compiled_config.json --k0 1000 --with-limits
239
+ ```
240
+
241
+ ## Environment Variables
242
+
243
+ | Variable | Description |
244
+ |----------|-------------|
245
+ | `ANALOGCLOUD_API_KEY` | API key for authentication (required) |
246
+ | `ANALOGCLOUD_URL` | Base URL of the Analog Cloud server |
247
+
248
+ ## Development
249
+
250
+ ### Running Tests
251
+ ```bash
252
+ uv run pytest
253
+ ```
254
+
255
+ ### Code Formatting
256
+ ```bash
257
+ uv run black .
258
+ ```
259
+
260
+ ## Dependencies
261
+
262
+ - **click**: Command-line interface framework
263
+ - **httpx**: HTTP client for API communication
264
+ - **pydantic**: Data validation using the shared models
265
+ - **rich**: Rich text and beautiful formatting in the terminal
266
+ - **analogcloud-common**: Shared models and utilities
267
+
268
+ ## Error Handling
269
+
270
+ The CLI provides user-friendly error messages for common issues:
271
+ - Connection errors to the API server
272
+ - Invalid JSON files
273
+ - Missing files
274
+ - API errors with detailed messages
275
+
276
+ Progress indicators show task status during execution, and results are displayed in a formatted, readable way.