文件预览

main.py

查看 Weiyun Skills 技能包中的文件内容。

文件内容

weiyun_skills/main.py

"""Weiyun Skills CLI - command line interface for Weiyun management."""

import sys
import json
import argparse

from weiyun_skills.client import WeiyunClient
from weiyun_skills.utils import format_size

try:
    from tabulate import tabulate
except ImportError:
    tabulate = None


def _confirm(prompt: str, assume_yes: bool = False) -> bool:
    """Ask the user to confirm a destructive/irreversible action.

    Returns True only when the user explicitly types 'y' / 'yes' (case
    insensitive). If stdin is not a TTY (e.g. running inside an automated
    agent pipeline) the prompt is refused unless ``assume_yes`` is set via
    an explicit ``--yes`` flag, so an agent cannot silently approve
    destructive operations on the user's behalf.
    """
    if assume_yes:
        return True
    # If we cannot interactively prompt the user, fail closed.
    if not sys.stdin or not sys.stdin.isatty():
        print(
            "[!] Refusing to run a destructive action without an interactive "
            "confirmation. Re-run in a terminal, or pass --yes if you have "
            "manually verified the operation."
        )
        return False
    try:
        answer = input(f"{prompt} [y/N]: ").strip().lower()
    except EOFError:
        return False
    return answer in ("y", "yes")


def _print_json(data: dict) -> None:
    """Pretty print JSON data."""
    print(json.dumps(data, ensure_ascii=False, indent=2))


def _print_table(headers: list, rows: list) -> None:
    """Print data as a formatted table."""
    if tabulate:
        print(tabulate(rows, headers=headers, tablefmt="grid"))
    else:
        # Fallback: simple table output
        print("\t".join(headers))
        print("-" * (len(headers) * 20))
        for row in rows:
            print("\t".join(str(c) for c in row))


def cmd_list(client: WeiyunClient, args) -> None:
    """Handle 'list' command."""
    result = client.list_files(
        remote_path=args.path,
        sort_by=getattr(args, "sort", "name"),
        sort_order=getattr(args, "order", "asc"),
    )
    if not result["success"]:
        print(f"[ERROR] {result['message']}")
        return

    files = result["data"]["files"]
    if not files:
        print(f"(empty directory: {args.path})")
        return

    headers = ["Type", "Name", "Size", "Updated"]
    rows = []
    for f in files:
        icon = "📁" if f["type"] == "folder" else "📄"
        rows.append([icon, f["name"], f["size_str"], f["updated_at"]])
    _print_table(headers, rows)
    print(f"\nTotal: {result['data']['total']} items")


def cmd_upload(client: WeiyunClient, args) -> None:
    """Handle 'upload' command."""
    overwrite = getattr(args, "overwrite", False)
    assume_yes = getattr(args, "yes", False)

    # Uploading mutates the user's Weiyun account. When --overwrite is on,
    # an existing remote file will be REPLACED, which is effectively a
    # silent destructive action, so we surface that explicitly before
    # asking for confirmation.
    if overwrite:
        print(
            f"[!] --overwrite is enabled. If a file already exists at "
            f"{args.remote}, its contents will be REPLACED."
        )
    if not _confirm(
        f"Upload '{args.local}' to Weiyun at '{args.remote}'"
        f"{' (overwrite allowed)' if overwrite else ''}?",
        assume_yes=assume_yes,
    ):
        print("[-] Upload cancelled.")
        return

    print(f"[*] Uploading {args.local} -> {args.remote}")
    result = client.upload_file(
        args.local, args.remote,
        overwrite=overwrite,
    )
    if result["success"]:
        d = result["data"]
        print(f"[✓] Uploaded: {d['name']} ({format_size(d['size'])})")
        print(f"    Path: {d['remote_path']}")
        print(f"    MD5:  {d['md5']}")
    else:
        print(f"[✗] Upload failed: {result['message']}")


