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