htx-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
disk_cli/cli.py ADDED
@@ -0,0 +1,775 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import datetime as dt
5
+ import json
6
+ import os
7
+ import sys
8
+ import webbrowser
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Any
12
+ from urllib.parse import parse_qs, urlparse
13
+
14
+ from rich import box
15
+ from rich.console import Console
16
+ from rich.panel import Panel
17
+ from rich.progress import (
18
+ BarColumn,
19
+ DownloadColumn,
20
+ Progress,
21
+ SpinnerColumn,
22
+ TaskProgressColumn,
23
+ TextColumn,
24
+ TimeRemainingColumn,
25
+ TransferSpeedColumn,
26
+ )
27
+ from rich.table import Table
28
+ from rich.text import Text
29
+
30
+ from . import __version__
31
+ from .api import DiskAPIError, DiskClient
32
+ from .config import DEFAULT_STATE_PATH, DiskSettings
33
+
34
+ RTYPE_FILE = 0
35
+ RTYPE_FOLDER = 1
36
+ RTYPE_LINK = 2
37
+
38
+ STATUS_LABELS = {
39
+ 0: "public",
40
+ 1: "private",
41
+ 2: "protected",
42
+ }
43
+
44
+ TYPE_LABELS = {
45
+ RTYPE_FILE: "file",
46
+ RTYPE_FOLDER: "dir",
47
+ RTYPE_LINK: "link",
48
+ }
49
+
50
+ TYPE_STYLES = {
51
+ RTYPE_FILE: "white",
52
+ RTYPE_FOLDER: "bold cyan",
53
+ RTYPE_LINK: "bold magenta",
54
+ }
55
+
56
+ STATUS_STYLES = {
57
+ 0: "bold green",
58
+ 1: "bold yellow",
59
+ 2: "bold red",
60
+ }
61
+
62
+ console = Console()
63
+ error_console = Console(stderr=True)
64
+
65
+
66
+ class CLIUsageError(RuntimeError):
67
+ pass
68
+
69
+
70
+ @dataclass
71
+ class CommandContext:
72
+ settings: DiskSettings
73
+ client: DiskClient
74
+
75
+ def require_login(self) -> None:
76
+ if not self.settings.token:
77
+ raise CLIUsageError("Please run `htx login` first.")
78
+
79
+ def sync_user(self) -> dict[str, Any]:
80
+ self.require_login()
81
+ user = self.client.get_current_user()
82
+ self.settings.user = user
83
+ self.settings.save()
84
+ return user
85
+
86
+ def root_res_id(self) -> str:
87
+ user = self.settings.user or self.sync_user()
88
+ root_res = user.get("root_res")
89
+ if not root_res:
90
+ user = self.sync_user()
91
+ root_res = user.get("root_res")
92
+ if not root_res:
93
+ raise CLIUsageError("The current user does not have a root resource folder.")
94
+ return root_res
95
+
96
+
97
+ def build_parser() -> argparse.ArgumentParser:
98
+ parser = argparse.ArgumentParser(
99
+ prog="htx",
100
+ description="Command-line client for the Disk backend API.",
101
+ )
102
+ parser.add_argument(
103
+ "--config",
104
+ type=Path,
105
+ default=DEFAULT_STATE_PATH,
106
+ help=f"State file path (default: {DEFAULT_STATE_PATH})",
107
+ )
108
+ parser.add_argument("--version", action="version", version=f"htx-cli {__version__}")
109
+
110
+ subparsers = parser.add_subparsers(dest="command", required=True)
111
+
112
+ login = subparsers.add_parser("login", help="Authenticate via the SSO flow.")
113
+ login.add_argument("--code", help="SSO authorization code or callback URL.")
114
+ login.add_argument(
115
+ "--print-url",
116
+ action="store_true",
117
+ help="Only print the SSO authorization URL and exit.",
118
+ )
119
+ login.add_argument(
120
+ "--no-open-browser",
121
+ action="store_true",
122
+ help="Do not try to open the authorization URL in a browser.",
123
+ )
124
+
125
+ whoami = subparsers.add_parser("whoami", help="Show the authenticated Disk user.")
126
+ whoami.add_argument("--json", action="store_true", help="Print the raw API payload.")
127
+ subparsers.add_parser("logout", help="Clear the stored login token.")
128
+
129
+ ls_cmd = subparsers.add_parser("ls", help="List a remote Disk directory.")
130
+ ls_cmd.add_argument(
131
+ "remote_path",
132
+ nargs="?",
133
+ default="/",
134
+ help="Remote path, @<res_str_id>, or id:<res_str_id>.",
135
+ )
136
+ ls_cmd.add_argument("--json", action="store_true", help="Print the raw API payload.")
137
+
138
+ upload = subparsers.add_parser("upload", help="Upload a local file or directory.")
139
+ upload.add_argument("local_path", type=Path, help="Local file or directory to upload.")
140
+ upload.add_argument(
141
+ "remote_path",
142
+ nargs="?",
143
+ default="/",
144
+ help="Target remote directory path, @<res_str_id>, or id:<res_str_id>.",
145
+ )
146
+
147
+ mkdir = subparsers.add_parser("mkdir", help="Create a remote directory.")
148
+ mkdir.add_argument("name", help="Directory name to create.")
149
+ mkdir.add_argument(
150
+ "parent",
151
+ nargs="?",
152
+ default="/",
153
+ help="Parent remote directory path, @<res_str_id>, or id:<res_str_id>.",
154
+ )
155
+
156
+ rm = subparsers.add_parser("rm", help="Delete a remote file or directory.")
157
+ rm.add_argument("remote_path", help="Remote path, @<res_str_id>, or id:<res_str_id>.")
158
+ rm.add_argument(
159
+ "-r",
160
+ "--recursive",
161
+ action="store_true",
162
+ help="Recursively delete a directory and its contents.",
163
+ )
164
+
165
+ mv = subparsers.add_parser("mv", help="Move a remote resource into a different directory.")
166
+ mv.add_argument("source", help="Remote path, @<res_str_id>, or id:<res_str_id>.")
167
+ mv.add_argument("target_dir", help="Target directory path, @<res_str_id>, or id:<res_str_id>.")
168
+
169
+ rename = subparsers.add_parser("rename", help="Rename a remote resource.")
170
+ rename.add_argument("remote_path", help="Remote path, @<res_str_id>, or id:<res_str_id>.")
171
+ rename.add_argument("new_name", help="New name for the resource.")
172
+
173
+ download = subparsers.add_parser("download", help="Download a remote file, link target, or directory.")
174
+ download.add_argument("remote_path", help="Remote path, @<res_str_id>, or id:<res_str_id>.")
175
+ download.add_argument(
176
+ "local_path",
177
+ nargs="?",
178
+ type=Path,
179
+ help="Optional output file path or existing directory.",
180
+ )
181
+
182
+ link = subparsers.add_parser("link", help="Create a remote link resource.")
183
+ link.add_argument("name", help="Link resource name.")
184
+ link.add_argument("url", help="Link target URL.")
185
+ link.add_argument(
186
+ "parent",
187
+ nargs="?",
188
+ default="/",
189
+ help="Parent remote directory path, @<res_str_id>, or id:<res_str_id>.",
190
+ )
191
+
192
+ return parser
193
+
194
+
195
+ def extract_code(value: str) -> str:
196
+ candidate = value.strip()
197
+ if not candidate:
198
+ raise CLIUsageError("An authorization code is required.")
199
+ if "code=" not in candidate:
200
+ return candidate
201
+
202
+ parsed = urlparse(candidate)
203
+ query = parse_qs(parsed.query)
204
+ code = query.get("code", [None])[0]
205
+ if not code:
206
+ raise CLIUsageError("Could not find a `code` query parameter in the pasted URL.")
207
+ return code
208
+
209
+
210
+ def format_remote_path(remote_path: str) -> str:
211
+ if not remote_path or remote_path == "/":
212
+ return "/"
213
+ return "/" + remote_path.strip("/")
214
+
215
+
216
+ def format_resource_ref(res_str_id: str) -> str:
217
+ return f"@{res_str_id}"
218
+
219
+
220
+ def format_timestamp(timestamp: Any) -> str:
221
+ if timestamp in (None, ""):
222
+ return "-"
223
+ try:
224
+ return dt.datetime.fromtimestamp(float(timestamp)).strftime("%Y-%m-%d %H:%M")
225
+ except (TypeError, ValueError, OSError):
226
+ return "-"
227
+
228
+
229
+ def format_size(size: Any) -> str:
230
+ if size is None:
231
+ return "-"
232
+ try:
233
+ value = float(size)
234
+ except (TypeError, ValueError):
235
+ return "-"
236
+ if value < 0:
237
+ return "-"
238
+ units = ["B", "KB", "MB", "GB", "TB"]
239
+ index = 0
240
+ while value >= 1024 and index < len(units) - 1:
241
+ value /= 1024
242
+ index += 1
243
+ if index == 0:
244
+ return f"{int(value)} {units[index]}"
245
+ return f"{value:.1f} {units[index]}"
246
+
247
+
248
+ def type_text(rtype: int) -> Text:
249
+ return Text(TYPE_LABELS.get(rtype, "unknown"), style=TYPE_STYLES.get(rtype, "white"))
250
+
251
+
252
+ def status_text(status: int) -> Text:
253
+ return Text(
254
+ STATUS_LABELS.get(status, str(status)),
255
+ style=STATUS_STYLES.get(status, "white"),
256
+ )
257
+
258
+
259
+ def resource_name_text(resource: dict[str, Any]) -> Text:
260
+ text = Text()
261
+ text.append(format_resource_ref(resource["res_str_id"]), style="bold yellow")
262
+ text.append(" ")
263
+ text.append(str(resource["rname"]), style="white")
264
+ return text
265
+
266
+
267
+ def display_size(resource: dict[str, Any]) -> str:
268
+ if resource.get("rtype") != RTYPE_FILE:
269
+ return "-"
270
+ return format_size(resource.get("rsize"))
271
+
272
+
273
+ def progress_columns() -> list[Any]:
274
+ return [
275
+ SpinnerColumn(style="bold cyan"),
276
+ TextColumn("[progress.description]{task.description}"),
277
+ BarColumn(),
278
+ TaskProgressColumn(),
279
+ DownloadColumn(binary_units=True),
280
+ TransferSpeedColumn(),
281
+ TimeRemainingColumn(),
282
+ ]
283
+
284
+
285
+ def print_success(message: str) -> None:
286
+ console.print(f"[bold green]OK[/bold green] {message}")
287
+
288
+
289
+ def print_note(message: str) -> None:
290
+ console.print(f"[bold cyan]INFO[/bold cyan] {message}")
291
+
292
+
293
+ def print_warning(message: str) -> None:
294
+ error_console.print(f"[bold yellow]WARN[/bold yellow] {message}")
295
+
296
+
297
+ def sort_children(children: list[dict[str, Any]]) -> list[dict[str, Any]]:
298
+ return sorted(
299
+ children,
300
+ key=lambda item: (
301
+ 0 if item.get("rtype") == RTYPE_FOLDER else 1,
302
+ item.get("rname", "").lower(),
303
+ ),
304
+ )
305
+
306
+
307
+ def ensure_folder_resource(resource: dict[str, Any], *, label: str) -> None:
308
+ info = resource.get("info", resource)
309
+ if info.get("rtype") != RTYPE_FOLDER:
310
+ raise CLIUsageError(f"{label} must refer to a remote directory.")
311
+
312
+
313
+ def parse_resource_reference(reference: str) -> tuple[str | None, str]:
314
+ if reference.startswith("@"):
315
+ return reference[1:], "resource id"
316
+ if reference.startswith("id:"):
317
+ return reference[3:], "resource id"
318
+ return None, "path"
319
+
320
+
321
+ def resolve_remote_reference(ctx: CommandContext, reference: str) -> dict[str, Any]:
322
+ ctx.require_login()
323
+ resource_id, _ = parse_resource_reference(reference)
324
+ if resource_id:
325
+ return ctx.client.get_resource(resource_id)
326
+
327
+ current_id = ctx.root_res_id()
328
+ normalized = reference.strip("/")
329
+ if not normalized:
330
+ return ctx.client.get_resource(current_id)
331
+
332
+ current_resource = ctx.client.get_resource(current_id)
333
+ for segment in [part for part in normalized.split("/") if part]:
334
+ ensure_folder_resource(current_resource, label=format_remote_path(reference))
335
+ selector = ctx.client.get_selector(current_resource["info"]["res_str_id"])
336
+ child = next(
337
+ (item for item in selector.get("child_list", []) if item.get("rname") == segment),
338
+ None,
339
+ )
340
+ if child is None:
341
+ raise CLIUsageError(
342
+ f"Remote path segment `{segment}` does not exist under {format_remote_path(reference)}."
343
+ )
344
+ current_resource = ctx.client.get_resource(child["res_str_id"])
345
+ return current_resource
346
+
347
+
348
+ def find_child_folder(ctx: CommandContext, parent_id: str, folder_name: str) -> dict[str, Any] | None:
349
+ selector = ctx.client.get_selector(parent_id)
350
+ for child in selector.get("child_list", []):
351
+ if child.get("rname") == folder_name:
352
+ if child.get("rtype") != RTYPE_FOLDER:
353
+ raise CLIUsageError(
354
+ f"Remote item `{folder_name}` exists under the target path, but it is not a directory."
355
+ )
356
+ return child
357
+ return None
358
+
359
+
360
+ def find_child_resource(ctx: CommandContext, parent_id: str, resource_name: str) -> dict[str, Any] | None:
361
+ selector = ctx.client.get_selector(parent_id)
362
+ for child in selector.get("child_list", []):
363
+ if child.get("rname") == resource_name:
364
+ return child
365
+ return None
366
+
367
+
368
+ def ensure_remote_folder(ctx: CommandContext, parent_id: str, folder_name: str) -> str:
369
+ existing = find_child_folder(ctx, parent_id, folder_name)
370
+ if existing:
371
+ return existing["res_str_id"]
372
+ created = ctx.client.create_folder(parent_id, folder_name)
373
+ print_success(f"created remote folder {folder_name} ({format_resource_ref(created['res_str_id'])})")
374
+ return created["res_str_id"]
375
+
376
+
377
+ def upload_file_to_remote(ctx: CommandContext, local_path: Path, parent_id: str) -> dict[str, Any]:
378
+ with console.status(f"[bold cyan]Preparing upload for[/bold cyan] {local_path.name}", spinner="dots"):
379
+ upload_token = ctx.client.get_upload_token(parent_id, local_path.name)
380
+
381
+ total = local_path.stat().st_size
382
+ with Progress(*progress_columns(), console=console, transient=True) as progress:
383
+ task_id = progress.add_task(f"Uploading {local_path.name}", total=total)
384
+
385
+ def on_progress(transferred: int, callback_total: int | None) -> None:
386
+ progress.update(task_id, completed=transferred, total=callback_total or total)
387
+
388
+ uploaded = ctx.client.upload_file(upload_token, local_path, progress_callback=on_progress)
389
+ print_success(
390
+ f"uploaded {local_path.name} -> {uploaded['rname']} ({format_resource_ref(uploaded['res_str_id'])})"
391
+ )
392
+ return uploaded
393
+
394
+
395
+ def upload_directory_to_remote(ctx: CommandContext, local_dir: Path, parent_id: str) -> None:
396
+ root_remote_id = ensure_remote_folder(ctx, parent_id, local_dir.name)
397
+ remote_folder_map: dict[Path, str] = {Path("."): root_remote_id}
398
+
399
+ for current_root, dirnames, filenames in os.walk(local_dir):
400
+ dirnames.sort()
401
+ filenames.sort()
402
+ current_root_path = Path(current_root)
403
+ relative_root = current_root_path.relative_to(local_dir)
404
+ remote_parent_id = remote_folder_map[relative_root]
405
+
406
+ for dirname in dirnames:
407
+ child_relative = relative_root / dirname
408
+ remote_folder_map[child_relative] = ensure_remote_folder(ctx, remote_parent_id, dirname)
409
+
410
+ for filename in filenames:
411
+ upload_file_to_remote(ctx, current_root_path / filename, remote_parent_id)
412
+
413
+
414
+ def resolve_download_destination(resource: dict[str, Any], local_path: Path | None) -> Path:
415
+ resource_name = resource["info"]["rname"]
416
+ if local_path is None:
417
+ return Path.cwd() / resource_name
418
+
419
+ resolved = local_path.expanduser()
420
+ if resolved.exists() and resolved.is_dir():
421
+ return resolved / resource_name
422
+ return resolved
423
+
424
+
425
+ def download_file_to_local(ctx: CommandContext, resource: dict[str, Any], destination: Path) -> str:
426
+ with Progress(*progress_columns(), console=console, transient=True) as progress:
427
+ task_id = progress.add_task(f"Downloading {resource['info']['rname']}", total=None)
428
+
429
+ def on_progress(transferred: int, total: int | None) -> None:
430
+ progress.update(task_id, completed=transferred, total=total)
431
+
432
+ return ctx.client.download_resource(
433
+ resource["info"]["res_str_id"],
434
+ destination,
435
+ progress_callback=on_progress,
436
+ )
437
+
438
+
439
+ def download_directory_to_local(
440
+ ctx: CommandContext,
441
+ resource: dict[str, Any],
442
+ destination: Path,
443
+ ) -> tuple[int, int]:
444
+ destination.mkdir(parents=True, exist_ok=True)
445
+ file_count = 0
446
+ directory_count = 1
447
+
448
+ for child in sort_children(resource.get("child_list", [])):
449
+ child_resource = ctx.client.get_resource(child["res_str_id"])
450
+ child_info = child_resource["info"]
451
+ child_destination = destination / child_info["rname"]
452
+ if child_info["rtype"] == RTYPE_FOLDER:
453
+ child_files, child_dirs = download_directory_to_local(ctx, child_resource, child_destination)
454
+ file_count += child_files
455
+ directory_count += child_dirs
456
+ continue
457
+
458
+ final_url = download_file_to_local(ctx, child_resource, child_destination)
459
+ file_count += 1
460
+ print_note(
461
+ f"downloaded {child_info['rname']} ({format_resource_ref(child_info['res_str_id'])})"
462
+ f" -> {child_destination}"
463
+ )
464
+ print_note(f"source: {final_url}")
465
+
466
+ return file_count, directory_count
467
+
468
+
469
+ def delete_resource_recursive(ctx: CommandContext, resource: dict[str, Any]) -> None:
470
+ info = resource["info"]
471
+ if info["rtype"] == RTYPE_FOLDER:
472
+ for child in sort_children(resource.get("child_list", [])):
473
+ child_resource = ctx.client.get_resource(child["res_str_id"])
474
+ delete_resource_recursive(ctx, child_resource)
475
+ ctx.client.delete_resource(info["res_str_id"])
476
+ print_success(f"deleted {info['rname']} ({format_resource_ref(info['res_str_id'])})")
477
+
478
+
479
+ def print_resource_listing(resource: dict[str, Any]) -> None:
480
+ info = resource["info"]
481
+ child_list = sort_children(resource.get("child_list", []))
482
+
483
+ summary = Table.grid(padding=(0, 2))
484
+ summary.add_column(style="bold cyan", justify="right")
485
+ summary.add_column()
486
+ summary.add_row("Name", Text(str(info["rname"]), style="bold white"))
487
+ summary.add_row("Ref", Text(format_resource_ref(info["res_str_id"]), style="bold yellow"))
488
+ summary.add_row("Type", type_text(info["rtype"]))
489
+ summary.add_row("Status", status_text(info["status"]))
490
+ summary.add_row(
491
+ "Owner",
492
+ Text(str(info["owner"]["nickname"]), style="white"),
493
+ )
494
+ summary.add_row("Time", Text(format_timestamp(info.get("create_time")), style="white"))
495
+
496
+ if info["rtype"] != RTYPE_FOLDER:
497
+ console.print(Panel(summary, border_style="bright_black", box=box.ROUNDED))
498
+ return
499
+
500
+ summary.add_row("Items", Text(str(len(child_list)), style="white"))
501
+ console.print(Panel(summary, border_style="bright_black", box=box.ROUNDED))
502
+
503
+ if not child_list:
504
+ console.print("[dim](empty)[/dim]")
505
+ return
506
+
507
+ table = Table(
508
+ box=box.SIMPLE_HEAVY,
509
+ expand=True,
510
+ show_edge=False,
511
+ header_style="bold cyan",
512
+ pad_edge=False,
513
+ )
514
+ table.add_column("TYPE", no_wrap=True)
515
+ table.add_column("STATUS", no_wrap=True)
516
+ table.add_column("SIZE", justify="right", no_wrap=True)
517
+ table.add_column("CREATED", no_wrap=True)
518
+ table.add_column("RESOURCE", ratio=1, overflow="ellipsis", no_wrap=True)
519
+
520
+ for child in child_list:
521
+ table.add_row(
522
+ type_text(child["rtype"]),
523
+ status_text(child["status"]),
524
+ Text(display_size(child), style="white"),
525
+ Text(format_timestamp(child.get("create_time")), style="white"),
526
+ resource_name_text(child),
527
+ )
528
+
529
+ console.print(table)
530
+
531
+
532
+ def print_user_summary(user: dict[str, Any]) -> None:
533
+ summary = Table.grid(padding=(0, 2))
534
+ summary.add_column(style="bold cyan", justify="right")
535
+ summary.add_column()
536
+ summary.add_row("Nickname", Text(str(user.get("nickname", "-")), style="bold white"))
537
+ summary.add_row("Avatar", Text(str(user.get("avatar", "-")), style="white"))
538
+ root_res = user.get("root_res", "-")
539
+ summary.add_row(
540
+ "Root Ref",
541
+ Text(format_resource_ref(root_res) if root_res != "-" else root_res, style="bold yellow"),
542
+ )
543
+ console.print(Panel(summary, border_style="bright_black", box=box.ROUNDED))
544
+
545
+
546
+ def handle_login(ctx: CommandContext, args: argparse.Namespace) -> int:
547
+ auth_url = ctx.settings.oauth_authorize_url()
548
+ if args.print_url:
549
+ console.print(auth_url)
550
+ return 0
551
+
552
+ if not args.code:
553
+ console.print(
554
+ Panel(
555
+ Text(
556
+ "Open the SSO URL below, authorize the app, then paste the callback URL or code back here.\n\n"
557
+ f"{auth_url}",
558
+ style="white",
559
+ ),
560
+ title="SSO Login",
561
+ border_style="cyan",
562
+ box=box.ROUNDED,
563
+ )
564
+ )
565
+ if not args.no_open_browser:
566
+ webbrowser.open(auth_url)
567
+ args.code = input("callback URL or code: ").strip()
568
+
569
+ code = extract_code(args.code)
570
+ with console.status("[bold cyan]Exchanging authorization code[/bold cyan]", spinner="dots"):
571
+ oauth_body = ctx.client.exchange_code(code)
572
+ ctx.settings.user = oauth_body.get("user") or ctx.client.get_current_user()
573
+ ctx.settings.save()
574
+ user = ctx.settings.user or {}
575
+ print_success(
576
+ f"logged in as {user.get('nickname', 'unknown user')} (root: {format_resource_ref(user.get('root_res', 'n/a'))})"
577
+ )
578
+ return 0
579
+
580
+
581
+ def handle_whoami(ctx: CommandContext, _: argparse.Namespace) -> int:
582
+ with console.status("[bold cyan]Fetching user profile[/bold cyan]", spinner="dots"):
583
+ user = ctx.sync_user()
584
+ if _.json:
585
+ console.print_json(json.dumps(user, ensure_ascii=False, indent=2))
586
+ return 0
587
+ print_user_summary(user)
588
+ return 0
589
+
590
+
591
+ def handle_logout(ctx: CommandContext, _: argparse.Namespace) -> int:
592
+ ctx.settings.clear_auth()
593
+ ctx.settings.save()
594
+ print_success("stored credentials cleared")
595
+ return 0
596
+
597
+
598
+ def handle_ls(ctx: CommandContext, args: argparse.Namespace) -> int:
599
+ with console.status("[bold cyan]Loading resource listing[/bold cyan]", spinner="dots"):
600
+ resource = resolve_remote_reference(ctx, args.remote_path)
601
+ if args.json:
602
+ console.print_json(json.dumps(resource, ensure_ascii=False, indent=2))
603
+ return 0
604
+ print_resource_listing(resource)
605
+ return 0
606
+
607
+
608
+ def handle_upload(ctx: CommandContext, args: argparse.Namespace) -> int:
609
+ local_path = args.local_path.expanduser().resolve()
610
+ if not local_path.exists():
611
+ raise CLIUsageError(f"Local path does not exist: {local_path}")
612
+
613
+ with console.status("[bold cyan]Resolving target directory[/bold cyan]", spinner="dots"):
614
+ target_resource = resolve_remote_reference(ctx, args.remote_path)
615
+ ensure_folder_resource(target_resource, label=args.remote_path)
616
+ target_id = target_resource["info"]["res_str_id"]
617
+
618
+ if local_path.is_file():
619
+ upload_file_to_remote(ctx, local_path, target_id)
620
+ return 0
621
+
622
+ upload_directory_to_remote(ctx, local_path, target_id)
623
+ return 0
624
+
625
+
626
+ def handle_mkdir(ctx: CommandContext, args: argparse.Namespace) -> int:
627
+ with console.status("[bold cyan]Resolving parent directory[/bold cyan]", spinner="dots"):
628
+ target_resource = resolve_remote_reference(ctx, args.parent)
629
+ ensure_folder_resource(target_resource, label=args.parent)
630
+ target_id = target_resource["info"]["res_str_id"]
631
+
632
+ existing = find_child_resource(ctx, target_id, args.name)
633
+ if existing is not None:
634
+ raise CLIUsageError(f"`{args.name}` already exists under {args.parent}.")
635
+
636
+ with console.status(f"[bold cyan]Creating directory[/bold cyan] {args.name}", spinner="dots"):
637
+ created = ctx.client.create_folder(target_id, args.name)
638
+ print_success(f"created {created['rname']} ({format_resource_ref(created['res_str_id'])})")
639
+ return 0
640
+
641
+
642
+ def handle_rm(ctx: CommandContext, args: argparse.Namespace) -> int:
643
+ with console.status("[bold cyan]Resolving resource[/bold cyan]", spinner="dots"):
644
+ resource = resolve_remote_reference(ctx, args.remote_path)
645
+ info = resource["info"]
646
+ if info["rtype"] == RTYPE_FOLDER and resource.get("child_list") and not args.recursive:
647
+ raise CLIUsageError("Directory is not empty. Re-run with `htx rm --recursive`.")
648
+
649
+ if info["rtype"] == RTYPE_FOLDER and args.recursive:
650
+ delete_resource_recursive(ctx, resource)
651
+ return 0
652
+
653
+ ctx.client.delete_resource(info["res_str_id"])
654
+ print_success(f"deleted {info['rname']} ({format_resource_ref(info['res_str_id'])})")
655
+ return 0
656
+
657
+
658
+ def handle_mv(ctx: CommandContext, args: argparse.Namespace) -> int:
659
+ with console.status("[bold cyan]Resolving source and target[/bold cyan]", spinner="dots"):
660
+ source = resolve_remote_reference(ctx, args.source)
661
+ target_dir = resolve_remote_reference(ctx, args.target_dir)
662
+ ensure_folder_resource(target_dir, label=args.target_dir)
663
+
664
+ source_info = source["info"]
665
+ target_info = target_dir["info"]
666
+ if source_info["res_str_id"] == target_info["res_str_id"]:
667
+ raise CLIUsageError("Source and target directory cannot be the same resource.")
668
+
669
+ with console.status("[bold cyan]Moving resource[/bold cyan]", spinner="dots"):
670
+ ctx.client.update_resource(
671
+ source_info["res_str_id"],
672
+ parent_str_id=target_info["res_str_id"],
673
+ )
674
+ print_success(
675
+ f"moved {source_info['rname']} ({format_resource_ref(source_info['res_str_id'])})"
676
+ f" -> {target_info['rname']} ({format_resource_ref(target_info['res_str_id'])})"
677
+ )
678
+ return 0
679
+
680
+
681
+ def handle_rename(ctx: CommandContext, args: argparse.Namespace) -> int:
682
+ with console.status("[bold cyan]Resolving resource[/bold cyan]", spinner="dots"):
683
+ resource = resolve_remote_reference(ctx, args.remote_path)
684
+ info = resource["info"]
685
+ with console.status("[bold cyan]Renaming resource[/bold cyan]", spinner="dots"):
686
+ ctx.client.update_resource(info["res_str_id"], rname=args.new_name)
687
+ print_success(
688
+ f"renamed {info['rname']} ({format_resource_ref(info['res_str_id'])})"
689
+ f" -> {args.new_name}"
690
+ )
691
+ return 0
692
+
693
+
694
+ def handle_download(ctx: CommandContext, args: argparse.Namespace) -> int:
695
+ with console.status("[bold cyan]Resolving remote resource[/bold cyan]", spinner="dots"):
696
+ resource = resolve_remote_reference(ctx, args.remote_path)
697
+ destination = resolve_download_destination(resource, args.local_path)
698
+ if resource["info"]["rtype"] == RTYPE_FOLDER:
699
+ file_count, directory_count = download_directory_to_local(ctx, resource, destination)
700
+ print_success(
701
+ f"downloaded directory {resource['info']['rname']} ({format_resource_ref(resource['info']['res_str_id'])})"
702
+ f" -> {destination}"
703
+ )
704
+ print_note(f"entries: {file_count} files across {directory_count} directories")
705
+ return 0
706
+
707
+ final_url = download_file_to_local(ctx, resource, destination)
708
+ print_success(
709
+ f"downloaded {resource['info']['rname']} ({format_resource_ref(resource['info']['res_str_id'])})"
710
+ f" -> {destination}"
711
+ )
712
+ print_note(f"source: {final_url}")
713
+ return 0
714
+
715
+
716
+ def handle_link(ctx: CommandContext, args: argparse.Namespace) -> int:
717
+ with console.status("[bold cyan]Resolving parent directory[/bold cyan]", spinner="dots"):
718
+ target_resource = resolve_remote_reference(ctx, args.parent)
719
+ ensure_folder_resource(target_resource, label=args.parent)
720
+ target_id = target_resource["info"]["res_str_id"]
721
+
722
+ existing = find_child_resource(ctx, target_id, args.name)
723
+ if existing is not None:
724
+ raise CLIUsageError(f"`{args.name}` already exists under {args.parent}.")
725
+
726
+ with console.status(f"[bold cyan]Creating link[/bold cyan] {args.name}", spinner="dots"):
727
+ created = ctx.client.create_link(target_id, args.name, args.url)
728
+ print_success(
729
+ f"created link {created['rname']} ({format_resource_ref(created['res_str_id'])})"
730
+ f" -> {args.url}"
731
+ )
732
+ return 0
733
+
734
+
735
+ def dispatch(args: argparse.Namespace) -> int:
736
+ settings = DiskSettings.load(args.config)
737
+ client = DiskClient(settings)
738
+ ctx = CommandContext(settings=settings, client=client)
739
+
740
+ handlers = {
741
+ "login": handle_login,
742
+ "whoami": handle_whoami,
743
+ "logout": handle_logout,
744
+ "ls": handle_ls,
745
+ "upload": handle_upload,
746
+ "mkdir": handle_mkdir,
747
+ "rm": handle_rm,
748
+ "mv": handle_mv,
749
+ "rename": handle_rename,
750
+ "download": handle_download,
751
+ "link": handle_link,
752
+ }
753
+ return handlers[args.command](ctx, args)
754
+
755
+
756
+ def main(argv: list[str] | None = None) -> int:
757
+ parser = build_parser()
758
+ args = parser.parse_args(argv)
759
+ try:
760
+ return dispatch(args)
761
+ except CLIUsageError as exc:
762
+ error_console.print(f"[bold red]ERROR[/bold red] {exc}")
763
+ return 2
764
+ except DiskAPIError as exc:
765
+ error_console.print(f"[bold red]API[/bold red] {exc}")
766
+ if exc.debug_msg:
767
+ print_warning(f"debug: {exc.debug_msg}")
768
+ return 1
769
+ except KeyboardInterrupt:
770
+ error_console.print("[bold yellow]ABORTED[/bold yellow]")
771
+ return 130
772
+
773
+
774
+ if __name__ == "__main__":
775
+ raise SystemExit(main())