def cmd_upload_folder(client: WeiyunClient, args) -> None:
    """Handle 'upload-folder' command."""
    remote = getattr(args, "remote", "/")
    overwrite = getattr(args, "overwrite", False)
    assume_yes = getattr(args, "yes", False)

    # Bulk folder uploads can create and/or overwrite many files at once,
    # so we gate the whole operation behind the same confirmation prompt
    # the rest of the mutating commands use.
    if overwrite:
        print(
            f"[!] --overwrite is enabled. Existing files under "
            f"'{remote}' may be REPLACED in bulk."
        )
    if not _confirm(
        f"Upload folder '{args.local}' to Weiyun at '{remote}'"
        f"{' (overwrite allowed)' if overwrite else ''}?",
        assume_yes=assume_yes,
    ):
        print("[-] Upload cancelled.")
        return

    print(f"[*] Uploading folder '{args.local}' -> Weiyun:{remote}")
    result = client.upload_folder(
        args.local, remote,
        overwrite=overwrite,
    )
    if result["success"]:
        d = result["data"]
        print(f"[✓] Uploaded folder: {d['folder_name']}")
        print(f"    Files:  {d['uploaded_count']} uploaded, "
              f"{d['failed_count']} failed")
        print(f"    Size:   {d['total_size_str']}")
        print(f"    Time:   {d['elapsed']}s")
        if d["uploaded_files"]:
            print("\n    Uploaded files:")
            for f in d["uploaded_files"]:
                instant = " ⚡" if f.get("instant_upload") else ""
                print(f"      📄 {f['name']} ({f['size_str']}){instant}")
        if d["failed_files"]:
            print("\n    Failed files:")
            for f in d["failed_files"]:
                print(f"      ❌ {f['name']}: {f['error']}")
    else:
        print(f"[✗] Upload failed: {result['message']}")


def cmd_download(client: WeiyunClient, args) -> None:
    """Handle 'download' command."""
    print(f"[*] Downloading {args.remote} -> {args.local}")
    result = client.download_file(
        args.remote, args.local,
        overwrite=getattr(args, "overwrite", False)
    )
    if result["success"]:
        d = result["data"]
        print(f"[✓] Downloaded: {d['local_path']} ({format_size(d['size'])})")
        print(f"    MD5:  {d['md5']}")
        print(f"    Time: {d['elapsed']}s")
    else:
        print(f"[✗] Download failed: {result['message']}")


def cmd_download_folder(client: WeiyunClient, args) -> None:
    """Handle 'download-folder' command."""
    as_zip = getattr(args, "zip", False)
    mode = "zip" if as_zip else "recursive"
    print(f"[*] Downloading folder '{args.folder}' -> {args.local} (mode: {mode})")
    result = client.download_folder(
        args.folder, args.local,
        overwrite=getattr(args, "overwrite", False),
        as_zip=as_zip,
    )
    if result["success"]:
        d = result["data"]
        if as_zip:
            print(f"[✓] Downloaded zip: {d['local_path']}")
            print(f"    Size: {d.get('size_str', format_size(d.get('size', 0)))}")
            print(f"    MD5:  {d['md5']}")
            print(f"    Time: {d['elapsed']}s")
        else:
            print(f"[✓] Downloaded folder: {d['local_path']}")
            print(f"    Files:  {d['downloaded_count']} downloaded, "
                  f"{d['failed_count']} failed")
            print(f"    Size:   {d['total_size_str']}")
            print(f"    Time:   {d['elapsed']}s")
            if d["downloaded_files"]:
                print("\n    Downloaded files:")
                for f in d["downloaded_files"]:
                    print(f"      📄 {f['name']} ({f['size_str']})")
            if d["failed_files"]:
                print("\n    Failed files:")
                for f in d["failed_files"]:
                    print(f"      ❌ {f['name']}: {f['error']}")
    else:
        print(f"[✗] Download failed: {result['message']}")


