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.
- meshagent/cli/__init__.py +3 -0
- meshagent/cli/agent.py +263 -0
- meshagent/cli/api_keys.py +102 -0
- meshagent/cli/async_typer.py +31 -0
- meshagent/cli/auth.py +30 -0
- meshagent/cli/auth_async.py +295 -0
- meshagent/cli/call.py +224 -0
- meshagent/cli/chatbot.py +311 -0
- meshagent/cli/cli.py +184 -0
- meshagent/cli/cli_mcp.py +344 -0
- meshagent/cli/cli_secrets.py +414 -0
- meshagent/cli/common_options.py +23 -0
- meshagent/cli/containers.py +577 -0
- meshagent/cli/developer.py +70 -0
- meshagent/cli/exec.py +381 -0
- meshagent/cli/helper.py +147 -0
- meshagent/cli/helpers.py +131 -0
- meshagent/cli/mailbot.py +260 -0
- meshagent/cli/meeting_transcriber.py +124 -0
- meshagent/cli/messaging.py +160 -0
- meshagent/cli/oauth2.py +189 -0
- meshagent/cli/participant_token.py +61 -0
- meshagent/cli/projects.py +105 -0
- meshagent/cli/queue.py +91 -0
- meshagent/cli/services.py +490 -0
- meshagent/cli/sessions.py +26 -0
- meshagent/cli/storage.py +813 -0
- meshagent/cli/version.py +1 -0
- meshagent/cli/voicebot.py +178 -0
- meshagent/cli/webhook.py +100 -0
- meshagent_cli-0.6.1.dist-info/METADATA +47 -0
- meshagent_cli-0.6.1.dist-info/RECORD +35 -0
- meshagent_cli-0.6.1.dist-info/WHEEL +5 -0
- meshagent_cli-0.6.1.dist-info/entry_points.txt +2 -0
- meshagent_cli-0.6.1.dist-info/top_level.txt +1 -0
meshagent/cli/storage.py
ADDED
|
@@ -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()
|