meshagent-cli 0.6.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.

Potentially problematic release.


This version of meshagent-cli might be problematic. Click here for more details.

@@ -0,0 +1,813 @@
1
+ import typer
2
+ from typing import Annotated, Optional
3
+ from meshagent.cli.common_options import ProjectIdOption, RoomOption
4
+ from rich import print
5
+ import os
6
+ import fnmatch
7
+ import glob
8
+ import shutil
9
+
10
+ from meshagent.api import RoomClient, WebSocketClientProtocol
11
+ from meshagent.api.room_server_client import StorageClient
12
+ from meshagent.api.helpers import meshagent_base_url, websocket_room_url
13
+ from meshagent.cli import async_typer
14
+ from meshagent.cli.helper import (
15
+ get_client,
16
+ resolve_project_id,
17
+ )
18
+ from meshagent.cli.helper import resolve_room
19
+
20
+ app = async_typer.AsyncTyper(help="Manage storage for a room")
21
+
22
+
23
+ def parse_path(path: str):
24
+ """
25
+ Parse a path and return a tuple (scheme, subpath).
26
+ scheme = "room" if it starts with "room://", otherwise "local".
27
+ subpath = the remainder of the path with no leading slashes.
28
+ """
29
+ prefix = "room://"
30
+ if path.startswith(prefix):
31
+ return ("room", path[len(prefix) :])
32
+ return ("local", path)
33
+
34
+
35
+ def split_glob_subpath(subpath: str):
36
+ """
37
+ Given something like "folder/*.txt" or "folder/subfolder/*.json",
38
+ return (dir_part, pattern_part).
39
+
40
+ If there's no wildcard, we return (subpath, None).
41
+ """
42
+ # If there is no '*', '?', or '[' in subpath, we assume no glob
43
+ # (simplistic check — if you want more robust detection, parse carefully).
44
+ if any(c in subpath for c in ["*", "?", "["]):
45
+ # We assume the pattern is the final portion after the last slash
46
+ # e.g. "folder/*.txt" => dir_part="folder", pattern_part="*.txt"
47
+ # If there's no slash, dir_part="" and pattern_part=subpath
48
+ base_dir = os.path.dirname(subpath)
49
+ pattern = os.path.basename(subpath)
50
+ return (base_dir, pattern)
51
+ else:
52
+ return (subpath, None)
53
+
54
+
55
+ @app.async_command("exists")
56
+ async def storage_exists_command(
57
+ *,
58
+ project_id: ProjectIdOption = None,
59
+ room: RoomOption,
60
+ path: str,
61
+ ):
62
+ """
63
+ Check if a file/folder exists in remote storage.
64
+ """
65
+ account_client = await get_client()
66
+ try:
67
+ project_id = await resolve_project_id(project_id=project_id)
68
+ room = resolve_room(room)
69
+ connection = await account_client.connect_room(project_id=project_id, room=room)
70
+
71
+ print("[bold green]Connecting to room...[/bold green]")
72
+ async with RoomClient(
73
+ protocol=WebSocketClientProtocol(
74
+ url=websocket_room_url(room_name=room, base_url=meshagent_base_url()),
75
+ token=connection.jwt,
76
+ )
77
+ ) as client:
78
+ file_exists = await client.storage.exists(path=path)
79
+ if file_exists:
80
+ print(f"[bold cyan]'{path}' exists in remote storage.[/bold cyan]")
81
+ else:
82
+ print(
83
+ f"[bold red]'{path}' does NOT exist in remote storage.[/bold red]"
84
+ )
85
+ finally:
86
+ await account_client.close()
87
+
88
+
89
+ @app.async_command("cp")
90
+ async def storage_cp_command(
91
+ *,
92
+ project_id: ProjectIdOption = None,
93
+ room: RoomOption,
94
+ source_path: str,
95
+ dest_path: str,
96
+ ):
97
+ room = resolve_room(room)
98
+
99
+ try:
100
+ """
101
+ Copy files between local and remote storage. Supports globs on the source side.
102
+ - cp room://folder/*.txt mylocaldir
103
+ - cp *.txt room://myfolder
104
+ - cp room://file.bin room://backup/file.bin
105
+ - etc.
106
+ """
107
+ # Determine what is remote or local for source/dest
108
+ src_scheme, src_subpath = parse_path(source_path)
109
+ dst_scheme, dst_subpath = parse_path(dest_path)
110
+
111
+ # This code will only connect to the room once if either side is remote
112
+ # (or both are remote).
113
+ account_client = None
114
+ client = None
115
+ storage_client: None | StorageClient = None
116
+
117
+ # A helper to ensure we have a connected StorageClient if needed
118
+ async def ensure_storage_client():
119
+ nonlocal account_client, client, storage_client, project_id
120
+
121
+ if storage_client is not None:
122
+ return # Already connected
123
+
124
+ account_client = await get_client()
125
+ project_id = await resolve_project_id(project_id=project_id)
126
+
127
+ connection = await account_client.connect_room(
128
+ project_id=project_id, room=room
129
+ )
130
+
131
+ print("[bold green]Connecting to room...[/bold green]")
132
+ client = RoomClient(
133
+ protocol=WebSocketClientProtocol(
134
+ url=websocket_room_url(
135
+ room_name=room, base_url=meshagent_base_url()
136
+ ),
137
+ token=connection.jwt,
138
+ )
139
+ )
140
+
141
+ await client.__aenter__() # Manually enter the async context
142
+ storage_client = client.storage
143
+
144
+ # We’ll gather the final (source->destination) file pairs to copy
145
+ copy_operations = []
146
+
147
+ # 1) Expand the source side if there's a glob
148
+ def local_list_files_with_glob(path_pattern: str):
149
+ return glob.glob(path_pattern)
150
+
151
+ async def remote_list_files_with_glob(sc: StorageClient, path_pattern: str):
152
+ base_dir, maybe_pattern = split_glob_subpath(path_pattern)
153
+ if maybe_pattern is None:
154
+ # Single file
155
+ # We'll just see if it exists
156
+ file_exists = await sc.exists(path=base_dir)
157
+ if not file_exists:
158
+ raise FileNotFoundError(
159
+ f"Remote file '{path_pattern}' does not exist."
160
+ )
161
+ return [(base_dir, os.path.basename(base_dir))] # (full, filename)
162
+ else:
163
+ # List base_dir, filter
164
+ entries = await sc.list(path=base_dir)
165
+ matched = [
166
+ (os.path.join(base_dir, e.name), e.name)
167
+ for e in entries
168
+ if not e.is_folder and fnmatch.fnmatch(e.name, maybe_pattern)
169
+ ]
170
+ return matched
171
+
172
+ # Expand the source files
173
+ if src_scheme == "local":
174
+ # local
175
+ if any(c in src_subpath for c in ["*", "?", "["]):
176
+ # Glob
177
+ local_matches = local_list_files_with_glob(src_subpath)
178
+ if not local_matches:
179
+ print(f"[bold red]No local files match '{src_subpath}'[/bold red]")
180
+ return
181
+ # We'll store (absolute_source_file, filename_only)
182
+ expanded_sources = [
183
+ (m, os.path.basename(m)) for m in local_matches if os.path.isfile(m)
184
+ ]
185
+ else:
186
+ # Single local file
187
+ if not os.path.isfile(src_subpath):
188
+ print(f"[bold red]Local file '{src_subpath}' not found.[/bold red]")
189
+ return
190
+ expanded_sources = [(src_subpath, os.path.basename(src_subpath))]
191
+ else:
192
+ # remote
193
+ await ensure_storage_client()
194
+ matches = await remote_list_files_with_glob(storage_client, src_subpath)
195
+ if not matches:
196
+ print(f"[bold red]No remote files match '{src_subpath}'[/bold red]")
197
+ return
198
+ expanded_sources = matches # list of (full_remote_path, filename)
199
+
200
+ # 2) Figure out if destination is a single file or a directory
201
+ # We'll handle multi-file -> directory or single-file -> single-file.
202
+ multiple_sources = len(expanded_sources) > 1
203
+
204
+ if dst_scheme == "local":
205
+ # If local destination is a directory or ends with a path separator,
206
+ # we treat it as a directory. If multiple files, it must be a directory.
207
+ if (
208
+ os.path.isdir(dst_subpath)
209
+ or dst_subpath.endswith(os.sep)
210
+ or dst_subpath == ""
211
+ ):
212
+ # directory
213
+ for full_src, fname in expanded_sources:
214
+ copy_operations.append((full_src, os.path.join(dst_subpath, fname)))
215
+ else:
216
+ # single file (or maybe it doesn't exist yet, but no slash)
217
+ if multiple_sources:
218
+ # Must be a directory, but user gave a file-like name => error
219
+ print(
220
+ f"[bold red]Destination '{dest_path}' is not a directory, but multiple files are being copied.[/bold red]"
221
+ )
222
+ return
223
+ # single file
224
+ copy_operations.append((expanded_sources[0][0], dst_subpath))
225
+ else:
226
+ # remote
227
+ # We need to see if there's a slash at the end or if it might be a directory
228
+ # There's no built-in "is_folder" check by default except listing. We'll do a naive approach:
229
+ # We'll see if the path exists as a folder by listing it. If that fails, assume it's a file path.
230
+ await ensure_storage_client()
231
+
232
+ # Let's attempt to list the `dst_subpath`. If it returns any result, it might exist as a folder.
233
+ # If it doesn't exist, we treat it as a file path. This can get tricky if your backend
234
+ # doesn't differentiate a folder from an empty folder. We'll keep it simple.
235
+ is_destination_folder = False
236
+ try:
237
+ await storage_client.list(path=dst_subpath)
238
+ # If listing worked, it might be a folder (unless it's a file with children?).
239
+ # We'll assume it’s a folder if we get any results or no error.
240
+ is_destination_folder = True
241
+ except Exception:
242
+ # Probably a file or does not exist
243
+ is_destination_folder = False
244
+
245
+ if is_destination_folder:
246
+ # it's a folder
247
+ for full_src, fname in expanded_sources:
248
+ # We'll store a path "dst_subpath/fname"
249
+ remote_dest_file = os.path.join(dst_subpath, fname)
250
+ copy_operations.append((full_src, remote_dest_file))
251
+ else:
252
+ # single file path
253
+ if multiple_sources:
254
+ print(
255
+ f"[bold red]Destination '{dest_path}' is not a folder, but multiple files are being copied.[/bold red]"
256
+ )
257
+ return
258
+ copy_operations.append((expanded_sources[0][0], dst_subpath))
259
+
260
+ # 3) Perform the copy
261
+ # copy_operations is a list of (source_file, dest_file).
262
+ # We need to handle four combos:
263
+ # a) local->local (unlikely in your scenario, but we handle it)
264
+ # b) local->remote
265
+ # c) remote->local
266
+ # d) remote->remote
267
+ for src_file, dst_file in copy_operations:
268
+ # Determine combo
269
+ if src_scheme == "local" and dst_scheme == "local":
270
+ # local->local
271
+ print(f"Copying local '{src_file}' -> local '{dst_file}'")
272
+ os.makedirs(os.path.dirname(dst_file), exist_ok=True)
273
+ with open(src_file, "rb") as fsrc, open(dst_file, "wb") as fdst:
274
+ fdst.write(fsrc.read())
275
+ print(
276
+ f"[bold cyan]Copied local '{src_file}' to '{dst_file}'[/bold cyan]"
277
+ )
278
+
279
+ elif src_scheme == "local" and dst_scheme == "room":
280
+ # local->remote
281
+ print(f"Copying local '{src_file}' -> remote '{dst_file}'")
282
+ with open(src_file, "rb") as fsrc:
283
+ data = fsrc.read()
284
+ # open, write, close
285
+ dest_handle = await storage_client.open(path=dst_file, overwrite=True)
286
+ await storage_client.write(handle=dest_handle, data=data)
287
+ await storage_client.close(handle=dest_handle)
288
+ print(
289
+ f"[bold cyan]Uploaded '{src_file}' to remote '{dst_file}'[/bold cyan]"
290
+ )
291
+
292
+ elif src_scheme == "room" and dst_scheme == "local":
293
+ # remote->local
294
+ print(f"Copying remote '{src_file}' -> local '{dst_file}'")
295
+ remote_file = await storage_client.download(path=src_file)
296
+ if os.path.dirname(dst_file) != "":
297
+ os.makedirs(os.path.dirname(dst_file), exist_ok=True)
298
+ with open(dst_file, "wb") as fdst:
299
+ fdst.write(remote_file.data)
300
+ print(
301
+ f"[bold cyan]Downloaded remote '{src_file}' to local '{dst_file}'[/bold cyan]"
302
+ )
303
+
304
+ else:
305
+ # remote->remote
306
+ print(f"Copying remote '{src_file}' -> remote '{dst_file}'")
307
+ source_file = await storage_client.download(path=src_file)
308
+ dest_handle = await storage_client.open(path=dst_file, overwrite=True)
309
+ await storage_client.write(handle=dest_handle, data=source_file.data)
310
+ await storage_client.close(handle=dest_handle)
311
+ print(
312
+ f"[bold cyan]Copied remote '{src_file}' to '{dst_file}'[/bold cyan]"
313
+ )
314
+ finally:
315
+ # Clean up
316
+ if client is not None:
317
+ await client.__aexit__(None, None, None)
318
+ if account_client is not None:
319
+ await account_client.close()
320
+
321
+
322
+ @app.async_command("show")
323
+ async def storage_show_command(
324
+ *,
325
+ project_id: ProjectIdOption = None,
326
+ room: RoomOption,
327
+ path: str,
328
+ encoding: Annotated[
329
+ str, typer.Option("--encoding", help="Text encoding")
330
+ ] = "utf-8",
331
+ ):
332
+ """
333
+ Print the contents of a file (local or remote) to the console.
334
+ """
335
+ room = resolve_room(room)
336
+
337
+ scheme, subpath = parse_path(path)
338
+
339
+ # If we need a remote connection, set it up:
340
+ account_client = None
341
+ client = None
342
+ storage_client = None
343
+
344
+ async def ensure_storage_client():
345
+ nonlocal account_client, client, storage_client, project_id
346
+
347
+ if storage_client is not None:
348
+ return
349
+
350
+ if not room:
351
+ raise typer.BadParameter("To show a remote file, you must provide --room")
352
+
353
+ account_client = await get_client()
354
+ project_id = await resolve_project_id(project_id=project_id)
355
+
356
+ connection = await account_client.connect_room(project_id=project_id, name=room)
357
+
358
+ print("[bold green]Connecting to room...[/bold green]")
359
+ client = RoomClient(
360
+ protocol=WebSocketClientProtocol(
361
+ url=websocket_room_url(room_name=room, base_url=meshagent_base_url()),
362
+ token=connection.jwt,
363
+ )
364
+ )
365
+
366
+ await client.__aenter__()
367
+ storage_client = client.storage
368
+
369
+ try:
370
+ if scheme == "local":
371
+ # Local file read
372
+ if not os.path.isfile(subpath):
373
+ print(f"[bold red]Local file '{subpath}' not found.[/bold red]")
374
+ raise typer.Exit(code=1)
375
+ with open(subpath, "rb") as f:
376
+ data = f.read()
377
+ text_content = data.decode(encoding, errors="replace")
378
+ print(text_content)
379
+ else:
380
+ # Remote file read
381
+ await ensure_storage_client()
382
+ if not await storage_client.exists(path=subpath):
383
+ print(f"[bold red]Remote file '{subpath}' not found.[/bold red]")
384
+ raise typer.Exit(code=1)
385
+ remote_file = await storage_client.download(path=subpath)
386
+ text_content = remote_file.data.decode(encoding, errors="replace")
387
+ print(text_content)
388
+ finally:
389
+ if client is not None:
390
+ await client.__aexit__(None, None, None)
391
+ if account_client is not None:
392
+ await account_client.close()
393
+
394
+
395
+ @app.async_command("rm")
396
+ async def storage_rm_command(
397
+ *,
398
+ project_id: ProjectIdOption = None,
399
+ room: RoomOption,
400
+ path: str,
401
+ recursive: Annotated[
402
+ bool, typer.Option("-r", help="Remove directories/folders recursively")
403
+ ] = False,
404
+ ):
405
+ """
406
+ Remove files (and optionally folders) either locally or in remote storage.
407
+ - Supports wildcards on the source path (for files).
408
+ - Fails if trying to remove a directory/folder without --recursive/-r.
409
+ """
410
+ room = resolve_room(room)
411
+
412
+ try:
413
+ # We'll mimic the "cp" approach, expanding wildcards for local/remote.
414
+
415
+ scheme, subpath = parse_path(path)
416
+
417
+ account_client = None
418
+ client = None
419
+ storage_client: Optional[StorageClient] = None
420
+
421
+ # Helper to ensure we have a storage client if we need remote operations
422
+ async def ensure_storage_client():
423
+ nonlocal account_client, client, storage_client, project_id
424
+
425
+ if storage_client is not None:
426
+ return # Already set up
427
+
428
+ if not room:
429
+ raise typer.BadParameter(
430
+ "To remove a remote file or folder, you must provide --room"
431
+ )
432
+
433
+ account_client = await get_client()
434
+ project_id = await resolve_project_id(project_id=project_id)
435
+
436
+ connection = await account_client.connect_room(
437
+ project_id=project_id, name=room
438
+ )
439
+
440
+ print("[bold green]Connecting to room...[/bold green]")
441
+ client = RoomClient(
442
+ protocol=WebSocketClientProtocol(
443
+ url=websocket_room_url(
444
+ room_name=room, base_url=meshagent_base_url()
445
+ ),
446
+ token=connection.jwt,
447
+ )
448
+ )
449
+
450
+ await client.__aenter__()
451
+ storage_client = client.storage
452
+
453
+ # --------------
454
+ # LOCAL HELPERS
455
+ # --------------
456
+ def local_list_files_with_glob(path_pattern: str):
457
+ return glob.glob(path_pattern)
458
+
459
+ def remove_local_path(local_path: str, recursive: bool):
460
+ """Remove a single local path (file or directory). Respects 'recursive' for directories."""
461
+ if not os.path.exists(local_path):
462
+ print(
463
+ f"[yellow]Local path '{local_path}' does not exist. Skipping.[/yellow]"
464
+ )
465
+ return
466
+
467
+ if os.path.isfile(local_path):
468
+ os.remove(local_path)
469
+ print(f"[bold cyan]Removed local file '{local_path}'[/bold cyan]")
470
+ elif os.path.isdir(local_path):
471
+ if not recursive:
472
+ print(
473
+ f"[bold red]Cannot remove directory '{local_path}' without -r.[/bold red]"
474
+ )
475
+ raise typer.Exit(code=1)
476
+ shutil.rmtree(local_path)
477
+ print(
478
+ f"[bold cyan]Removed local directory '{local_path}' recursively[/bold cyan]"
479
+ )
480
+ else:
481
+ # Neither file nor directory?
482
+ print(
483
+ f"[bold red]'{local_path}' is not a regular file or directory. Skipping.[/bold red]"
484
+ )
485
+
486
+ # ---------------
487
+ # REMOTE HELPERS
488
+ # ---------------
489
+ async def remote_list_files_with_glob(sc: StorageClient, path_pattern: str):
490
+ """
491
+ If there's a wildcard, returns only matching files (not folders),
492
+ consistent with 'cp' approach. If no wildcard, returns either
493
+ [(full_path, basename)] if it exists or empty list if not found.
494
+ """
495
+ base_dir, maybe_pattern = split_glob_subpath(path_pattern)
496
+ if maybe_pattern is None:
497
+ # Single path
498
+ file_exists = await sc.exists(path=base_dir)
499
+ if not file_exists:
500
+ return []
501
+ # We don't know if it's file or folder yet, but for a single path, we'll treat it as one item.
502
+ return [(base_dir, os.path.basename(base_dir))]
503
+ else:
504
+ # We have a pattern
505
+ entries = await sc.list(path=base_dir)
506
+ matched = [
507
+ (os.path.join(base_dir, e.name), e.name)
508
+ for e in entries
509
+ if not e.is_folder and fnmatch.fnmatch(e.name, maybe_pattern)
510
+ ]
511
+ return matched
512
+
513
+ async def is_remote_folder(sc: StorageClient, remote_path: str) -> bool:
514
+ """Return True if remote_path is a folder, otherwise False or it doesn't exist."""
515
+ stat = await sc.stat(path=remote_path)
516
+ if stat is None:
517
+ return False
518
+ else:
519
+ return stat.is_folder
520
+
521
+ async def remove_remote_path(sc: StorageClient, path: str, recursive: bool):
522
+ """
523
+ Removes a single remote path (file or folder). If it's a folder,
524
+ recursively remove if `recursive` is True, otherwise fail.
525
+ """
526
+ # Does it exist at all?
527
+ if not await sc.exists(path=path):
528
+ print(
529
+ f"[yellow]Remote path '{path}' does not exist. Skipping.[/yellow]"
530
+ )
531
+ return
532
+
533
+ # Check if it's a folder
534
+ if await is_remote_folder(sc, path):
535
+ # It's a folder
536
+ if not recursive:
537
+ print(
538
+ f"[bold red]Cannot remove remote directory '{path}' without -r.[/bold red]"
539
+ )
540
+ raise typer.Exit(code=1)
541
+
542
+ # Recursively remove contents
543
+ entries = await sc.list(path=path)
544
+ for e in entries:
545
+ child_path = os.path.join(path, e.name)
546
+ await remove_remote_path(sc, child_path, recursive)
547
+
548
+ # Finally remove the folder itself (assuming storage.delete can remove empty folders)
549
+ await sc.delete(path)
550
+ print(
551
+ f"[bold cyan]Removed remote directory '{path}' recursively[/bold cyan]"
552
+ )
553
+ else:
554
+ # It's a file
555
+ await sc.delete(path)
556
+ print(f"[bold cyan]Removed remote file '{path}'[/bold cyan]")
557
+
558
+ # ----------------------------------------------------------------
559
+ # 1) Expand the path if there's a wildcard
560
+ # ----------------------------------------------------------------
561
+ expanded_targets = []
562
+ if scheme == "local":
563
+ # Local expansions
564
+ if any(c in subpath for c in ["*", "?", "["]):
565
+ local_matches = local_list_files_with_glob(subpath)
566
+ if not local_matches:
567
+ print(f"[bold red]No local files match '{subpath}'[/bold red]")
568
+ raise typer.Exit(code=1)
569
+ # We'll store them (absolute_path, basename)
570
+ expanded_targets = [(m, os.path.basename(m)) for m in local_matches]
571
+ else:
572
+ # Single path
573
+ expanded_targets = [(subpath, os.path.basename(subpath))]
574
+ else:
575
+ # Remote expansions
576
+ await ensure_storage_client()
577
+ matches = await remote_list_files_with_glob(storage_client, subpath)
578
+ if not matches:
579
+ print(f"[bold red]No remote files/folders match '{subpath}'[/bold red]")
580
+ raise typer.Exit(code=1)
581
+ expanded_targets = matches
582
+
583
+ # ----------------------------------------------------------------
584
+ # 2) Perform the removal
585
+ # ----------------------------------------------------------------
586
+ for full_path, _ in expanded_targets:
587
+ if scheme == "local":
588
+ remove_local_path(full_path, recursive)
589
+ else:
590
+ await remove_remote_path(storage_client, full_path, recursive)
591
+ finally:
592
+ # Clean up remote client if used
593
+ if client is not None:
594
+ await client.__aexit__(None, None, None)
595
+ if account_client is not None:
596
+ await account_client.close()
597
+
598
+
599
+ @app.async_command("ls")
600
+ async def storage_ls_command(
601
+ *,
602
+ project_id: ProjectIdOption = None,
603
+ room: RoomOption,
604
+ path: Annotated[
605
+ str, typer.Argument(..., help="Path to list (local or room://...)")
606
+ ],
607
+ recursive: Annotated[
608
+ bool, typer.Option("-r", help="List subfolders/files recursively")
609
+ ] = False,
610
+ ):
611
+ """
612
+ List files/folders either locally or in remote storage.
613
+ - Supports wildcards on the path (e.g. *.txt).
614
+ - Use -r for recursive listing.
615
+ """
616
+
617
+ scheme, subpath = parse_path(path)
618
+
619
+ account_client = None
620
+ client = None
621
+ storage_client: Optional[StorageClient] = None
622
+
623
+ room = resolve_room(room)
624
+
625
+ # --- Set up remote connection if needed ---
626
+ async def ensure_storage_client():
627
+ nonlocal account_client, client, storage_client, project_id
628
+ if storage_client is not None:
629
+ return
630
+
631
+ if not room:
632
+ raise typer.BadParameter("To list a remote path, you must provide --room")
633
+
634
+ account_client = await get_client()
635
+ project_id = await resolve_project_id(project_id=project_id)
636
+ connection = await account_client.connect_room(project_id=project_id, room=room)
637
+
638
+ client = RoomClient(
639
+ protocol=WebSocketClientProtocol(
640
+ url=websocket_room_url(room_name=room, base_url=meshagent_base_url()),
641
+ token=connection.jwt,
642
+ )
643
+ )
644
+ await client.__aenter__()
645
+ storage_client = client.storage
646
+
647
+ # ----------------------------------------------------------------
648
+ # Local listing
649
+ # ----------------------------------------------------------------
650
+ def list_local_path(base_path: str, recursive: bool, prefix: str = ""):
651
+ """
652
+ List a local path. If it's a file, print it.
653
+ If it's a directory, list its contents.
654
+ If recursive=True, walk subdirectories as well.
655
+
656
+ prefix is used to indent or prefix lines if desired.
657
+ """
658
+
659
+ if not os.path.exists(base_path):
660
+ print(f"[red]{prefix}{base_path} does not exist[/red]")
661
+ return
662
+
663
+ if os.path.isfile(base_path):
664
+ print(f"{prefix}{os.path.basename(base_path)}")
665
+ return
666
+
667
+ # If it's a directory
668
+ # Print the directory name itself (if you want a heading)
669
+ print(f"{prefix}{os.path.basename(base_path)}/")
670
+
671
+ try:
672
+ with os.scandir(base_path) as it:
673
+ for entry in sorted(it, key=lambda x: x.name):
674
+ if entry.is_dir():
675
+ print(f"{prefix} {entry.name}/")
676
+ if recursive:
677
+ # Recursively list the folder
678
+ list_local_path(
679
+ os.path.join(base_path, entry.name),
680
+ True,
681
+ prefix + " ",
682
+ )
683
+ else:
684
+ print(f"{prefix} {entry.name}")
685
+ except PermissionError:
686
+ print(f"{prefix}[PermissionError] Cannot list {base_path}")
687
+
688
+ def glob_and_list_local(path_pattern: str, recursive: bool):
689
+ """
690
+ Glob for local paths. For each match:
691
+ - If it's a file, print the file.
692
+ - If it's a folder, list its contents.
693
+ """
694
+ matches = glob.glob(path_pattern)
695
+ if not matches:
696
+ print(f"[red]No local files/folders match '{path_pattern}'[/red]")
697
+ return
698
+
699
+ for m in matches:
700
+ list_local_path(m, recursive)
701
+
702
+ # ----------------------------------------------------------------
703
+ # Remote listing
704
+ # ----------------------------------------------------------------
705
+ async def is_remote_folder(sc: StorageClient, remote_path: str) -> bool:
706
+ """Return True if remote_path is a folder, otherwise False or it doesn't exist."""
707
+ stat = await sc.stat(path=remote_path)
708
+
709
+ if stat is None:
710
+ return False
711
+ else:
712
+ return stat.is_folder
713
+
714
+ async def list_remote_path(
715
+ sc: StorageClient, remote_path: str, recursive: bool, prefix: str = ""
716
+ ):
717
+ """
718
+ List a remote path. If it's a file, just print it.
719
+ If it's a folder, list its contents. If recursive=True, list subfolders too.
720
+ """
721
+
722
+ # Does it exist at all?
723
+ if not await sc.exists(path=remote_path):
724
+ print(f"{prefix}[red]{remote_path} does not exist (remote)[/red]")
725
+ return
726
+
727
+ if await is_remote_folder(sc, remote_path):
728
+ # It's a folder
729
+ folder_name = os.path.basename(remote_path.rstrip("/")) or remote_path
730
+ print(f"{prefix}{folder_name}/")
731
+ if recursive:
732
+ try:
733
+ entries = await sc.list(path=remote_path)
734
+ # Sort by name for consistent output
735
+ entries.sort(key=lambda e: e.name)
736
+ for e in entries:
737
+ child_path = os.path.join(remote_path, e.name)
738
+ if e.is_folder:
739
+ print(f"{prefix} {e.name}/")
740
+ if recursive:
741
+ await list_remote_path(
742
+ sc, child_path, recursive, prefix + " "
743
+ )
744
+ else:
745
+ print(f"{prefix} {e.name}")
746
+ except Exception as ex:
747
+ print(
748
+ f"{prefix}[red]Cannot list remote folder '{remote_path}': {ex}[/red]"
749
+ )
750
+ else:
751
+ # It's a file
752
+ print(f"{prefix}{os.path.basename(remote_path)}")
753
+
754
+ async def glob_and_list_remote(
755
+ sc: StorageClient, path_pattern: str, recursive: bool
756
+ ):
757
+ """
758
+ If there's a wildcard, list matching files/folders. If no wildcard, list the single path.
759
+ """
760
+ base_dir, maybe_pattern = split_glob_subpath(path_pattern)
761
+ if maybe_pattern is None:
762
+ # Single path
763
+ await list_remote_path(sc, base_dir, recursive)
764
+ else:
765
+ # Expand the directory
766
+ # For 'ls' we might want to list matching files and also matching folders if pattern matches?
767
+ # But your earlier approach for "cp" and "rm" only matched files in wildcard.
768
+ # Let's do a slightly broader approach: match both files and folders by listing base_dir.
769
+ if not await storage_client.exists(path=base_dir):
770
+ print(f"[red]Remote folder '{base_dir}' does not exist[/red]")
771
+ return
772
+ try:
773
+ entries = await sc.list(path=base_dir)
774
+ # Filter by pattern
775
+ matched = [e for e in entries if fnmatch.fnmatch(e.name, maybe_pattern)]
776
+ if not matched:
777
+ print(f"[red]No remote entries match '{path_pattern}'[/red]")
778
+ return
779
+
780
+ # For each match, build the full path and list it
781
+ for e in matched:
782
+ child_path = os.path.join(base_dir, e.name)
783
+ await list_remote_path(sc, child_path, recursive)
784
+ except Exception as ex:
785
+ print(f"[red]Error listing remote '{base_dir}': {ex}[/red]")
786
+
787
+ # ----------------------------------------------------------------
788
+ # Execute listing based on local/remote
789
+ # ----------------------------------------------------------------
790
+ try:
791
+ if scheme == "local":
792
+ if any(c in subpath for c in ["*", "?", "["]):
793
+ glob_and_list_local(subpath, recursive)
794
+ else:
795
+ # Single path (file or folder)
796
+ if not os.path.exists(subpath):
797
+ print(f"[red]Local path '{subpath}' does not exist[/red]")
798
+ raise typer.Exit(code=1)
799
+ list_local_path(subpath, recursive)
800
+ else:
801
+ await ensure_storage_client()
802
+ # wildcard or single path
803
+ if any(c in subpath for c in ["*", "?", "["]):
804
+ await glob_and_list_remote(storage_client, subpath, recursive)
805
+ else:
806
+ # Single remote path
807
+ await list_remote_path(storage_client, subpath, recursive)
808
+
809
+ finally:
810
+ if client is not None:
811
+ await client.__aexit__(None, None, None)
812
+ if account_client is not None:
813
+ await account_client.close()