def cmd_delete(client: WeiyunClient, args) -> None:
    """Handle 'delete' command."""
    permanent = getattr(args, "permanent", False)
    assume_yes = getattr(args, "yes", False)
    action = "Permanently deleting" if permanent else "Deleting"

    # Require explicit user confirmation before any remote deletion.
    # Permanent delete skips the recycle bin and is effectively irreversible,
    # so it uses a stricter prompt that spells out the consequences.
    if permanent:
        print(
            f"[!] PERMANENT DELETE requested for: {args.path}\n"
            f"    This will bypass the recycle bin and CANNOT be undone."
        )
        if not _confirm(
            f"Permanently delete '{args.path}'? This cannot be undone.",
            assume_yes=assume_yes,
        ):
            print("[-] Delete cancelled.")
            return
    else:
        if not _confirm(
            f"Move '{args.path}' to the Weiyun recycle bin?",
            assume_yes=assume_yes,
        ):
            print("[-] Delete cancelled.")
            return

    print(f"[*] {action} {args.path}")
    result = client.delete_file(args.path, permanent=permanent)
    if result["success"]:
        d = result["data"]
        msg = "permanently deleted" if d["is_permanent"] else "moved to recycle bin"
        print(f"[✓] {d['deleted_path']} {msg}")
    else:
        print(f"[✗] Delete failed: {result['message']}")


def cmd_move(client: WeiyunClient, args) -> None:
    """Handle 'move' command."""
    if not _confirm(
        f"Move '{args.source}' to '{args.target}' on Weiyun?",
        assume_yes=getattr(args, "yes", False),
    ):
        print("[-] Move cancelled.")
        return
    print(f"[*] Moving {args.source} -> {args.target}")
    result = client.move_file(args.source, args.target)
    if result["success"]:
        d = result["data"]
        print(f"[✓] Moved to: {d['target_path']}")
    else:
        print(f"[✗] Move failed: {result['message']}")


def cmd_copy(client: WeiyunClient, args) -> None:
    """Handle 'copy' command."""
    if not _confirm(
        f"Copy '{args.source}' to '{args.target}' on Weiyun?",
        assume_yes=getattr(args, "yes", False),
    ):
        print("[-] Copy cancelled.")
        return
    print(f"[*] Copying {args.source} -> {args.target}")
    result = client.copy_file(args.source, args.target)
    if result["success"]:
        d = result["data"]
        print(f"[✓] Copied to: {d['target_path']}")
    else:
        print(f"[✗] Copy failed: {result['message']}")


def cmd_rename(client: WeiyunClient, args) -> None:
    """Handle 'rename' command."""
    if not _confirm(
        f"Rename '{args.path}' to '{args.name}' on Weiyun?",
        assume_yes=getattr(args, "yes", False),
    ):
        print("[-] Rename cancelled.")
        return
    print(f"[*] Renaming {args.path} -> {args.name}")
    result = client.rename_file(args.path, args.name)
    if result["success"]:
        d = result["data"]
        print(f"[✓] Renamed: {d['old_path']} -> {d['new_path']}")
    else:
        print(f"[✗] Rename failed: {result['message']}")


def cmd_mkdir(client: WeiyunClient, args) -> None:
    """Handle 'mkdir' command."""
    # mkdir is a mutating operation (it changes the remote directory tree),
    # so it goes through the same confirmation gate as upload/delete/etc.
    if not _confirm(
        f"Create folder '{args.path}' on Weiyun?",
        assume_yes=getattr(args, "yes", False),
    ):
        print("[-] Create cancelled.")
        return
    print(f"[*] Creating folder: {args.path}")
    result = client.create_folder(args.path)
    if result["success"]:
        print(f"[✓] Created: {result['data']['path']}")
    else:
        print(f"[✗] Create failed: {result['message']}")


def cmd_search(client: WeiyunClient, args) -> None:
    """Handle 'search' command."""
    result = client.search_files(
        keyword=args.keyword,
        file_type=getattr(args, "type", "all"),
    )
    if not result["success"]:
        print(f"[ERROR] {result['message']}")
        return

    results = result["data"]["results"]
    if not results:
        print(f"No files found matching '{args.keyword}'")
        return

    headers = ["Name", "Size", "Path"]
    rows = [[r["name"], r["size_str"], r["path"]] for r in results]
    _print_table(headers, rows)
    print(f"\nFound: {result['data']['total']} items")


