cyberwave-cli 0.11.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ # Init for cyberwave_cli package
cyberwave_cli/core.py ADDED
@@ -0,0 +1,42 @@
1
+ import typer
2
+ from rich import print
3
+ from .plugins import loader
4
+
5
+ app = typer.Typer(rich_markup_mode="markdown")
6
+
7
+ def main():
8
+ # Discover and register plugins
9
+ loader.register_all(app)
10
+ # Run the app
11
+ app()
12
+
13
+ @app.callback()
14
+ def callback():
15
+ """
16
+ CyberWave Command-Line Interface
17
+ """
18
+ pass
19
+
20
+ @app.command()
21
+ def version() -> None:
22
+ """Show the installed CLI version."""
23
+ import importlib.metadata
24
+
25
+ cli_version = importlib.metadata.version("cyberwave-cli")
26
+ print(f"CyberWave CLI version: [bold green]{cli_version}[/bold green]")
27
+
28
+
29
+ @app.command()
30
+ def plugins_cmd() -> None:
31
+ """List available CLI plugins."""
32
+ plugin_names = loader.discover_plugins()
33
+ if not plugin_names:
34
+ print("No plugins found")
35
+ else:
36
+ print("Loaded plugins:")
37
+ for name in plugin_names:
38
+ print(f"- {name}")
39
+
40
+
41
+ if __name__ == "__main__":
42
+ main()
@@ -0,0 +1 @@
1
+ # Init for cyberwave_cli.plugins package
@@ -0,0 +1 @@
1
+ # Init for assets plugin
@@ -0,0 +1,87 @@
1
+ import asyncio
2
+ import re
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ import typer
7
+ from rich import print
8
+
9
+ from cyberwave import Client
10
+
11
+ app = typer.Typer(help="Manage CyberWave assets (upload meshes to the catalog, list).")
12
+
13
+
14
+ def _simple_slugify(text: str) -> str:
15
+ """Very small helper to create URL-friendly slugs."""
16
+ slug = re.sub(r"[^a-zA-Z0-9-]+", "-", text.lower()).strip("-")
17
+ return slug
18
+
19
+
20
+ async def _upload_asset(
21
+ mesh_path: Path,
22
+ name: str,
23
+ workspace_uuid: str,
24
+ project_uuid: str,
25
+ description: Optional[str],
26
+ registry_id: Optional[str],
27
+ ) -> None:
28
+ client = Client()
29
+ await client.login()
30
+ # Create a simple catalog asset and attach a GLB file
31
+ # Prefer high-level helpers if present (test stub API)
32
+ if hasattr(client, "create_asset_definition"):
33
+ created = await client.create_asset_definition(name=name)
34
+ await client.upload_mesh(created["id"], mesh_path)
35
+ await client.add_geometry_to_asset_definition(created["id"])
36
+ print(f":white_check_mark: Created asset '[bold]{name}[/bold]' (ID {created['id']}) and uploaded GLB")
37
+ return
38
+ # Fallback to raw request + upload the GLB
39
+ created = await client._request(
40
+ "POST",
41
+ "/assets",
42
+ json={
43
+ "name": name,
44
+ "description": description or "",
45
+ "public": False,
46
+ "registry_id": registry_id,
47
+ },
48
+ )
49
+ asset = created.json() if hasattr(created, "json") else created
50
+ await client.upload_asset_glb(asset.get("uuid") or asset.get("id"), mesh_path)
51
+ # If no thumbnail, attempt to auto-generate a simple one from the GLB path (placeholder icon)
52
+ # Backend may also generate thumbnails asynchronously; this is a best-effort client-side fallback.
53
+ try:
54
+ # For now, rely on backend pipeline; printing hint for the user.
55
+ print(":bulb: If the asset lacks a thumbnail, the backend will attempt to generate one.")
56
+ except Exception:
57
+ pass
58
+ print(f":white_check_mark: Created asset '[bold]{name}[/bold]' (UUID {asset['uuid']}) and uploaded GLB")
59
+
60
+ @app.command()
61
+ def upload(
62
+ mesh: Path = typer.Argument(..., exists=True, help="Path to a mesh file"),
63
+ name: str = typer.Option(..., "--name", help="Name for the catalog entry"),
64
+ workspace: str = typer.Option(..., "--workspace", "-w", help="Workspace UUID"),
65
+ project: str = typer.Option(..., "--project", "-p", help="Project UUID (for future)"),
66
+ registry_id: Optional[str] = typer.Option(None, "--registry-id", help="Catalog registry identifier (e.g., so/100)"),
67
+ description: Optional[str] = typer.Option(None, "--description", help="Description"),
68
+ ):
69
+ """Upload a mesh and create a catalog asset definition."""
70
+
71
+ asyncio.run(_upload_asset(mesh, name, workspace, project, description, registry_id))
72
+
73
+ @app.command("list") # Explicit command name
74
+ def list_assets():
75
+ """List available assets in CyberWave storage."""
76
+ async def _run() -> None:
77
+ client = Client()
78
+ await client.login()
79
+ assets = await client.list_assets()
80
+ for a in assets:
81
+ rid = a.get("registry_id") or (a.get("metadata", {}) or {}).get("registry_id")
82
+ print(f"{a.get('uuid')} {a.get('name')} registry_id={rid}")
83
+
84
+ asyncio.run(_run())
85
+
86
+ if __name__ == "__main__":
87
+ app()
@@ -0,0 +1 @@
1
+ # Auth plugin for web-based authentication
@@ -0,0 +1,340 @@
1
+ import asyncio
2
+ import webbrowser
3
+ import time
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Optional, Dict, Any
7
+ import os
8
+
9
+ import typer
10
+ from rich import print
11
+ from rich.prompt import Confirm
12
+ from rich.console import Console
13
+ from rich.table import Table
14
+ import httpx
15
+
16
+ console = Console()
17
+ app = typer.Typer(help="Authentication and configuration management")
18
+
19
+ # Configuration constants
20
+ CONFIG_DIR = Path.home() / ".cyberwave"
21
+ CONFIG_FILE = CONFIG_DIR / "config.toml"
22
+ DEFAULT_FRONTEND_URL = "http://localhost:3000"
23
+ DEFAULT_BACKEND_URL = "http://localhost:8000"
24
+
25
+
26
+ class DeviceAuthFlow:
27
+ """Handles device flow authentication with the CyberWave platform."""
28
+
29
+ def __init__(self, backend_url: str, frontend_url: str):
30
+ self.backend_url = backend_url.rstrip('/')
31
+ self.frontend_url = frontend_url.rstrip('/')
32
+ self.client = httpx.AsyncClient(timeout=30.0)
33
+
34
+ async def initiate_device_flow(self) -> Dict[str, Any]:
35
+ """
36
+ Initiate device flow authentication.
37
+ Returns device code, user code, and verification URLs.
38
+ """
39
+ try:
40
+ response = await self.client.post(
41
+ f"{self.backend_url}/api/v1/auth/device/initiate",
42
+ json={"client_type": "cli"}
43
+ )
44
+ response.raise_for_status()
45
+ return response.json()
46
+ except httpx.HTTPError as e:
47
+ raise typer.Exit(f"Failed to initiate device flow: {e}")
48
+
49
+ async def poll_for_token(self, device_code: str, interval: int = 5) -> Optional[Dict[str, Any]]:
50
+ """
51
+ Poll the backend for token completion.
52
+ Returns tokens when user completes authentication, None if still pending.
53
+ """
54
+ try:
55
+ response = await self.client.post(
56
+ f"{self.backend_url}/api/v1/auth/device/token",
57
+ json={"device_code": device_code}
58
+ )
59
+
60
+ if response.status_code == 200:
61
+ return response.json()
62
+ elif response.status_code == 202:
63
+ # Still pending
64
+ return None
65
+ else:
66
+ response.raise_for_status()
67
+ except httpx.HTTPError:
68
+ return None
69
+
70
+ async def close(self):
71
+ """Close the HTTP client."""
72
+ await self.client.aclose()
73
+
74
+
75
+ def load_config() -> Dict[str, Any]:
76
+ """Load CLI configuration from file."""
77
+ if not CONFIG_FILE.exists():
78
+ return {}
79
+
80
+ try:
81
+ import tomllib
82
+ except ImportError:
83
+ try:
84
+ import tomli as tomllib
85
+ except ImportError:
86
+ print("[red]Error: Neither tomllib nor tomli is available. Please install tomli: pip install tomli[/red]")
87
+ raise typer.Exit(1)
88
+
89
+ try:
90
+ with open(CONFIG_FILE, 'rb') as f:
91
+ return tomllib.load(f)
92
+ except Exception as e:
93
+ print(f"[red]Error reading config file: {e}[/red]")
94
+ return {}
95
+
96
+
97
+ def save_config(config: Dict[str, Any]) -> None:
98
+ """Save CLI configuration to file."""
99
+ CONFIG_DIR.mkdir(exist_ok=True)
100
+
101
+ try:
102
+ import tomli_w
103
+ except ImportError:
104
+ print("[red]Error: tomli_w is not available. Please install tomli-w: pip install tomli-w[/red]")
105
+ raise typer.Exit(1)
106
+
107
+ try:
108
+ with open(CONFIG_FILE, 'wb') as f:
109
+ tomli_w.dump(config, f)
110
+ print(f"[green]Configuration saved to {CONFIG_FILE}[/green]")
111
+ except Exception as e:
112
+ print(f"[red]Error saving config: {e}[/red]")
113
+ raise typer.Exit(1)
114
+
115
+
116
+ @app.command()
117
+ def login(
118
+ backend_url: Optional[str] = typer.Option(None, "--backend-url", help="Backend URL"),
119
+ frontend_url: Optional[str] = typer.Option(None, "--frontend-url", help="Frontend URL"),
120
+ no_browser: bool = typer.Option(False, "--no-browser", help="Don't open browser automatically")
121
+ ) -> None:
122
+ """
123
+ Authenticate with CyberWave using web-based device flow.
124
+
125
+ This will open your web browser to complete authentication on the CyberWave platform.
126
+ """
127
+ asyncio.run(_login_flow(backend_url, frontend_url, no_browser))
128
+
129
+
130
+ async def _login_flow(backend_url: Optional[str], frontend_url: Optional[str], no_browser: bool) -> None:
131
+ """Async implementation of login flow."""
132
+ config = load_config()
133
+
134
+ # Use provided URLs or fall back to config or defaults
135
+ backend_url = backend_url or config.get("backend_url", DEFAULT_BACKEND_URL)
136
+ frontend_url = frontend_url or config.get("frontend_url", DEFAULT_FRONTEND_URL)
137
+
138
+ print(f"[cyan]Initiating authentication with CyberWave...[/cyan]")
139
+ print(f"Backend: {backend_url}")
140
+ print(f"Frontend: {frontend_url}")
141
+
142
+ auth_flow = DeviceAuthFlow(backend_url, frontend_url)
143
+
144
+ try:
145
+ # Step 1: Initiate device flow
146
+ device_data = await auth_flow.initiate_device_flow()
147
+
148
+ device_code = device_data["device_code"]
149
+ user_code = device_data["user_code"]
150
+ verification_url = device_data["verification_url"]
151
+ expires_in = device_data.get("expires_in", 300) # 5 minutes default
152
+ interval = device_data.get("interval", 5) # 5 seconds default
153
+
154
+ # Step 2: Display instructions to user
155
+ print(f"\n[bold yellow]Authentication Required[/bold yellow]")
156
+ print(f"Please visit: [bold blue]{verification_url}[/bold blue]")
157
+ print(f"And enter the code: [bold green]{user_code}[/bold green]")
158
+ print(f"\nCode expires in {expires_in} seconds")
159
+
160
+ # Step 3: Open browser (unless disabled)
161
+ if not no_browser:
162
+ try:
163
+ # Construct the full URL with the user code
164
+ full_url = f"{frontend_url}/auth/device?user_code={user_code}"
165
+ webbrowser.open(full_url)
166
+ print(f"[dim]Opened browser to: {full_url}[/dim]")
167
+ except Exception as e:
168
+ print(f"[yellow]Could not open browser automatically: {e}[/yellow]")
169
+
170
+ # Step 4: Poll for completion
171
+ print(f"\n[dim]Waiting for authentication completion...[/dim]")
172
+ start_time = time.time()
173
+
174
+ with console.status("[bold green]Waiting for authentication...") as status:
175
+ while time.time() - start_time < expires_in:
176
+ token_data = await auth_flow.poll_for_token(device_code, interval)
177
+
178
+ if token_data:
179
+ # Success! Store tokens
180
+ print(f"\n[green]✓ Authentication successful![/green]")
181
+
182
+ # Save tokens using the SDK's token storage
183
+ from cyberwave import Client
184
+ client = Client(base_url=backend_url)
185
+ client._access_token = token_data["access_token"]
186
+ client._refresh_token = token_data.get("refresh_token")
187
+ client._session_info = {k: v for k, v in token_data.items()
188
+ if k not in ["access_token", "refresh_token"]}
189
+
190
+ if client._use_token_cache:
191
+ client._save_token_to_cache()
192
+
193
+ # Update config with URLs
194
+ config.update({
195
+ "backend_url": backend_url,
196
+ "frontend_url": frontend_url,
197
+ "default_workspace": token_data.get("default_workspace"),
198
+ "default_project": token_data.get("default_project")
199
+ })
200
+ save_config(config)
201
+
202
+ # Display user info
203
+ try:
204
+ user_info = await client.get_current_user_info()
205
+ print(f"Logged in as: [bold]{user_info.get('email', 'Unknown')}[/bold]")
206
+ except Exception:
207
+ print("Logged in successfully!")
208
+
209
+ await client.aclose()
210
+ return
211
+
212
+ # Wait before next poll
213
+ await asyncio.sleep(interval)
214
+ status.update(f"[bold green]Waiting for authentication... ({int(expires_in - (time.time() - start_time))}s remaining)")
215
+
216
+ print(f"\n[red]✗ Authentication timed out. Please try again.[/red]")
217
+
218
+ except Exception as e:
219
+ print(f"[red]✗ Authentication failed: {e}[/red]")
220
+ raise typer.Exit(1)
221
+ finally:
222
+ await auth_flow.close()
223
+
224
+
225
+ @app.command()
226
+ def logout() -> None:
227
+ """Log out and clear stored authentication."""
228
+ try:
229
+ from cyberwave import Client
230
+
231
+ # Clear tokens from cache
232
+ client = Client()
233
+ asyncio.run(client.logout())
234
+
235
+ print("[green]✓ Successfully logged out[/green]")
236
+ except Exception as e:
237
+ print(f"[red]Error during logout: {e}[/red]")
238
+ raise typer.Exit(1)
239
+
240
+
241
+ @app.command()
242
+ def status() -> None:
243
+ """Show current authentication and configuration status."""
244
+ config = load_config()
245
+
246
+ # Check if user is authenticated
247
+ from cyberwave import Client
248
+ client = Client()
249
+
250
+ table = Table(title="CyberWave CLI Status", show_header=True, header_style="bold magenta")
251
+ table.add_column("Setting", style="cyan", width=20)
252
+ table.add_column("Value", style="white")
253
+
254
+ # Authentication status
255
+ if client._access_token:
256
+ table.add_row("Authentication", "[green]✓ Authenticated[/green]")
257
+
258
+ # Try to get user info
259
+ try:
260
+ user_info = asyncio.run(client.get_current_user_info())
261
+ table.add_row("User", user_info.get('email', 'Unknown'))
262
+ except Exception:
263
+ table.add_row("User", "[yellow]Token may be expired[/yellow]")
264
+ else:
265
+ table.add_row("Authentication", "[red]✗ Not authenticated[/red]")
266
+
267
+ # Configuration
268
+ table.add_row("Backend URL", config.get("backend_url", DEFAULT_BACKEND_URL))
269
+ table.add_row("Frontend URL", config.get("frontend_url", DEFAULT_FRONTEND_URL))
270
+ table.add_row("Default Workspace", str(config.get("default_workspace", "Not set")))
271
+ table.add_row("Default Project", str(config.get("default_project", "Not set")))
272
+ table.add_row("Config File", str(CONFIG_FILE))
273
+
274
+ console.print(table)
275
+
276
+
277
+ @app.command()
278
+ def config(
279
+ key: Optional[str] = typer.Argument(None, help="Configuration key to set or get"),
280
+ value: Optional[str] = typer.Argument(None, help="Value to set (omit to get current value)"),
281
+ list_all: bool = typer.Option(False, "--list", "-l", help="List all configuration"),
282
+ unset: bool = typer.Option(False, "--unset", "-u", help="Unset a configuration key")
283
+ ) -> None:
284
+ """
285
+ Manage CLI configuration.
286
+
287
+ Examples:
288
+ cyberwave auth config backend_url http://localhost:8000
289
+ cyberwave auth config backend_url
290
+ cyberwave auth config --list
291
+ cyberwave auth config --unset default_workspace
292
+ """
293
+ current_config = load_config()
294
+
295
+ if list_all or (key is None and value is None):
296
+ # List all configuration
297
+ if not current_config:
298
+ print("[yellow]No configuration found[/yellow]")
299
+ return
300
+
301
+ table = Table(title="Configuration", show_header=True, header_style="bold magenta")
302
+ table.add_column("Key", style="cyan")
303
+ table.add_column("Value", style="white")
304
+
305
+ for k, v in current_config.items():
306
+ table.add_row(k, str(v))
307
+
308
+ console.print(table)
309
+ return
310
+
311
+ if key is None:
312
+ print("[red]Error: Please specify a configuration key[/red]")
313
+ raise typer.Exit(1)
314
+
315
+ if unset:
316
+ # Unset a key
317
+ if key in current_config:
318
+ del current_config[key]
319
+ save_config(current_config)
320
+ print(f"[green]Unset {key}[/green]")
321
+ else:
322
+ print(f"[yellow]Key '{key}' not found[/yellow]")
323
+ return
324
+
325
+ if value is None:
326
+ # Get current value
327
+ if key in current_config:
328
+ print(f"{key} = {current_config[key]}")
329
+ else:
330
+ print(f"[yellow]Key '{key}' not found[/yellow]")
331
+ return
332
+
333
+ # Set value
334
+ current_config[key] = value
335
+ save_config(current_config)
336
+ print(f"[green]Set {key} = {value}[/green]")
337
+
338
+
339
+ if __name__ == "__main__":
340
+ app()
@@ -0,0 +1 @@
1
+ # Device management CLI plugin
@@ -0,0 +1,49 @@
1
+ import asyncio
2
+ from typing import Optional
3
+
4
+ import typer
5
+ from rich import print
6
+
7
+ from cyberwave import Client
8
+
9
+ app = typer.Typer(help="Manage devices (registration and tokens)")
10
+
11
+
12
+ @app.command("register")
13
+ def register_device(
14
+ project: int = typer.Option(..., "--project", "-p", help="Project ID"),
15
+ name: str = typer.Option(..., "--name", "-n", help="Device name"),
16
+ type: str = typer.Option(..., "--type", "-t", help="Device type"),
17
+ asset: Optional[str] = typer.Option(None, "--asset", "-a", help="Asset catalog UUID"),
18
+ ) -> None:
19
+ """Register a new device."""
20
+
21
+ async def _run() -> None:
22
+ client = Client()
23
+ await client.login()
24
+ device = await client.register_device(
25
+ project_id=project,
26
+ name=name,
27
+ device_type=type,
28
+ asset_catalog_uuid=asset,
29
+ )
30
+ print(f":white_check_mark: Registered device [bold]{name}[/bold] (ID {device['id']})")
31
+
32
+ asyncio.run(_run())
33
+
34
+
35
+ @app.command("issue-offline-token")
36
+ def issue_offline_token(device: int = typer.Option(..., "--device", "-d", help="Device ID")) -> None:
37
+ """Issue an offline token for a device."""
38
+
39
+ async def _run() -> None:
40
+ client = Client()
41
+ await client.login()
42
+ token = await client.issue_device_token(device_id=device)
43
+ print(f":key: Offline token for device {device}: [bold]{token}[/bold]")
44
+
45
+ asyncio.run(_run())
46
+
47
+
48
+ if __name__ == "__main__":
49
+ app()
@@ -0,0 +1,149 @@
1
+ import json
2
+ import os
3
+ import subprocess
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ import typer
8
+ from rich import print
9
+
10
+ from cyberwave import Client
11
+
12
+ app = typer.Typer(help="Configure and run Cyberwave Edge nodes")
13
+
14
+ DEFAULT_EDGE_CONFIG = Path.home() / ".cyberwave" / "edge.json"
15
+
16
+
17
+ def _write_config_file(cfg_path: Path, data: dict) -> None:
18
+ cfg_path.parent.mkdir(parents=True, exist_ok=True)
19
+ with open(cfg_path, "w", encoding="utf-8") as f:
20
+ json.dump(data, f, indent=2)
21
+ os.chmod(cfg_path, 0o600)
22
+
23
+
24
+ @app.command("init")
25
+ def init(
26
+ robot: str = typer.Option(..., "--robot", help="Robot driver type, e.g. so_arm100"),
27
+ port: Optional[str] = typer.Option(None, "--port", help="Serial/connection port for the robot"),
28
+ backend: Optional[str] = typer.Option(None, "--backend", help="Backend base URL (e.g. http://localhost:8000/api/v1)"),
29
+ device_id: Optional[int] = typer.Option(None, "--device-id", help="Existing device ID"),
30
+ project_id: Optional[int] = typer.Option(None, "--project", help="Project ID for auto-registration"),
31
+ device_name: Optional[str] = typer.Option(None, "--device-name", help="Device name for auto-registration"),
32
+ device_type: Optional[str] = typer.Option("robot/so-arm100", "--device-type", help="Device type for auto-registration"),
33
+ auto_register: bool = typer.Option(False, "--auto-register", help="Register device if device_id not provided"),
34
+ use_device_token: bool = typer.Option(False, "--use-device-token", help="Issue and use a device token for offline operation"),
35
+ loop_hz: float = typer.Option(20.0, "--loop-hz", help="Polling frequency (Hz)"),
36
+ config: Path = typer.Option(DEFAULT_EDGE_CONFIG, "--config", help="Where to write the Edge config JSON"),
37
+ ) -> None:
38
+ """Create or update an Edge node configuration file."""
39
+
40
+ data = {
41
+ "robot_type": robot,
42
+ "robot_port": port,
43
+ "backend_url": backend,
44
+ "device_id": device_id,
45
+ "project_id": project_id,
46
+ "device_name": device_name,
47
+ "device_type": device_type,
48
+ "auto_register_device": auto_register,
49
+ "use_device_token": use_device_token,
50
+ "loop_hz": loop_hz,
51
+ }
52
+
53
+ # Attempt to reuse existing CLI auth to fill backend if not provided
54
+ if backend is None:
55
+ from cyberwave_cli.plugins.auth.app import load_config as _load_cli_cfg, DEFAULT_BACKEND_URL
56
+ cli_cfg = _load_cli_cfg()
57
+ data["backend_url"] = cli_cfg.get("backend_url", DEFAULT_BACKEND_URL) + "/api/v1"
58
+
59
+ # If auto-register requested and no device_id, perform registration and optionally issue a token
60
+ if auto_register and not device_id:
61
+ if not project_id or not device_name or not device_type:
62
+ raise typer.Exit("--project, --device-name, and --device-type are required for --auto-register")
63
+ async def _run():
64
+ client = Client(base_url=data["backend_url"])
65
+ await client.login()
66
+ dev = await client.register_device(project_id=project_id, name=device_name, device_type=device_type)
67
+ data["device_id"] = int(dev.get("id"))
68
+ if use_device_token:
69
+ token = await client.issue_device_token(device_id=data["device_id"])
70
+ data["access_token"] = token
71
+ await client.aclose()
72
+ import asyncio; asyncio.run(_run())
73
+
74
+ _write_config_file(config, data)
75
+ print(f":white_check_mark: Edge config written to [bold]{config}[/bold]")
76
+
77
+
78
+ @app.command("run")
79
+ def run(config: Path = typer.Option(DEFAULT_EDGE_CONFIG, "--config", help="Edge config JSON path")) -> None:
80
+ """Run the Edge node with the provided configuration file."""
81
+ if not config.exists():
82
+ raise typer.Exit(f"Config file not found: {config}")
83
+ # Prefer running the module directly to use current env
84
+ cmd = ["python", "-m", "cyberwave_edge.main", "--config", str(config)]
85
+ print(f"Starting Edge: {' '.join(cmd)}")
86
+ subprocess.run(cmd, check=False)
87
+
88
+
89
+ @app.command("simulate")
90
+ def simulate(
91
+ sensor: str = typer.Option(..., "--sensor", "-s", help="Sensor UUID"),
92
+ video: Path = typer.Option(..., "--video", "-v", help="Path to local mp4"),
93
+ fps: float = typer.Option(2.0, "--fps"),
94
+ ) -> None:
95
+ """Stream a local video file as a virtual camera to a sensor."""
96
+ cmd = [
97
+ "python", "-m", "cyberwave_edge.camera_worker",
98
+ "--sensor", sensor,
99
+ "--source", str(video),
100
+ "--fps", str(fps),
101
+ ]
102
+ print(f"Simulating edge camera: {' '.join(cmd)}")
103
+ subprocess.run(cmd, check=False)
104
+
105
+ @app.command("status")
106
+ def status(config: Path = typer.Option(DEFAULT_EDGE_CONFIG, "--config", help="Edge config JSON path")) -> None:
107
+ """Quick diagnostics for Edge configuration and cloud connectivity."""
108
+ if not config.exists():
109
+ raise typer.Exit(f"Config file not found: {config}")
110
+ with open(config, "r", encoding="utf-8") as f:
111
+ cfg = json.load(f)
112
+
113
+ missing = [k for k in ("robot_type", "backend_url") if not cfg.get(k)]
114
+ if missing:
115
+ print(f"[red]Missing required config keys: {missing}[/red]")
116
+ raise typer.Exit(1)
117
+
118
+ # Try SDK token and device reachability
119
+ async def _check():
120
+ client = Client(base_url=cfg["backend_url"])
121
+ try:
122
+ await client.login() # uses cached token via SDK
123
+ except Exception:
124
+ if tok := cfg.get("access_token"):
125
+ client._access_token = tok # fallback to provided device token
126
+ else:
127
+ print("[yellow]No active session; telemetry may fail without token[/yellow]")
128
+ did = cfg.get("device_id")
129
+ ok = True
130
+ if did:
131
+ try:
132
+ # simple check: send empty telemetry as dry run
133
+ await client.send_telemetry(did, {"ping": True})
134
+ print(f"[green]✓ Cloud reachable. Device {did} accepted telemetry[/green]")
135
+ except Exception as e:
136
+ ok = False
137
+ print(f"[red]✗ Telemetry check failed for device {did}: {e}[/red]")
138
+ await client.aclose()
139
+ return ok
140
+
141
+ import asyncio; ok = asyncio.run(_check())
142
+ if not ok:
143
+ raise typer.Exit(1)
144
+
145
+
146
+ if __name__ == "__main__":
147
+ app()
148
+
149
+
@@ -0,0 +1,75 @@
1
+ import asyncio
2
+ import typer
3
+ from rich import print
4
+
5
+ from cyberwave import Client
6
+
7
+ app = typer.Typer(help="Manage environments")
8
+
9
+
10
+ @app.command("create")
11
+ def create(
12
+ project: str = typer.Option(..., "--project", "-p", help="Project UUID"),
13
+ name: str = typer.Option(..., "--name", "-n"),
14
+ description: str = typer.Option("", "--description", "-d"),
15
+ ):
16
+ async def _run():
17
+ client = Client()
18
+ await client.login()
19
+ env = await client.create_environment(project_uuid=project, name=name, description=description)
20
+ print(f"[green]✓[/green] Created environment [bold]{env.get('name')}[/bold] (UUID {env.get('uuid')})")
21
+ await client.aclose()
22
+ asyncio.run(_run())
23
+
24
+
25
+ @app.command("list")
26
+ def list_envs(project: str = typer.Option(..., "--project", "-p", help="Project UUID")):
27
+ async def _run():
28
+ client = Client()
29
+ await client.login()
30
+ envs = await client.get_environments(project_uuid=project)
31
+ for e in envs:
32
+ print(f"- {e.get('name')} ({e.get('uuid')})")
33
+ await client.aclose()
34
+ asyncio.run(_run())
35
+
36
+
37
+ @app.command("events")
38
+ def list_events(
39
+ environment: str = typer.Option(..., "--environment", "-e", help="Environment UUID"),
40
+ limit: int = typer.Option(10, "--limit", "-n", help="Max entries to show"),
41
+ json_out: bool = typer.Option(False, "--json", help="Emit JSON output"),
42
+ ):
43
+ """List recent environment events (latest session per twin), including segment thumbnails when available."""
44
+ async def _run():
45
+ client = Client()
46
+ await client.login()
47
+ resp = await client._client.get(f"/environments/{environment}/events", headers=client._get_headers())
48
+ resp.raise_for_status()
49
+ data = resp.json()
50
+ from datetime import datetime
51
+ if json_out:
52
+ import json as _json
53
+ print(_json.dumps(data[:limit], indent=2))
54
+ else:
55
+ shown = 0
56
+ for item in data:
57
+ if shown >= limit:
58
+ break
59
+ twin = item.get("twin_uuid")
60
+ sess = item.get("session", {})
61
+ seg = item.get("segment") or {}
62
+ ts = sess.get("started_at_unix")
63
+ ts_str = datetime.fromtimestamp(ts).isoformat() if ts else "?"
64
+ seg_url = seg.get("url")
65
+ seg_key = seg.get("storage_key")
66
+ print(f"- twin={twin} started_at={ts_str} segment_key={seg_key} segment_url={seg_url}")
67
+ shown += 1
68
+ await client.aclose()
69
+ asyncio.run(_run())
70
+
71
+
72
+ if __name__ == "__main__":
73
+ app()
74
+
75
+
@@ -0,0 +1,43 @@
1
+ import typer
2
+ import importlib.metadata as metadata
3
+ from rich import print
4
+
5
+
6
+ from importlib.metadata import EntryPoint
7
+ from typing import Iterable, List
8
+
9
+
10
+ def _get_entry_points(group: str) -> Iterable[EntryPoint]:
11
+ """Return entry points for the given group across Python versions."""
12
+ try: # Python >=3.10
13
+ return metadata.entry_points(group=group)
14
+ except TypeError: # Python <3.10
15
+ return metadata.entry_points().get(group, [])
16
+
17
+
18
+ def discover_plugins() -> List[str]:
19
+ """Return a list of available plugin names."""
20
+ return [ep.name for ep in _get_entry_points("cyberwave.cli.plugins")]
21
+
22
+ def register_all(root_app: typer.Typer) -> None:
23
+ """Discovers and registers all plugins declared via entry points."""
24
+ print("[dim]Discovering CLI plugins...[/dim]")
25
+ discovered_plugins = list(_get_entry_points("cyberwave.cli.plugins"))
26
+
27
+ if not discovered_plugins:
28
+ print("[dim]No external plugins found.[/dim]")
29
+ return
30
+
31
+ for ep in discovered_plugins:
32
+ print(f" - Loading plugin: [bold cyan]{ep.name}[/bold cyan]")
33
+ try:
34
+ sub_app = ep.load()
35
+ if isinstance(sub_app, typer.Typer):
36
+ root_app.add_typer(sub_app, name=ep.name)
37
+ print(f" [green]✓[/green] Registered typer app for '[bold]{ep.name}[/bold]'")
38
+ else:
39
+ # Handle single commands if needed, though spec implies Typer apps
40
+ print(f" [yellow]⚠[/yellow] Plugin '[bold]{ep.name}[/bold]' did not load a Typer app. Skipping.")
41
+ except Exception as e:
42
+ print(f" [red]✗[/red] Failed to load plugin '[bold]{ep.name}[/bold]': {e}")
43
+
@@ -0,0 +1 @@
1
+ # Init for projects plugin
@@ -0,0 +1,55 @@
1
+ import asyncio
2
+ from typing import List, Dict
3
+
4
+ import typer
5
+ from rich import print
6
+
7
+ from cyberwave import Client
8
+
9
+ app = typer.Typer(help="Manage projects within a workspace")
10
+
11
+
12
+ @app.command()
13
+ def create(
14
+ name: str = typer.Option(..., "--name", "-n", help="Project name"),
15
+ workspace: str = typer.Option(..., "--workspace", "-w", help="Workspace UUID"),
16
+ ) -> None:
17
+ """Create a new project."""
18
+
19
+ async def _run() -> None:
20
+ client = Client()
21
+ await client.login()
22
+ # Ensure workspace_id is numeric if API expects int
23
+ try:
24
+ ws_id = int(workspace)
25
+ except Exception:
26
+ ws_id = workspace
27
+ project = await client.create_project(workspace_id=ws_id, name=name)
28
+ print(f":white_check_mark: Created project [bold]{name}[/bold] (UUID {project.get('uuid') or project.get('id')})")
29
+
30
+ asyncio.run(_run())
31
+
32
+
33
+ @app.command("list")
34
+ def list_projects(
35
+ workspace: str = typer.Option(..., "--workspace", "-w", help="Workspace UUID"),
36
+ ) -> None:
37
+ """List projects within a workspace."""
38
+
39
+ async def _run() -> None:
40
+ client = Client()
41
+ await client.login()
42
+ try:
43
+ ws_id = int(workspace)
44
+ except Exception:
45
+ ws_id = workspace
46
+ projects: List[Dict] = await client.get_projects(workspace_id=ws_id)
47
+ for proj in projects:
48
+ print(f"{proj.get('uuid') or proj.get('id')}: {proj.get('name')}")
49
+
50
+ asyncio.run(_run())
51
+
52
+
53
+ if __name__ == "__main__":
54
+ app()
55
+
@@ -0,0 +1,138 @@
1
+ import asyncio
2
+ import typer
3
+ from rich import print
4
+
5
+ from cyberwave import Client
6
+ import httpx
7
+ import json
8
+
9
+ app = typer.Typer(help="Manage sensors")
10
+
11
+
12
+ @app.command("list")
13
+ def list_sensors(environment: str = typer.Option(..., "--environment", "-e", help="Environment UUID")):
14
+ async def _run():
15
+ client = Client()
16
+ await client.login()
17
+ sensors = await client._client.get(f"/environments/{environment}/sensors", headers=client._get_headers())
18
+ sensors.raise_for_status()
19
+ for s in sensors.json():
20
+ print(f"- {s.get('name')} ({s.get('uuid')}) type={s.get('sensor_type')} twin={s.get('twin_uuid')}")
21
+ await client.aclose()
22
+ asyncio.run(_run())
23
+
24
+
25
+ @app.command("events")
26
+ def sensor_events(
27
+ sensor_uuid: str = typer.Option(..., "--sensor", "-s"),
28
+ environment: str = typer.Option(None, "--environment", "-e", help="Environment UUID (to locate latest session)"),
29
+ limit: int = typer.Option(50, "--limit", "-n"),
30
+ ):
31
+ """Tail analyzer events for a sensor.
32
+
33
+ - Backend mode (default): polls /api/v1/sensors/{sensor_uuid}/events and prints latest events.
34
+ - Node+session mode (fallback): if --environment is provided and there is an active teleop session,
35
+ read session NDJSON and filter events for the sensor.
36
+ """
37
+ async def _run():
38
+ client = Client()
39
+ await client.login()
40
+ # Preferred: backend mode
41
+ try:
42
+ r = await client._client.get(f"/sensors/{sensor_uuid}/events", headers=client._get_headers())
43
+ r.raise_for_status()
44
+ events = r.json()
45
+ for ev in events[-limit:]:
46
+ print(ev)
47
+ await client.aclose(); return
48
+ except httpx.HTTPStatusError:
49
+ pass
50
+
51
+ # Fallback: node+session mode requires environment to locate latest session
52
+ if not environment:
53
+ print("[yellow]Backend events endpoint unavailable. Provide --environment to fallback to session log tailing.[/yellow]")
54
+ await client.aclose(); return
55
+ sensors = await client._client.get(f"/environments/{environment}/sensors", headers=client._get_headers())
56
+ sensors.raise_for_status()
57
+ twin_uuid = None
58
+ for s in sensors.json():
59
+ if s.get("uuid") == sensor_uuid:
60
+ twin_uuid = s.get("twin_uuid")
61
+ break
62
+ if not twin_uuid:
63
+ print(f"[red]Sensor {sensor_uuid} not found in environment {environment}[/red]")
64
+ await client.aclose(); return
65
+ evs = await client._client.get(f"/environments/{environment}/events", headers=client._get_headers())
66
+ evs.raise_for_status()
67
+ session_id = None
68
+ for row in evs.json():
69
+ if row.get("twin_uuid") == twin_uuid:
70
+ session_id = row.get("session", {}).get("session_id")
71
+ break
72
+ if not session_id:
73
+ print(f"[yellow]No sessions found for twin {twin_uuid}[/yellow]")
74
+ await client.aclose(); return
75
+ r = await client._client.get(f"/twins/{twin_uuid}/teleop/sessions/{session_id}/events", headers=client._get_headers())
76
+ r.raise_for_status()
77
+ count = 0
78
+ for line in r.text.splitlines():
79
+ try:
80
+ obj = json.loads(line)
81
+ except Exception:
82
+ continue
83
+ if obj.get("type") == "event" and obj.get("payload", {}).get("sensor_uuid") == sensor_uuid:
84
+ analyzer = obj.get("payload", {}).get("analyzer")
85
+ ts = obj.get("ts_unix")
86
+ print(f"{ts} analyzer={analyzer} event={obj.get('payload')}")
87
+ count += 1
88
+ if count >= limit:
89
+ break
90
+ await client.aclose()
91
+ asyncio.run(_run())
92
+
93
+ @app.command("create")
94
+ def create_sensor(
95
+ environment: str = typer.Option(..., "--environment", "-e"),
96
+ name: str = typer.Option(..., "--name", "-n"),
97
+ sensor_type: str = typer.Option("camera", "--type", "-t"),
98
+ twin: str = typer.Option(None, "--twin"),
99
+ ):
100
+ async def _run():
101
+ client = Client()
102
+ await client.login()
103
+ payload = {"name": name, "sensor_type": sensor_type, "twin_uuid": twin}
104
+ resp = await client._client.post(f"/environments/{environment}/sensors", json=payload, headers=client._get_headers())
105
+ resp.raise_for_status()
106
+ s = resp.json()
107
+ print(f"[green]✓[/green] Created sensor [bold]{s.get('name')}[/bold] (UUID {s.get('uuid')})")
108
+ await client.aclose()
109
+ asyncio.run(_run())
110
+
111
+
112
+ @app.command("update")
113
+ def update_sensor(
114
+ environment: str = typer.Option(..., "--environment", "-e"),
115
+ sensor_uuid: str = typer.Option(..., "--sensor", "-s"),
116
+ name: str = typer.Option(None, "--name"),
117
+ sensor_type: str = typer.Option(None, "--type"),
118
+ twin: str = typer.Option(None, "--twin"),
119
+ ):
120
+ async def _run():
121
+ client = Client()
122
+ await client.login()
123
+ payload = {}
124
+ if name is not None: payload["name"] = name
125
+ if sensor_type is not None: payload["sensor_type"] = sensor_type
126
+ if twin is not None: payload["twin_uuid"] = twin
127
+ resp = await client._client.put(f"/environments/{environment}/sensors/{sensor_uuid}", json=payload, headers=client._get_headers())
128
+ resp.raise_for_status()
129
+ s = resp.json()
130
+ print(f"[green]✓[/green] Updated sensor [bold]{s.get('name')}[/bold] (UUID {s.get('uuid')})")
131
+ await client.aclose()
132
+ asyncio.run(_run())
133
+
134
+
135
+ if __name__ == "__main__":
136
+ app()
137
+
138
+
@@ -0,0 +1,17 @@
1
+ import typer
2
+ from rich import print
3
+
4
+ app = typer.Typer(help="Control local simulations (Isaac Sim, Unity).")
5
+
6
+ @app.command()
7
+ def launch(simulator: str = typer.Option("isaac", help="Simulator type ('isaac' or 'unity')")):
8
+ """Launch a local simulation environment."""
9
+ print(f":rocket: Launching local [yellow]{simulator}[/yellow] simulation... (not implemented)")
10
+
11
+ @app.command()
12
+ def status():
13
+ """Check the status of running local simulations."""
14
+ print(":magnifying_glass_right: Checking simulation status... (not implemented)")
15
+
16
+ if __name__ == "__main__":
17
+ app()
@@ -0,0 +1 @@
1
+ # Init for telemetry plugin
@@ -0,0 +1,17 @@
1
+ import typer
2
+ from rich import print
3
+
4
+ app = typer.Typer(help="Access robot telemetry data (logs, live streams).")
5
+
6
+ @app.command()
7
+ def logs(robot_id: str = typer.Argument(..., help="ID of the robot")):
8
+ """Fetch logs for a specific robot."""
9
+ print(f":scroll: Fetching logs for robot [bold blue]{robot_id}[/bold blue]... (not implemented)")
10
+
11
+ @app.command()
12
+ def stream(robot_id: str = typer.Argument(..., help="ID of the robot")):
13
+ """Stream live telemetry data for a specific robot."""
14
+ print(f":satellite_antenna: Streaming live data for robot [bold blue]{robot_id}[/bold blue]... (not implemented)")
15
+
16
+ if __name__ == "__main__":
17
+ app()
@@ -0,0 +1,66 @@
1
+ import asyncio
2
+ import json
3
+ from typing import Optional
4
+
5
+ import typer
6
+ from rich import print
7
+
8
+ from cyberwave import Client
9
+
10
+ app = typer.Typer(help="Twin commands and control (teleop)")
11
+
12
+
13
+ @app.command("command")
14
+ def send_command(
15
+ twin: str = typer.Option(..., "--twin", "-t", help="Twin UUID"),
16
+ name: str = typer.Option(..., "--name", "-n", help="Command name, e.g., arm.move_joints"),
17
+ joints: Optional[str] = typer.Option(None, "--joints", help="JSON list of joint positions for arm.move_joints"),
18
+ pose: Optional[str] = typer.Option(None, "--pose", help='JSON dict pose for arm.move_pose/move_to (e.g., {"x":0.1, "y":0.2, "z":0.0})'),
19
+ mode: str = typer.Option("both", "--mode", help="sim|real|both"),
20
+ source: Optional[str] = typer.Option("cli", "--source", help="Source tag, e.g., cli/web-ui/edge-leader"),
21
+ ) -> None:
22
+ """Send a unified command to a twin via the backend TeleopControllerService."""
23
+
24
+ async def _run():
25
+ client = Client()
26
+ await client.login()
27
+
28
+ payload: dict = {"name": name, "payload": {}, "mode": mode, "source": source}
29
+ if name.endswith("move_joints") and joints:
30
+ payload["payload"] = {"joints": json.loads(joints)}
31
+ elif name.endswith("move_pose") or name.endswith("move_to") or name.endswith("fly_to"):
32
+ if pose:
33
+ payload["payload"] = {"pose": json.loads(pose)}
34
+ # Fallback: if user gave raw payload via pose or joints json that doesn't match name, just pass through
35
+
36
+ # Use SDK internals to POST
37
+ headers = client._get_headers() # type: ignore[attr-defined]
38
+ resp = await client._client.post(f"/twins/{twin}/commands", json=payload, headers=headers) # type: ignore[attr-defined]
39
+ if resp.status_code >= 400:
40
+ print(f"[red]Command failed: {resp.status_code} {resp.text}[/red]")
41
+ else:
42
+ print(f"[green]✓ Command sent: {name}[/green]")
43
+ await client.aclose()
44
+
45
+ asyncio.run(_run())
46
+
47
+
48
+ @app.command("apply-defaults")
49
+ def apply_defaults(twin: str = typer.Option(..., "--twin", "-t", help="Twin UUID")) -> None:
50
+ """Apply asset catalog state defaults (pose, joints, logical) to the twin."""
51
+ async def _run():
52
+ client = Client()
53
+ await client.login()
54
+ headers = client._get_headers()
55
+ resp = await client._client.post(f"/twins/{twin}/apply-defaults", headers=headers)
56
+ if resp.status_code >= 400:
57
+ print(f"[red]Failed to apply defaults: {resp.status_code} {resp.text}[/red]")
58
+ else:
59
+ print(f"[green]✓ Defaults applied[/green] {resp.json()}")
60
+ await client.aclose()
61
+ asyncio.run(_run())
62
+
63
+ if __name__ == "__main__":
64
+ app()
65
+
66
+
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.4
2
+ Name: cyberwave-cli
3
+ Version: 0.11.0
4
+ Summary: Cyberwave command-line interface (plugins, asset pipeline, telemetry)
5
+ Requires-Dist: typer[all]>=0.12
6
+ Requires-Dist: rich>=13
7
+ Requires-Dist: importlib_metadata>=6.0; python_version < "3.10"
8
+ Requires-Dist: setuptools>=61.0
9
+ Requires-Dist: httpx>=0.24.0
10
+ Requires-Dist: tomli>=2.0.0; python_version < "3.11"
11
+ Requires-Dist: tomli-w>=1.0.0
12
+ Provides-Extra: sdk
13
+ Requires-Dist: cyberwave<0.12,>=0.11; extra == "sdk"
14
+ Provides-Extra: test
15
+ Requires-Dist: pytest>=7.0; extra == "test"
16
+ Requires-Dist: pytest-cov; extra == "test"
17
+ Requires-Dist: pytest-mock; extra == "test"
@@ -0,0 +1,24 @@
1
+ cyberwave_cli/__init__.py,sha256=FGDbBQ9Fmw1exvYKdJWlFmWxvAIZAMb-6QxrYiTkMIg,33
2
+ cyberwave_cli/core.py,sha256=aFL63zvGDO7Kd8Z0WwRM2FunKdnznY6542RXueGZcn0,897
3
+ cyberwave_cli/plugins/__init__.py,sha256=Mi4fla-L0jlXM7F62-yEwxJe03ROcv1zuLNDLAyT-g0,41
4
+ cyberwave_cli/plugins/loader.py,sha256=jm_U_-XUHZqmhddJ8vtr6YqLYZH_zsYQMQhgZe2YXvo,1673
5
+ cyberwave_cli/plugins/assets/__init__.py,sha256=T9QRK4DS85JAeu4uq5sK51aWUGqTVyx-AmNwWnJ7E-A,25
6
+ cyberwave_cli/plugins/assets/app.py,sha256=HU3qMJyP2NbRcfYXPIQxYEeKft2DofK-p5ysdptR7Io,3335
7
+ cyberwave_cli/plugins/auth/__init__.py,sha256=_V4d0s0oIMfJJWLxmxiUPdWBYqyLykcaCiAIbknZH7c,43
8
+ cyberwave_cli/plugins/auth/app.py,sha256=8fPp1AvV4TDMW6h6VryqECXy3ljO0BkqlNs98HIgm8k,12370
9
+ cyberwave_cli/plugins/devices/__init__.py,sha256=j_LQ2cQzv34mZoKGT7efEI2TvDiH7F0qMBSuzhhaUmM,31
10
+ cyberwave_cli/plugins/devices/app.py,sha256=NtNCsexEmBoPeVxDNEXUi3gdLcQINdSJF4mCo88ZlVQ,1448
11
+ cyberwave_cli/plugins/edge/app.py,sha256=AIH8nd0XRUd2Gyd3ZRR-bWQJg_qo8IVbaHKzQOhYR2A,6209
12
+ cyberwave_cli/plugins/environments/app.py,sha256=mALlzR2LzjgFy1eF_mQ7J92kce5osK6nvPw2-3D_OZ0,2609
13
+ cyberwave_cli/plugins/projects/__init__.py,sha256=4cVt2vOy-JQ_-gF2CQF__oLZliHUBkPHBihoxt9LezE,27
14
+ cyberwave_cli/plugins/projects/app.py,sha256=zl2Ko9TOFSCIo5EmlXz4Ahj9lTpMk58AMZzN53jVthA,1499
15
+ cyberwave_cli/plugins/sensors/app.py,sha256=_zY_NghwcKfnaUIgdWPOX-fQUYhRZE8FcVSuS8g0MMI,5517
16
+ cyberwave_cli/plugins/sim/app.py,sha256=KNjkR4YU3QkLAtPPDNpIh7mRHNxlVwiFwJG8DuvURiY,581
17
+ cyberwave_cli/plugins/telemetry/__init__.py,sha256=rz87C8_j2gF5JGW8dJjO1WR9PedXYj5yMNDOPQTX6Mc,28
18
+ cyberwave_cli/plugins/telemetry/app.py,sha256=Ae6UAA6JcT9E9wqEYDaTmb1Cme-tdTVmzZUS0o4Ylf4,650
19
+ cyberwave_cli/plugins/twins/app.py,sha256=DG1QUo2mgwTRJ9aG-3oDdo6tDcil4rvIuFYSgMwVKdA,2704
20
+ cyberwave_cli-0.11.0.dist-info/METADATA,sha256=9sdak6CDqkgi92Fb7WmOdQYBMa-KcmE44FIBpqu-aZk,626
21
+ cyberwave_cli-0.11.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
22
+ cyberwave_cli-0.11.0.dist-info/entry_points.txt,sha256=KTGLdbgB3E8Dsn1vOkqjgA6GdisabfFhPZ6_VhDcoTo,411
23
+ cyberwave_cli-0.11.0.dist-info/top_level.txt,sha256=2L8SNq9MKHTbggUoUZZ8wqbdr8DuEOzrcecOcBjzaqc,14
24
+ cyberwave_cli-0.11.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,11 @@
1
+ [console_scripts]
2
+ cyberwave = cyberwave_cli.core:main
3
+
4
+ [cyberwave.cli.plugins]
5
+ auth = cyberwave_cli.plugins.auth.app:app
6
+ devices = cyberwave_cli.plugins.devices.app:app
7
+ edge = cyberwave_cli.plugins.edge.app:app
8
+ environments = cyberwave_cli.plugins.environments.app:app
9
+ projects = cyberwave_cli.plugins.projects.app:app
10
+ sensors = cyberwave_cli.plugins.sensors.app:app
11
+ twins = cyberwave_cli.plugins.twins.app:app
@@ -0,0 +1 @@
1
+ cyberwave_cli