edgelens-cli 0.2.1__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,8 @@
1
+ """edgelens CLI — interactive sessions and quick tests without a framework.
2
+
3
+ Entry point: ``edgelens`` (set via the ``[project.scripts]`` table in
4
+ ``pyproject.toml``). For programmatic use, import ``edgelens_cli.main.cli``
5
+ directly.
6
+ """
7
+
8
+ __version__ = "0.2.1"
edgelens_cli/client.py ADDED
@@ -0,0 +1,42 @@
1
+ """Common client-construction wrapper for every command.
2
+
3
+ Resolves API key from (in priority order):
4
+ - ``--api-key`` flag on the command
5
+ - ``EDGELENS_API_KEY`` env var
6
+ - ``~/.edgelens/config.json``
7
+
8
+ Same for ``--base-url``.
9
+
10
+ Raises a friendly click error instead of letting an
11
+ ``AuthenticationError`` bubble.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Optional
17
+
18
+ import click
19
+ from edgelens import EdgeLensClient
20
+ from edgelens.errors import AuthenticationError
21
+
22
+ from edgelens_cli.state import load_config
23
+
24
+
25
+ def build_client(
26
+ api_key: Optional[str] = None,
27
+ base_url: Optional[str] = None,
28
+ ) -> EdgeLensClient:
29
+ config = load_config()
30
+ effective_key = api_key or config.api_key
31
+ effective_url = base_url or config.base_url
32
+ if not effective_key:
33
+ raise click.ClickException(
34
+ "No API key. Set EDGELENS_API_KEY or run `edgelens login`."
35
+ )
36
+ kwargs: dict = {"api_key": effective_key}
37
+ if effective_url:
38
+ kwargs["base_url"] = effective_url
39
+ try:
40
+ return EdgeLensClient(**kwargs)
41
+ except AuthenticationError as e:
42
+ raise click.ClickException(str(e))
File without changes
@@ -0,0 +1,20 @@
1
+ """Resolve a session id from explicit argument or current-session state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import click
8
+
9
+ from edgelens_cli.state import load_state
10
+
11
+
12
+ def resolve_session_id(explicit: Optional[str]) -> str:
13
+ if explicit:
14
+ return explicit
15
+ state = load_state()
16
+ if state.current_session_id is None:
17
+ raise click.ClickException(
18
+ "No current session. Run `edgelens session create ...` or `edgelens use <id>`."
19
+ )
20
+ return state.current_session_id
@@ -0,0 +1,163 @@
1
+ """Device operations on the current (or named) session."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import click
8
+ from edgelens.errors import EdgeLensError
9
+
10
+ from edgelens_cli.client import build_client
11
+ from edgelens_cli.commands._resolve import resolve_session_id
12
+ from edgelens_cli.output import emit, error
13
+
14
+
15
+ def _session_with_device(ctx: click.Context, session_id: Optional[str]): # type: ignore[no-untyped-def]
16
+ """Resolve session id → ready Session, or click-error if not ready."""
17
+ sid = resolve_session_id(session_id)
18
+ client = build_client(ctx.obj.get("api_key"), ctx.obj.get("base_url"))
19
+ try:
20
+ session = client.get_session(sid)
21
+ except EdgeLensError as e:
22
+ error(str(e))
23
+ if session.status.value != "ready":
24
+ error(
25
+ f"session {sid} is in state {session.status.value}, not ready. "
26
+ "Run `edgelens session wait` first.",
27
+ exit_code=2,
28
+ )
29
+ return session
30
+
31
+
32
+ @click.command("exec")
33
+ @click.argument("command_str")
34
+ @click.option("--session", "session_id", default=None, help="Session id (default: current).")
35
+ @click.option("--timeout", default=60.0, type=float, help="Command timeout in seconds.")
36
+ @click.pass_context
37
+ def exec_cmd(
38
+ ctx: click.Context, command_str: str, session_id: Optional[str], timeout: float
39
+ ) -> None:
40
+ """Run a shell command on the camera and print stdout / exit code."""
41
+ session = _session_with_device(ctx, session_id)
42
+ try:
43
+ result = session.device.exec(command_str, timeout=timeout)
44
+ except EdgeLensError as e:
45
+ error(str(e))
46
+
47
+ payload = {
48
+ "stdout": result.stdout,
49
+ "stderr": result.stderr,
50
+ "exit_code": result.exit_code,
51
+ }
52
+ human_parts = []
53
+ if result.stdout:
54
+ human_parts.append(result.stdout.rstrip())
55
+ if result.stderr:
56
+ human_parts.append(click.style(result.stderr.rstrip(), fg="red"))
57
+ if not human_parts:
58
+ human_parts.append(f"(no output, exit={result.exit_code})")
59
+ emit(payload, json_mode=ctx.obj["json_mode"], human="\n".join(human_parts))
60
+ if result.exit_code != 0:
61
+ ctx.exit(result.exit_code if 0 < result.exit_code < 128 else 1)
62
+
63
+
64
+ @click.command("deploy")
65
+ @click.argument("model_path", type=click.Path(exists=True, dir_okay=False))
66
+ @click.option("--config", "config_path", type=click.Path(exists=True, dir_okay=False), default=None)
67
+ @click.option(
68
+ "--file", "extra_files", type=click.Path(exists=True, dir_okay=False),
69
+ multiple=True, help="Additional supporting files (repeatable).",
70
+ )
71
+ @click.option("--session", "session_id", default=None)
72
+ @click.pass_context
73
+ def deploy_cmd(
74
+ ctx: click.Context,
75
+ model_path: str,
76
+ config_path: Optional[str],
77
+ extra_files: tuple,
78
+ session_id: Optional[str],
79
+ ) -> None:
80
+ """Deploy a model file to the camera."""
81
+ session = _session_with_device(ctx, session_id)
82
+ try:
83
+ result = session.device.deploy(
84
+ model=model_path,
85
+ config=config_path,
86
+ files=list(extra_files) or None,
87
+ )
88
+ except EdgeLensError as e:
89
+ error(str(e))
90
+ emit(
91
+ result,
92
+ json_mode=ctx.obj["json_mode"],
93
+ human=f"✓ deployed {model_path}",
94
+ )
95
+
96
+
97
+ @click.command("info")
98
+ @click.option("--session", "session_id", default=None)
99
+ @click.pass_context
100
+ def info_cmd(ctx: click.Context, session_id: Optional[str]) -> None:
101
+ """Print the device info report."""
102
+ session = _session_with_device(ctx, session_id)
103
+ try:
104
+ info = session.device.info()
105
+ except EdgeLensError as e:
106
+ error(str(e))
107
+ emit(
108
+ info.model_dump() if hasattr(info, "model_dump") else info,
109
+ json_mode=ctx.obj["json_mode"],
110
+ human=(
111
+ f"hostname: {info.hostname}\n"
112
+ f"hardware: {info.hardware}\n"
113
+ f"firmware: {info.firmware}"
114
+ ),
115
+ )
116
+
117
+
118
+ @click.command("ssh")
119
+ @click.option("--session", "session_id", default=None)
120
+ @click.option(
121
+ "--show-password", is_flag=True, default=False,
122
+ help="Print the SSH password in cleartext (or set EDGELENS_SHOW_PASSWORD=1).",
123
+ )
124
+ @click.pass_context
125
+ def ssh_cmd(
126
+ ctx: click.Context, session_id: Optional[str], show_password: bool,
127
+ ) -> None:
128
+ """Print SSH connection details.
129
+
130
+ Password is masked by default in
131
+ both --json and human modes. The plaintext is only emitted when
132
+ --show-password is passed or EDGELENS_SHOW_PASSWORD=1 in the env,
133
+ so the credential never reaches stdout or CI logs by accident.
134
+ """
135
+ import os as _os
136
+ reveal = show_password or _os.environ.get("EDGELENS_SHOW_PASSWORD") == "1"
137
+
138
+ session = _session_with_device(ctx, session_id)
139
+ try:
140
+ creds = session.device.ssh()
141
+ except EdgeLensError as e:
142
+ error(str(e))
143
+
144
+ payload = {
145
+ "user": creds.user, "host": creds.host, "port": creds.port,
146
+ }
147
+ if reveal:
148
+ payload["password"] = creds.password
149
+
150
+ password_line = (
151
+ f"password: {creds.password}\n"
152
+ if reveal
153
+ else "password: <hidden — pass --show-password to reveal>\n"
154
+ )
155
+ human = (
156
+ f"user: {creds.user}\n"
157
+ f"host: {creds.host}\n"
158
+ f"port: {creds.port}\n"
159
+ f"{password_line}"
160
+ "\n"
161
+ f"ssh -p {creds.port} {creds.user}@{creds.host}"
162
+ )
163
+ emit(payload, json_mode=ctx.obj["json_mode"], human=human)
@@ -0,0 +1,89 @@
1
+ """``edgelens display`` subcommands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import click
8
+ from edgelens.errors import EdgeLensError
9
+
10
+ from edgelens_cli.commands.device import _session_with_device
11
+ from edgelens_cli.output import emit, error
12
+
13
+
14
+ @click.group(name="display")
15
+ def display_group() -> None:
16
+ """Control the camera's external display."""
17
+
18
+
19
+ @display_group.command("play")
20
+ @click.argument("source")
21
+ @click.option("--loop/--no-loop", default=False)
22
+ @click.option("--session", "session_id", default=None)
23
+ @click.pass_context
24
+ def display_play(
25
+ ctx: click.Context, source: str, loop: bool, session_id: Optional[str]
26
+ ) -> None:
27
+ """Play a video on the display."""
28
+ session = _session_with_device(ctx, session_id)
29
+ try:
30
+ session.device.display.play(source, loop=loop)
31
+ except EdgeLensError as e:
32
+ error(str(e))
33
+ emit(
34
+ {"action": "play", "source": source, "loop": loop},
35
+ json_mode=ctx.obj["json_mode"],
36
+ human=f"✓ display playing {source}{' (loop)' if loop else ''}",
37
+ )
38
+
39
+
40
+ @display_group.command("show")
41
+ @click.argument("source")
42
+ @click.option("--session", "session_id", default=None)
43
+ @click.pass_context
44
+ def display_show(ctx: click.Context, source: str, session_id: Optional[str]) -> None:
45
+ """Show a still image on the display."""
46
+ session = _session_with_device(ctx, session_id)
47
+ try:
48
+ session.device.display.show(source)
49
+ except EdgeLensError as e:
50
+ error(str(e))
51
+ emit(
52
+ {"action": "show", "source": source},
53
+ json_mode=ctx.obj["json_mode"],
54
+ human=f"✓ display showing {source}",
55
+ )
56
+
57
+
58
+ @display_group.command("stop")
59
+ @click.option("--session", "session_id", default=None)
60
+ @click.pass_context
61
+ def display_stop(ctx: click.Context, session_id: Optional[str]) -> None:
62
+ """Stop display playback."""
63
+ session = _session_with_device(ctx, session_id)
64
+ try:
65
+ session.device.display.stop()
66
+ except EdgeLensError as e:
67
+ error(str(e))
68
+ emit({"action": "stop"}, json_mode=ctx.obj["json_mode"], human="✓ display stopped")
69
+
70
+
71
+ @display_group.command("status")
72
+ @click.option("--session", "session_id", default=None)
73
+ @click.pass_context
74
+ def display_status(ctx: click.Context, session_id: Optional[str]) -> None:
75
+ """Print the current display state."""
76
+ session = _session_with_device(ctx, session_id)
77
+ try:
78
+ state = session.device.display.status()
79
+ except EdgeLensError as e:
80
+ error(str(e))
81
+ emit(
82
+ state,
83
+ json_mode=ctx.obj["json_mode"],
84
+ human=(
85
+ f"ready: {state.get('ready')}\n"
86
+ f"state: {state.get('state')}\n"
87
+ f"source: {state.get('source') or '(none)'}"
88
+ ),
89
+ )
@@ -0,0 +1,100 @@
1
+ """``edgelens inference`` subcommands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import click
8
+ from edgelens.errors import EdgeLensError
9
+
10
+ from edgelens_cli.commands.device import _session_with_device
11
+ from edgelens_cli.output import emit, error
12
+
13
+
14
+ @click.group(name="inference")
15
+ def inference_group() -> None:
16
+ """Control on-device inference (stock OS only)."""
17
+
18
+
19
+ @inference_group.command("start")
20
+ @click.option("--session", "session_id", default=None)
21
+ @click.pass_context
22
+ def inference_start(ctx: click.Context, session_id: Optional[str]) -> None:
23
+ session = _session_with_device(ctx, session_id)
24
+ try:
25
+ session.device.inference.start()
26
+ except EdgeLensError as e:
27
+ error(str(e))
28
+ emit({"action": "start"}, json_mode=ctx.obj["json_mode"], human="✓ inference started")
29
+
30
+
31
+ @inference_group.command("stop")
32
+ @click.option("--session", "session_id", default=None)
33
+ @click.pass_context
34
+ def inference_stop(ctx: click.Context, session_id: Optional[str]) -> None:
35
+ session = _session_with_device(ctx, session_id)
36
+ try:
37
+ session.device.inference.stop()
38
+ except EdgeLensError as e:
39
+ error(str(e))
40
+ emit({"action": "stop"}, json_mode=ctx.obj["json_mode"], human="✓ inference stopped")
41
+
42
+
43
+ @inference_group.command("once")
44
+ @click.option("--session", "session_id", default=None)
45
+ @click.pass_context
46
+ def inference_once(ctx: click.Context, session_id: Optional[str]) -> None:
47
+ """Run a single frame and print the detections."""
48
+ session = _session_with_device(ctx, session_id)
49
+ try:
50
+ result = session.device.inference.once()
51
+ except EdgeLensError as e:
52
+ error(str(e))
53
+ emit(
54
+ result,
55
+ json_mode=ctx.obj["json_mode"],
56
+ human=f"detections: {len(result.get('detections', []))}",
57
+ )
58
+
59
+
60
+ @inference_group.command("stats")
61
+ @click.option("--session", "session_id", default=None)
62
+ @click.pass_context
63
+ def inference_stats(ctx: click.Context, session_id: Optional[str]) -> None:
64
+ """Print a point-in-time inference snapshot."""
65
+ session = _session_with_device(ctx, session_id)
66
+ try:
67
+ snap = session.device.inference.snapshot()
68
+ except EdgeLensError as e:
69
+ error(str(e))
70
+ payload = snap.model_dump() if hasattr(snap, "model_dump") else snap
71
+ human = (
72
+ f"fps: {snap.fps}\n"
73
+ f"latency_ms: {snap.latency_ms}\n"
74
+ f"detections: {snap.detection_count}"
75
+ )
76
+ emit(payload, json_mode=ctx.obj["json_mode"], human=human)
77
+
78
+
79
+ @inference_group.command("run")
80
+ @click.option("--duration", default=30.0, type=float, help="Seconds to run.")
81
+ @click.option("--collect-frames", is_flag=True, default=False)
82
+ @click.option("--session", "session_id", default=None)
83
+ @click.pass_context
84
+ def inference_run(
85
+ ctx: click.Context, duration: float, collect_frames: bool, session_id: Optional[str]
86
+ ) -> None:
87
+ """Run inference for a duration and print aggregated stats."""
88
+ session = _session_with_device(ctx, session_id)
89
+ try:
90
+ stats = session.device.inference.run(duration=duration, collect_frames=collect_frames)
91
+ except EdgeLensError as e:
92
+ error(str(e))
93
+ payload = stats.model_dump() if hasattr(stats, "model_dump") else stats
94
+ human = (
95
+ f"duration_s: {stats.duration_s}\n"
96
+ f"total_frames: {stats.total_frames}\n"
97
+ f"fps avg: {stats.fps.avg:.2f} p95: {stats.fps.p95:.2f}\n"
98
+ f"latency p95: {stats.latency_ms.p95:.2f}ms"
99
+ )
100
+ emit(payload, json_mode=ctx.obj["json_mode"], human=human)
@@ -0,0 +1,187 @@
1
+ """``edgelens session`` subcommands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import click
8
+ from edgelens.errors import EdgeLensError
9
+
10
+ from edgelens_cli.client import build_client
11
+ from edgelens_cli.commands._resolve import resolve_session_id
12
+ from edgelens_cli.output import emit, error
13
+ from edgelens_cli.state import clear_current_session, load_state, set_current_session
14
+
15
+
16
+ @click.group(name="session")
17
+ def session_group() -> None:
18
+ """Create, inspect, and release device sessions."""
19
+
20
+
21
+ @session_group.command("create")
22
+ @click.option(
23
+ "--device", "device_type", default="recamera-2002",
24
+ help="Device type to allocate (default: recamera-2002).",
25
+ )
26
+ @click.option(
27
+ "--os", "os_type", default="stock-os",
28
+ help="Which OS to flash (default: stock-os; alt: recamagined).",
29
+ )
30
+ @click.option(
31
+ "--timeout", "timeout_s", default=3600, type=int,
32
+ help="Max session duration in seconds (default: 3600).",
33
+ )
34
+ @click.option(
35
+ "--wait/--no-wait", default=True,
36
+ help="Block until status=ready (default: wait).",
37
+ )
38
+ @click.option(
39
+ "--wait-timeout", default=300, type=int,
40
+ help="Seconds to wait for ready (only with --wait, default: 300).",
41
+ )
42
+ @click.pass_context
43
+ def session_create(
44
+ ctx: click.Context,
45
+ device_type: str,
46
+ os_type: str,
47
+ timeout_s: int,
48
+ wait: bool,
49
+ wait_timeout: int,
50
+ ) -> None:
51
+ """Allocate a new device. Sets it as the current session on success."""
52
+ client = build_client(ctx.obj.get("api_key"), ctx.obj.get("base_url"))
53
+ try:
54
+ session = client.create_session(
55
+ device_type=device_type, os=os_type, timeout=timeout_s,
56
+ )
57
+ except EdgeLensError as e:
58
+ error(str(e))
59
+ set_current_session(session.id)
60
+ if wait:
61
+ try:
62
+ session.wait_ready(timeout=wait_timeout)
63
+ except EdgeLensError as e:
64
+ emit(
65
+ {"id": session.id, "status": session.status.value, "error": str(e)},
66
+ json_mode=ctx.obj["json_mode"],
67
+ human=f"✗ {session.id}: {e}",
68
+ )
69
+ ctx.exit(1)
70
+
71
+ emit(
72
+ _session_payload(session),
73
+ json_mode=ctx.obj["json_mode"],
74
+ human=_human_session(session),
75
+ )
76
+
77
+
78
+ @session_group.command("get")
79
+ @click.argument("session_id", required=False)
80
+ @click.pass_context
81
+ def session_get(ctx: click.Context, session_id: Optional[str]) -> None:
82
+ """Show one session. Defaults to the current session."""
83
+ session_id = resolve_session_id(session_id)
84
+ client = build_client(ctx.obj.get("api_key"), ctx.obj.get("base_url"))
85
+ try:
86
+ session = client.get_session(session_id)
87
+ except EdgeLensError as e:
88
+ error(str(e))
89
+ emit(
90
+ _session_payload(session),
91
+ json_mode=ctx.obj["json_mode"],
92
+ human=_human_session(session),
93
+ )
94
+
95
+
96
+ @session_group.command("wait")
97
+ @click.argument("session_id", required=False)
98
+ @click.option("--timeout", "wait_timeout", default=300, type=int)
99
+ @click.pass_context
100
+ def session_wait(ctx: click.Context, session_id: Optional[str], wait_timeout: int) -> None:
101
+ """Block until a session reaches status=ready."""
102
+ session_id = resolve_session_id(session_id)
103
+ client = build_client(ctx.obj.get("api_key"), ctx.obj.get("base_url"))
104
+ session = client.get_session(session_id)
105
+ try:
106
+ session.wait_ready(timeout=wait_timeout)
107
+ except EdgeLensError as e:
108
+ error(str(e))
109
+ emit(
110
+ _session_payload(session),
111
+ json_mode=ctx.obj["json_mode"],
112
+ human=_human_session(session),
113
+ )
114
+
115
+
116
+ @session_group.command("release")
117
+ @click.argument("session_id", required=False)
118
+ @click.pass_context
119
+ def session_release(ctx: click.Context, session_id: Optional[str]) -> None:
120
+ """End a session. Defaults to the current session and clears its pointer."""
121
+ session_id = resolve_session_id(session_id)
122
+ client = build_client(ctx.obj.get("api_key"), ctx.obj.get("base_url"))
123
+ try:
124
+ session = client.get_session(session_id)
125
+ session.release()
126
+ except EdgeLensError as e:
127
+ error(str(e))
128
+
129
+ # Clear `current` only if we just released what was pointed at.
130
+ state = load_state()
131
+ if state.current_session_id == session_id:
132
+ clear_current_session()
133
+
134
+ emit(
135
+ {"id": session_id, "status": "released"},
136
+ json_mode=ctx.obj["json_mode"],
137
+ human=f"✓ released {session_id}",
138
+ )
139
+
140
+
141
+ @session_group.command("list")
142
+ @click.pass_context
143
+ def session_list(ctx: click.Context) -> None:
144
+ """Show recently-touched sessions from local state.
145
+
146
+ The platform API doesn't yet expose a per-key list endpoint, so
147
+ this is a local-state echo of the last sessions this CLI touched.
148
+ """
149
+ state = load_state()
150
+ payload = {
151
+ "current_session_id": state.current_session_id,
152
+ "recent": state.recent,
153
+ }
154
+ human_lines = []
155
+ if state.current_session_id:
156
+ human_lines.append(f"* {state.current_session_id} (current)")
157
+ for sid in state.recent:
158
+ if sid != state.current_session_id:
159
+ human_lines.append(f" {sid}")
160
+ human = "\n".join(human_lines) if human_lines else "(no recent sessions)"
161
+ emit(payload, json_mode=ctx.obj["json_mode"], human=human)
162
+
163
+
164
+ def _session_payload(session) -> dict: # type: ignore[no-untyped-def]
165
+ payload: dict = {"id": session.id, "status": session.status.value}
166
+ conn = getattr(session, "_connection", None)
167
+ if conn is not None:
168
+ payload["device"] = {
169
+ "mac": conn.mac, "host": conn.host, "ssh_port": conn.ssh_port,
170
+ "ssh_user": conn.ssh_user, "os": conn.os.value,
171
+ }
172
+ if session.expires_at:
173
+ payload["expires_at"] = session.expires_at
174
+ return payload
175
+
176
+
177
+ def _human_session(session) -> str: # type: ignore[no-untyped-def]
178
+ lines = [f"id: {session.id}", f"status: {session.status.value}"]
179
+ conn = getattr(session, "_connection", None)
180
+ if conn is not None:
181
+ lines.extend([
182
+ f"device: {conn.os.value} ({conn.mac})",
183
+ f"ssh: {conn.ssh_user}@{conn.host}:{conn.ssh_port}",
184
+ ])
185
+ if session.expires_at:
186
+ lines.append(f"expires: {session.expires_at}")
187
+ return "\n".join(lines)
edgelens_cli/main.py ADDED
@@ -0,0 +1,138 @@
1
+ """CLI entry point.
2
+
3
+ The top-level group exposes a `--json` flag so every command can emit
4
+ machine-readable output. Subcommands fan out:
5
+
6
+ edgelens login save credentials
7
+ edgelens session ... create / get / wait / release / list
8
+ edgelens use SESSION_ID set current session for follow-up commands
9
+ edgelens exec / deploy / info / ssh device operations on the current session
10
+ edgelens display ... display control
11
+ edgelens inference ... inference control
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import sys
17
+ from typing import Optional
18
+
19
+ import click
20
+
21
+ from edgelens_cli import __version__
22
+ from edgelens_cli.commands import device, display, inference, session
23
+ from edgelens_cli.output import emit, error
24
+ from edgelens_cli.state import Config, clear_current_session, save_config, set_current_session
25
+
26
+
27
+ @click.group(context_settings={"help_option_names": ["-h", "--help"]})
28
+ @click.version_option(__version__, prog_name="edgelens")
29
+ @click.option(
30
+ "--json", "json_mode", is_flag=True, default=False,
31
+ help="Emit machine-readable JSON on stdout.",
32
+ )
33
+ @click.option(
34
+ "--api-key",
35
+ envvar="EDGELENS_API_KEY",
36
+ help=(
37
+ "Override the API key. Default reads EDGELENS_API_KEY or "
38
+ "~/.edgelens/config.json. Prefer EDGELENS_API_KEY: a key on the "
39
+ "command line is visible in the process list and shell history."
40
+ ),
41
+ )
42
+ @click.option(
43
+ "--base-url",
44
+ envvar="EDGELENS_BASE_URL",
45
+ help="Override the platform API base URL.",
46
+ )
47
+ @click.pass_context
48
+ def cli(
49
+ ctx: click.Context,
50
+ json_mode: bool,
51
+ api_key: Optional[str],
52
+ base_url: Optional[str],
53
+ ) -> None:
54
+ """edgelens — drive remote AI cameras from the command line."""
55
+ ctx.ensure_object(dict)
56
+ ctx.obj["json_mode"] = json_mode
57
+ ctx.obj["api_key"] = api_key
58
+ ctx.obj["base_url"] = base_url
59
+
60
+
61
+ def _stdin_is_tty() -> bool:
62
+ return sys.stdin.isatty()
63
+
64
+
65
+ def _read_api_key() -> str:
66
+ if _stdin_is_tty():
67
+ return click.prompt("API key", hide_input=True)
68
+ return sys.stdin.readline()
69
+
70
+
71
+ @cli.command()
72
+ @click.option(
73
+ "--api-key", default=None,
74
+ help=(
75
+ "API key to persist. Omit it to be prompted (input hidden) or to "
76
+ "pipe the key on stdin. Prefer that: a key on the command line is "
77
+ "visible in the process list and shell history."
78
+ ),
79
+ )
80
+ @click.option("--base-url", default=None, help="Optional platform base URL.")
81
+ @click.pass_context
82
+ def login(ctx: click.Context, api_key: Optional[str], base_url: Optional[str]) -> None:
83
+ """Persist credentials to ~/.edgelens/config.json (chmod 0600).
84
+
85
+ The key is read from a hidden prompt, or from stdin when stdin is not
86
+ a terminal.
87
+ """
88
+ if api_key is None:
89
+ api_key = _read_api_key().strip()
90
+ if not api_key:
91
+ error("no API key given", exit_code=2)
92
+ save_config(Config(api_key=api_key, base_url=base_url))
93
+ emit(
94
+ {"saved": True, "config_path": "~/.edgelens/config.json"},
95
+ json_mode=ctx.obj["json_mode"],
96
+ human="✓ credentials saved to ~/.edgelens/config.json",
97
+ )
98
+
99
+
100
+ @cli.command()
101
+ @click.pass_context
102
+ def logout(ctx: click.Context) -> None:
103
+ """Forget credentials + current session pointer."""
104
+ save_config(Config())
105
+ clear_current_session()
106
+ emit(
107
+ {"cleared": True},
108
+ json_mode=ctx.obj["json_mode"],
109
+ human="✓ cleared ~/.edgelens/config.json and ~/.edgelens/state.json",
110
+ )
111
+
112
+
113
+ @cli.command()
114
+ @click.argument("session_id")
115
+ @click.pass_context
116
+ def use(ctx: click.Context, session_id: str) -> None:
117
+ """Set the current session for follow-up commands (exec, deploy, ...)."""
118
+ if not session_id.startswith("sess_"):
119
+ error("session ids start with 'sess_'", exit_code=2)
120
+ set_current_session(session_id)
121
+ emit(
122
+ {"current_session_id": session_id},
123
+ json_mode=ctx.obj["json_mode"],
124
+ human=f"✓ current session set to {session_id}",
125
+ )
126
+
127
+
128
+ cli.add_command(session.session_group)
129
+ cli.add_command(device.exec_cmd)
130
+ cli.add_command(device.deploy_cmd)
131
+ cli.add_command(device.info_cmd)
132
+ cli.add_command(device.ssh_cmd)
133
+ cli.add_command(display.display_group)
134
+ cli.add_command(inference.inference_group)
135
+
136
+
137
+ if __name__ == "__main__":
138
+ cli()
edgelens_cli/output.py ADDED
@@ -0,0 +1,40 @@
1
+ """Output helpers — human vs JSON modes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from typing import Any, Optional
8
+
9
+ import click
10
+
11
+
12
+ def emit(payload: Any, *, json_mode: bool, human: Optional[str] = None) -> None:
13
+ """Emit ``payload`` as JSON or as the supplied ``human`` string.
14
+
15
+ When ``json_mode`` is True, the JSON form goes to stdout
16
+ regardless of ``human``. When False and ``human`` is None, we
17
+ fall back to ``str(payload)``.
18
+ """
19
+ if json_mode:
20
+ click.echo(json.dumps(payload, indent=2, default=_default))
21
+ return
22
+ click.echo(human if human is not None else str(payload))
23
+
24
+
25
+ def warn(msg: str) -> None:
26
+ click.echo(click.style(f"warning: {msg}", fg="yellow"), err=True)
27
+
28
+
29
+ def error(msg: str, *, exit_code: int = 1) -> None:
30
+ click.echo(click.style(f"error: {msg}", fg="red"), err=True)
31
+ sys.exit(exit_code)
32
+
33
+
34
+ def _default(o: Any) -> Any:
35
+ # pydantic v2 models expose model_dump
36
+ if hasattr(o, "model_dump"):
37
+ return o.model_dump()
38
+ if hasattr(o, "__dict__"):
39
+ return {k: v for k, v in o.__dict__.items() if not k.startswith("_")}
40
+ return str(o)
edgelens_cli/state.py ADDED
@@ -0,0 +1,157 @@
1
+ """Persistent CLI state (api key, current session) at ``~/.edgelens/``.
2
+
3
+ The state is split:
4
+
5
+ - ``~/.edgelens/config.json`` — auth + base URL. Written by ``edgelens login``.
6
+ - ``~/.edgelens/state.json`` — current-session pointer + recent sessions.
7
+ Written by ``edgelens session create`` / ``edgelens use``.
8
+
9
+ Both files have ``0o600`` permissions; absence of either is treated as
10
+ empty.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import json
17
+ import os
18
+ from dataclasses import asdict, dataclass, field
19
+ from pathlib import Path
20
+ from typing import Any, Optional
21
+
22
+
23
+ DEFAULT_DIR = Path.home() / ".edgelens"
24
+ CONFIG_PATH = DEFAULT_DIR / "config.json"
25
+ STATE_PATH = DEFAULT_DIR / "state.json"
26
+
27
+
28
+ def _fingerprint(api_key: Optional[str]) -> str:
29
+ """Render a key so two different keys look different and neither is readable.
30
+
31
+ A prefix of a secret is a piece of the secret, so the mask is a
32
+ one-way digest rather than a truncation: six hex characters of
33
+ sha256 identify a key to someone who already holds it and give
34
+ nothing to someone who does not.
35
+ """
36
+ if api_key is None:
37
+ return "None"
38
+ return f"<set sha256:{hashlib.sha256(api_key.encode()).hexdigest()[:6]}>"
39
+
40
+
41
+ @dataclass
42
+ class Config:
43
+ api_key: Optional[str] = field(default=None, repr=False)
44
+ base_url: Optional[str] = None
45
+
46
+ def __repr__(self) -> str:
47
+ # Any traceback carrying a Config reaches the operator's
48
+ # terminal or CI log; `asdict` is unaffected, so what gets
49
+ # persisted to config.json does not change.
50
+ return (
51
+ f"Config(api_key={_fingerprint(self.api_key)}, "
52
+ f"base_url={self.base_url!r})"
53
+ )
54
+
55
+
56
+ @dataclass
57
+ class State:
58
+ current_session_id: Optional[str] = None
59
+ recent: list[str] = field(default_factory=list)
60
+
61
+
62
+ def _read_json(path: Path) -> dict[str, Any]:
63
+ if not path.exists():
64
+ return {}
65
+ try:
66
+ return json.loads(path.read_text())
67
+ except (json.JSONDecodeError, OSError):
68
+ return {}
69
+
70
+
71
+ def _write_json(path: Path, data: dict[str, Any]) -> None:
72
+ """Atomic create with 0o600 in a
73
+ single syscall instead of write-then-chmod.
74
+
75
+ Creating the file with default perms and chmod-ing it afterwards
76
+ would leave a TOCTOU window in which another user on a multi-user
77
+ host could read the API key.
78
+ ``os.open(path, O_WRONLY|O_CREAT|O_TRUNC, 0o600)`` creates the
79
+ file with 0o600 atomically, so that window never exists.
80
+ When os.chmod is the only available knob (Windows, overlay FS,
81
+ network shares), emit a clear warning so the operator knows the
82
+ config file may not be owner-restricted.
83
+ """
84
+ import logging as _logging
85
+ _log = _logging.getLogger(__name__)
86
+
87
+ path.parent.mkdir(parents=True, exist_ok=True)
88
+ body = json.dumps(data, indent=2)
89
+
90
+ try:
91
+ fd = os.open(
92
+ str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600
93
+ )
94
+ except OSError:
95
+ # Windows / overlay paths where O_CREAT with mode is unsupported.
96
+ # Fall back to write_text + best-effort chmod, but loudly.
97
+ _log.warning(
98
+ "%s: atomic 0o600 create unavailable; falling back to "
99
+ "write_text + chmod (file may end up world-readable on "
100
+ "this platform).", path,
101
+ )
102
+ path.write_text(body)
103
+ try:
104
+ os.chmod(path, 0o600)
105
+ except OSError as e:
106
+ _log.warning(
107
+ "%s: chmod 0o600 also failed: %s. The API key in this "
108
+ "file may be readable by other local users.", path, e,
109
+ )
110
+ return
111
+
112
+ with os.fdopen(fd, "w") as f:
113
+ f.write(body)
114
+
115
+
116
+ def load_config() -> Config:
117
+ """Load ``~/.edgelens/config.json``. ``EDGELENS_*`` env vars override."""
118
+ raw = _read_json(CONFIG_PATH)
119
+ return Config(
120
+ api_key=os.environ.get("EDGELENS_API_KEY") or raw.get("api_key"),
121
+ base_url=os.environ.get("EDGELENS_BASE_URL") or raw.get("base_url"),
122
+ )
123
+
124
+
125
+ def save_config(config: Config) -> None:
126
+ """Write ``~/.edgelens/config.json``. Empty fields are stripped."""
127
+ payload = {k: v for k, v in asdict(config).items() if v is not None}
128
+ _write_json(CONFIG_PATH, payload)
129
+
130
+
131
+ def load_state() -> State:
132
+ raw = _read_json(STATE_PATH)
133
+ return State(
134
+ current_session_id=raw.get("current_session_id"),
135
+ recent=list(raw.get("recent") or []),
136
+ )
137
+
138
+
139
+ def save_state(state: State) -> None:
140
+ _write_json(STATE_PATH, asdict(state))
141
+
142
+
143
+ def set_current_session(session_id: str, *, remember: int = 10) -> None:
144
+ """Mark ``session_id`` as the current session and remember it."""
145
+ state = load_state()
146
+ state.current_session_id = session_id
147
+ if session_id in state.recent:
148
+ state.recent.remove(session_id)
149
+ state.recent.insert(0, session_id)
150
+ state.recent = state.recent[:remember]
151
+ save_state(state)
152
+
153
+
154
+ def clear_current_session() -> None:
155
+ state = load_state()
156
+ state.current_session_id = None
157
+ save_state(state)
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.5
2
+ Name: edgelens-cli
3
+ Version: 0.2.1
4
+ Summary: Command-line interface for the EdgeLens Edge AI Device Farm.
5
+ Project-URL: Homepage, https://edgelens.ai
6
+ Project-URL: Repository, https://github.com/edgelens-ai/edgelens-sdk
7
+ Project-URL: Issues, https://github.com/edgelens-ai/edgelens-sdk/issues
8
+ Author-email: EdgeLens <support@edgelens.ai>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Software Development :: Testing
16
+ Requires-Python: >=3.9
17
+ Requires-Dist: click>=8.1
18
+ Requires-Dist: edgelens-sdk<0.3,>=0.2.1
19
+ Provides-Extra: dev
20
+ Requires-Dist: mypy>=1.0; extra == 'dev'
21
+ Requires-Dist: pytest>=7.0; extra == 'dev'
22
+ Requires-Dist: ruff>=0.1; extra == 'dev'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # edgelens-cli
26
+
27
+ Command-line interface for the EdgeLens Edge AI Device Farm. Drives the
28
+ same `EdgeLensClient` as the Python SDK; useful for ad-hoc sessions,
29
+ quick experiments, and shell scripts.
30
+
31
+ ## Install
32
+
33
+ ```sh
34
+ pip install edgelens-cli
35
+ # or
36
+ pipx install edgelens-cli
37
+ ```
38
+
39
+ Adds an `edgelens` executable to your PATH.
40
+
41
+ ## Quickstart
42
+
43
+ ```sh
44
+ edgelens login # prompts for the key; input is hidden
45
+ edgelens session create --device recamera-2002 --os stock-os --timeout 600
46
+ # session id is remembered as 'current' — follow-up commands target it
47
+ edgelens info
48
+ edgelens exec 'uname -a'
49
+ edgelens deploy ./face.cvimodel
50
+ edgelens inference run --duration 30
51
+ edgelens session release
52
+ ```
53
+
54
+ In a script, pipe the key on stdin instead of passing it as an argument:
55
+
56
+ ```sh
57
+ printf '%s\n' "$EDGELENS_API_KEY" | edgelens login
58
+ ```
59
+
60
+ `--json` on the root command emits machine-readable JSON on every call,
61
+ useful for shell pipelines or CI:
62
+
63
+ ```sh
64
+ edgelens --json session create --no-wait | jq -r .id
65
+ ```
66
+
67
+ ## Commands
68
+
69
+ ```
70
+ edgelens login persist credentials (~/.edgelens/config.json)
71
+ edgelens logout forget credentials + current-session pointer
72
+ edgelens use <SESSION_ID> set current session for follow-up commands
73
+
74
+ edgelens session create [--device --os --timeout --wait/--no-wait]
75
+ edgelens session get [SESSION_ID] defaults to current
76
+ edgelens session wait [SESSION_ID] block until status=ready
77
+ edgelens session release [SESSION_ID]
78
+ edgelens session list recent sessions (from local state)
79
+
80
+ edgelens exec '<command>' run via SSH (proxied)
81
+ edgelens deploy <model_path> [--config --file ...]
82
+ edgelens info device info report
83
+ edgelens ssh print SSH connection details
84
+
85
+ edgelens display play <url> [--loop]
86
+ edgelens display show <url>
87
+ edgelens display stop
88
+ edgelens display status
89
+
90
+ edgelens inference start
91
+ edgelens inference stop
92
+ edgelens inference once
93
+ edgelens inference stats
94
+ edgelens inference run [--duration N --collect-frames]
95
+ ```
96
+
97
+ ## Configuration
98
+
99
+ | Source | Used for |
100
+ |---|---|
101
+ | `EDGELENS_API_KEY`, `EDGELENS_BASE_URL` env vars | Shell-session-scoped; the recommended way to pass a key in CI |
102
+ | `--base-url` flag | One-off override |
103
+ | `~/.edgelens/config.json` (chmod 0600) | Persistent default — set via `edgelens login` |
104
+
105
+ Current-session state lives at `~/.edgelens/state.json` (chmod 0600).
106
+ Session ids touched in the last ten commands are kept under `recent`
107
+ so `edgelens session list` shows what you were doing.
108
+
109
+ ## Develop
110
+
111
+ ```sh
112
+ python -m venv .venv && source .venv/bin/activate
113
+ pip install -e ".[dev]"
114
+ pytest
115
+ ```
116
+
@@ -0,0 +1,16 @@
1
+ edgelens_cli/__init__.py,sha256=oVSerpnUWzvsL82iEP0LKhQ2qa9UJn-tuZOJS2z2E4I,262
2
+ edgelens_cli/client.py,sha256=ugNfFm7kkcWly0KQWOkgA0sPI8urIL8j05_h7CVzjFs,1137
3
+ edgelens_cli/main.py,sha256=gOHyhLF3zCe0FGTm-gqQ3uDLyU-1L5QzVHZPv6dgBhY,4296
4
+ edgelens_cli/output.py,sha256=kHydpl2KOg23XT6lBhPlA9IhT7VJBbgkv51jr7frnBk,1145
5
+ edgelens_cli/state.py,sha256=_SdFvZSDEjeA905fBLu7XlRVyXKXYa8Dw58PwGF-F2I,4972
6
+ edgelens_cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ edgelens_cli/commands/_resolve.py,sha256=PmBwKOPSTKxXMyF0FSj9aKCESJ9DeWe38NQdPlAZpB8,537
8
+ edgelens_cli/commands/device.py,sha256=cH8XSxEcaSw-rdXaqWl5SrjJmWLbGZj4E7zggX5JAxk,5340
9
+ edgelens_cli/commands/display.py,sha256=Log76k03bXoXCIGxBkifZyjCkbBU7IlIubkUCB12ijY,2727
10
+ edgelens_cli/commands/inference.py,sha256=UWBtZmJydUnYZkgTgJo7O6ujwsI6nbuYJk4qK9nTBQ8,3506
11
+ edgelens_cli/commands/session.py,sha256=eeuWmqPIzjrGum1QrxiNjsir_YVjd6DnbXHFjAPXg5I,6161
12
+ edgelens_cli-0.2.1.dist-info/METADATA,sha256=dfCuqaOo4yqfbFSrYal85mUsxxR4H61mBWtlp39klXU,3607
13
+ edgelens_cli-0.2.1.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
14
+ edgelens_cli-0.2.1.dist-info/entry_points.txt,sha256=MjdPv8zj4sGwSPRE88lw43mFes8Uw4bvlzLu0gw4TNI,51
15
+ edgelens_cli-0.2.1.dist-info/licenses/LICENSE,sha256=JR_6_gvFCZzmYkGGa1BL36k0ZzhakowmxnXVhhPPKpc,1065
16
+ edgelens_cli-0.2.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ edgelens = edgelens_cli.main:cli
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 EdgeLens
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.