def cmd_share(client: WeiyunClient, args) -> None:
    """Handle 'share' command."""
    expire = getattr(args, "expire", 0)
    password = getattr(args, "password", None)
    assume_yes = getattr(args, "yes", False)

    # Creating a share link can expose private files to anyone on the
    # internet, so always surface the full parameters and require an
    # explicit confirmation before calling the Weiyun API.
    expire_desc = f"{expire} day(s)" if expire else "NEVER (permanent link)"
    pw_desc = "set" if password else "NONE (link is the only secret)"
    print("[!] About to create a public share link with these settings:")
    print(f"      File:       {args.path}")
    print(f"      Expires in: {expire_desc}")
    print(f"      Password:   {pw_desc}")
    if not password or not expire:
        print(
            "      WARNING: Sharing without both a password and an "
            "expiration makes the file reachable indefinitely by anyone "
            "holding the URL."
        )
    if not _confirm(
        f"Create share link for '{args.path}'?",
        assume_yes=assume_yes,
    ):
        print("[-] Share cancelled.")
        return

    print(f"[*] Creating share for: {args.path}")
    result = client.create_share(
        args.path,
        expire_days=expire,
        password=password,
    )
    if result["success"]:
        d = result["data"]
        print(f"[✓] Share created!")
        print(f"    URL:      {d['share_url']}")
        if d.get("password"):
            print(f"    Password: {d['password']}")
        if d.get("expire_at"):
            print(f"    Expires:  {d['expire_at']}")
    else:
        print(f"[✗] Share failed: {result['message']}")


def cmd_unshare(client: WeiyunClient, args) -> None:
    """Handle 'unshare' command."""
    if not _confirm(
        f"Cancel share '{args.share_id}'?",
        assume_yes=getattr(args, "yes", False),
    ):
        print("[-] Unshare cancelled.")
        return
    result = client.cancel_share(args.share_id)
    if result["success"]:
        print(f"[✓] Share {args.share_id} cancelled")
    else:
        print(f"[✗] Cancel failed: {result['message']}")


def cmd_shares(client: WeiyunClient, args) -> None:
    """Handle 'shares' command."""
    result = client.list_shares(
        status=getattr(args, "status", "all")
    )
    if not result["success"]:
        print(f"[ERROR] {result['message']}")
        return

    shares = result["data"]["shares"]
    if not shares:
        print("No shares found")
        return

    headers = ["ID", "File", "URL", "Status", "Views", "Downloads", "Expires"]
    rows = []
    for s in shares:
        rows.append([
            s["share_id"], s["file_name"], s["share_url"],
            s["status"], s["view_count"], s["download_count"],
            s["expire_at"],
        ])
    _print_table(headers, rows)
    print(f"\nTotal: {result['data']['total']} shares")


def cmd_space(client: WeiyunClient, args) -> None:
    """Handle 'space' command."""
    result = client.get_space_info()
    if not result["success"]:
        print(f"[ERROR] {result['message']}")
        return

    d = result["data"]
    print("=" * 40)
    print("  Weiyun Space Usage")
    print("=" * 40)
    print(f"  Total:   {d['total_space_str']}")
    print(f"  Used:    {d['used_space_str']} ({d['usage_percent']}%)")
    print(f"  Free:    {d['free_space_str']}")
    print(f"  Files:   {d['file_count']}")
    print(f"  Folders: {d['folder_count']}")
    print("=" * 40)

    # Simple progress bar
    bar_width = 30
    filled = int(bar_width * d["usage_percent"] / 100)
    bar = "█" * filled + "░" * (bar_width - filled)
    print(f"  [{bar}] {d['usage_percent']}%")


def cmd_recycle(client: WeiyunClient, args) -> None:
    """Handle 'recycle' command."""
    result = client.get_recycle_bin()
    if not result["success"]:
        print(f"[ERROR] {result['message']}")
        return

    files = result["data"]["files"]
    if not files:
        print("Recycle bin is empty")
        return

    headers = ["ID", "Name", "Size", "Original Path", "Deleted At"]
    rows = []
    for f in files:
        rows.append([
            f["file_id"], f["name"], f["size_str"],
            f["original_path"], f["deleted_at"],
        ])
    _print_table(headers, rows)
    print(f"\nTotal: {result['data']['total']} items "
          f"({result['data']['total_size_str']})")


def cmd_restore(client: WeiyunClient, args) -> None:
    """Handle 'restore' command."""
    result = client.restore_file(args.file_id)
    if result["success"]:
        d = result["data"]
        print(f"[✓] Restored to: {d['restored_path']}")
    else:
        print(f"[✗] Restore failed: {result['message']}")


def cmd_clear_recycle(client: WeiyunClient, args) -> None:
    """Handle 'clear-recycle' command."""
    assume_yes = getattr(args, "yes", False)
    if not getattr(args, "confirm", False) and not assume_yes:
        print("[!] This will permanently delete all files in recycle bin!")
        print("    Add --confirm flag to proceed.")
        return

    # Even with --confirm, prompt once more interactively (unless --yes).
    if not _confirm(
        "Permanently empty the Weiyun recycle bin? This cannot be undone.",
        assume_yes=assume_yes,
    ):
        print("[-] Clear cancelled.")
        return

    result = client.clear_recycle_bin(confirm=True)
    if result["success"]:
        d = result["data"]
        print(f"[✓] Recycle bin cleared!")
        print(f"    Deleted: {d['deleted_count']} files")
        print(f"    Freed:   {d['freed_space_str']}")
    else:
        print(f"[✗] Clear failed: {result['message']}")


def main():
    """Main CLI entry point."""
    parser = argparse.ArgumentParser(
        description="Weiyun Skills - Tencent Weiyun Management CLI",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  python -m weiyun_skills.main list /
  python -m weiyun_skills.main upload ./file.pdf /docs/
  python -m weiyun_skills.main download /docs/file.pdf ./
  python -m weiyun_skills.main share /docs/file.pdf --expire 7
  python -m weiyun_skills.main space
        """
    )

    parser.add_argument(
        "--cookies", type=str, default=None,
        help="Cookies string (overrides cookies.json)"
    )
    parser.add_argument(
        "-y", "--yes", action="store_true",
        help=(
            "Skip interactive confirmation prompts for destructive or "
            "exposure-sensitive actions (delete, permanent-delete, move, "
            "copy, rename, share, unshare, clear-recycle). Use with care."
        )
    )

    subparsers = parser.add_subparsers(dest="command", help="Available commands")

    # list
    p_list = subparsers.add_parser("list", help="List files in a directory")
    p_list.add_argument("path", nargs="?", default="/", help="Directory path")
    p_list.add_argument("--sort", choices=["name", "size", "time"], default="name")
    p_list.add_argument("--order", choices=["asc", "desc"], default="asc")

    # upload
    p_upload = subparsers.add_parser("upload", help="Upload a file")
    p_upload.add_argument("local", help="Local file path")
    p_upload.add_argument("remote", help="Remote target path")
    p_upload.add_argument("--overwrite", action="store_true")

    # upload-folder
    p_ul_folder = subparsers.add_parser("upload-folder",
                                         help="Upload a folder")
    p_ul_folder.add_argument("local", help="Local folder path")
    p_ul_folder.add_argument("remote", nargs="?", default="/",
                              help="Remote target path (default: root)")
    p_ul_folder.add_argument("--overwrite", action="store_true",
                              help="Overwrite existing files on Weiyun")

    # download
    p_download = subparsers.add_parser("download", help="Download a file")
    p_download.add_argument("remote", help="Remote file path")
    p_download.add_argument("local", help="Local save path")
    p_download.add_argument("--overwrite", action="store_true")

    # download-folder
    p_dl_folder = subparsers.add_parser("download-folder",
                                         help="Download a folder")
    p_dl_folder.add_argument("folder", help="Folder name on Weiyun")
    p_dl_folder.add_argument("local", help="Local save directory")
    p_dl_folder.add_argument("--overwrite", action="store_true",
                              help="Overwrite existing local files")
    p_dl_folder.add_argument("--zip", action="store_true",
                              help="Download as zip file instead of "
                                   "individual files")

    # delete
    p_delete = subparsers.add_parser("delete", help="Delete a file")
    p_delete.add_argument("path", help="File path to delete")
    p_delete.add_argument("--permanent", action="store_true",
                          help="Permanently delete (skip recycle bin)")

    # move
    p_move = subparsers.add_parser("move", help="Move a file")
    p_move.add_argument("source", help="Source path")
    p_move.add_argument("target", help="Target directory path")

    # copy
    p_copy = subparsers.add_parser("copy", help="Copy a file")
    p_copy.add_argument("source", help="Source path")
    p_copy.add_argument("target", help="Target directory path")

    # rename
    p_rename = subparsers.add_parser("rename", help="Rename a file")
    p_rename.add_argument("path", help="File path")
    p_rename.add_argument("name", help="New name")

    # mkdir
    p_mkdir = subparsers.add_parser("mkdir", help="Create a folder")
    p_mkdir.add_argument("path", help="Folder path")

    # search
    p_search = subparsers.add_parser("search", help="Search files")
    p_search.add_argument("keyword", help="Search keyword")
    p_search.add_argument("--type",
                          choices=["all", "document", "image", "video", "audio"],
                          default="all")

    # share
    p_share = subparsers.add_parser("share", help="Create a share link")
    p_share.add_argument("path", help="File path to share")
    p_share.add_argument("--expire", type=int, default=0,
                         help="Expire in days (0=permanent)")
    p_share.add_argument("--password", type=str, default=None,
                         help="Share password (4 chars)")

    # unshare
    p_unshare = subparsers.add_parser("unshare", help="Cancel a share")
    p_unshare.add_argument("share_id", help="Share ID to cancel")

    # shares
    p_shares = subparsers.add_parser("shares", help="List all shares")
    p_shares.add_argument("--status", choices=["all", "active", "expired"],
                          default="all")

    # space
    subparsers.add_parser("space", help="Show space usage")

    # recycle
    subparsers.add_parser("recycle", help="Show recycle bin")

    # restore
    p_restore = subparsers.add_parser("restore", help="Restore from recycle bin")
    p_restore.add_argument("file_id", help="File ID to restore")

    # clear-recycle
    p_clear = subparsers.add_parser("clear-recycle",
                                     help="Clear recycle bin")
    p_clear.add_argument("--confirm", action="store_true",
                         help="Confirm clear operation")

    args = parser.parse_args()

    if not args.command:
        parser.print_help()
        sys.exit(1)

    # Initialize client
    client = WeiyunClient(cookies_str=args.cookies)

    # Dispatch command
    commands = {
        "list": cmd_list,
        "upload": cmd_upload,
        "upload-folder": cmd_upload_folder,
        "download": cmd_download,
        "download-folder": cmd_download_folder,
        "delete": cmd_delete,
        "move": cmd_move,
        "copy": cmd_copy,
        "rename": cmd_rename,
        "mkdir": cmd_mkdir,
        "search": cmd_search,
        "share": cmd_share,
        "unshare": cmd_unshare,
        "shares": cmd_shares,
        "space": cmd_space,
        "recycle": cmd_recycle,
        "restore": cmd_restore,
        "clear-recycle": cmd_clear_recycle,
    }

    handler = commands.get(args.command)
    if handler:
        handler(client, args)
    else:
        parser.print_help()


if __name__ == "__main__":
    main()