e3cli 0.3.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.
e3cli/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """E3CLI — NYCU E3 Moodle Automation CLI Tool"""
2
+
3
+ __version__ = "0.3.1"
e3cli/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running as `python -m e3cli`."""
2
+
3
+ from e3cli.cli import app
4
+
5
+ app()
e3cli/ai/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """AI 整合模組 (未來擴充)。"""
e3cli/api/__init__.py ADDED
File without changes
@@ -0,0 +1,38 @@
1
+ """作業相關 API。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from e3cli.api.client import MoodleClient
6
+
7
+
8
+ def get_assignments(client: MoodleClient, courseids: list[int]) -> dict:
9
+ """
10
+ 取得指定課程的所有作業。
11
+
12
+ 回傳: {"courses": [{"id": ..., "assignments": [{...}]}]}
13
+ """
14
+ params = {f"courseids[{i}]": cid for i, cid in enumerate(courseids)}
15
+ return client.call("mod_assign_get_assignments", **params)
16
+
17
+
18
+ def get_submission_status(client: MoodleClient, assignid: int) -> dict:
19
+ """取得某作業的提交狀態。"""
20
+ return client.call("mod_assign_get_submission_status", assignid=assignid)
21
+
22
+
23
+ def save_submission(client: MoodleClient, assignid: int, draft_itemid: int, text: str = "") -> dict:
24
+ """
25
+ 提交作業(檔案已上傳到 draft area 後)。
26
+
27
+ draft_itemid: upload_file 回傳的 itemid
28
+ text: 可選的線上文字內容
29
+ """
30
+ params = {
31
+ "assignmentid": assignid,
32
+ "plugindata[files_filemanager]": draft_itemid,
33
+ }
34
+ if text:
35
+ params["plugindata[onlinetext_editor][text]"] = text
36
+ params["plugindata[onlinetext_editor][format]"] = 1 # HTML
37
+ params["plugindata[onlinetext_editor][itemid]"] = draft_itemid
38
+ return client.call("mod_assign_save_submission", **params)
e3cli/api/client.py ADDED
@@ -0,0 +1,77 @@
1
+ """Moodle REST API 客戶端。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import requests
6
+
7
+
8
+ class MoodleAPIError(Exception):
9
+ """Moodle API 回傳錯誤。"""
10
+
11
+ def __init__(self, errorcode: str, message: str):
12
+ self.errorcode = errorcode
13
+ super().__init__(f"[{errorcode}] {message}")
14
+
15
+
16
+ class MoodleClient:
17
+ """封裝 Moodle Web Service REST API 呼叫。"""
18
+
19
+ def __init__(self, base_url: str, token: str):
20
+ self.base_url = base_url.rstrip("/")
21
+ self.token = token
22
+ self.session = requests.Session()
23
+ self.rest_endpoint = f"{self.base_url}/webservice/rest/server.php"
24
+ self.upload_endpoint = f"{self.base_url}/webservice/upload.php"
25
+
26
+ def call(self, wsfunction: str, **kwargs) -> dict | list:
27
+ """
28
+ 呼叫 Moodle Web Service 函式。
29
+
30
+ 所有參數以 POST form data 傳送,Moodle 使用 bracket notation
31
+ 處理巢狀參數(如 courseids[0]=123)。
32
+ """
33
+ params = {
34
+ "wstoken": self.token,
35
+ "wsfunction": wsfunction,
36
+ "moodlewsrestformat": "json",
37
+ **kwargs,
38
+ }
39
+ resp = self.session.post(self.rest_endpoint, data=params, timeout=30)
40
+ resp.raise_for_status()
41
+ data = resp.json()
42
+
43
+ # Moodle 錯誤格式: {"exception": ..., "errorcode": ..., "message": ...}
44
+ if isinstance(data, dict) and "exception" in data:
45
+ raise MoodleAPIError(data.get("errorcode", "unknown"), data.get("message", "未知錯誤"))
46
+
47
+ return data
48
+
49
+ def upload_file(self, filepath, itemid: int = 0) -> list[dict]:
50
+ """
51
+ 上傳檔案到使用者的 draft area。
52
+
53
+ 回傳包含 itemid, filename 等資訊的 list。
54
+ """
55
+ from pathlib import Path
56
+ filepath = Path(filepath)
57
+
58
+ with open(filepath, "rb") as f:
59
+ resp = self.session.post(
60
+ self.upload_endpoint,
61
+ params={"token": self.token},
62
+ data={"itemid": itemid, "filepath": "/"},
63
+ files={"file_1": (filepath.name, f)},
64
+ timeout=120,
65
+ )
66
+ resp.raise_for_status()
67
+ data = resp.json()
68
+
69
+ if isinstance(data, dict) and "exception" in data:
70
+ raise MoodleAPIError(data.get("errorcode", "unknown"), data.get("message", "上傳失敗"))
71
+
72
+ return data
73
+
74
+ def download_url(self, fileurl: str) -> str:
75
+ """為檔案 URL 附加 token 參數以供下載。"""
76
+ sep = "&" if "?" in fileurl else "?"
77
+ return f"{fileurl}{sep}token={self.token}"
e3cli/api/courses.py ADDED
@@ -0,0 +1,27 @@
1
+ """課程相關 API。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from e3cli.api.client import MoodleClient
6
+
7
+
8
+ def get_enrolled_courses(client: MoodleClient, userid: int) -> list[dict]:
9
+ """取得使用者已註冊的課程列表。"""
10
+ return client.call("core_enrol_get_users_courses", userid=userid)
11
+
12
+
13
+ def get_course_contents(client: MoodleClient, courseid: int) -> list[dict]:
14
+ """
15
+ 取得課程內容(章節 → 模組 → 檔案)。
16
+
17
+ 回傳結構:
18
+ [
19
+ {
20
+ "id": section_id, "name": "第一週", "modules": [
21
+ {"id": mod_id, "modname": "resource", "name": "...",
22
+ "contents": [{"filename": "...", "fileurl": "...", "filesize": ..., "timemodified": ...}]}
23
+ ]
24
+ }
25
+ ]
26
+ """
27
+ return client.call("core_course_get_contents", courseid=courseid)
e3cli/api/files.py ADDED
@@ -0,0 +1,25 @@
1
+ """檔案下載功能。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from e3cli.api.client import MoodleClient
8
+
9
+
10
+ def download_file(client: MoodleClient, fileurl: str, dest: Path) -> Path:
11
+ """
12
+ 從 Moodle 下載檔案到本地路徑。
13
+
14
+ 自動建立目標目錄,串流下載大檔案。
15
+ """
16
+ url = client.download_url(fileurl)
17
+ resp = client.session.get(url, stream=True, timeout=120)
18
+ resp.raise_for_status()
19
+
20
+ dest.parent.mkdir(parents=True, exist_ok=True)
21
+ with open(dest, "wb") as f:
22
+ for chunk in resp.iter_content(chunk_size=8192):
23
+ f.write(chunk)
24
+
25
+ return dest
e3cli/api/site.py ADDED
@@ -0,0 +1,10 @@
1
+ """站點資訊 API。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from e3cli.api.client import MoodleClient
6
+
7
+
8
+ def get_site_info(client: MoodleClient) -> dict:
9
+ """取得站點資訊,包含 userid、username、sitename 等。"""
10
+ return client.call("core_webservice_get_site_info")
e3cli/auth.py ADDED
@@ -0,0 +1,33 @@
1
+ """認證模組 — 透過 login/token.php 取得 Moodle Web Service token。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import requests
6
+
7
+
8
+ class AuthError(Exception):
9
+ """認證失敗。"""
10
+
11
+
12
+ def get_token(base_url: str, username: str, password: str, service: str = "moodle_mobile_app") -> str:
13
+ """
14
+ 向 Moodle 取得 Web Service token。
15
+
16
+ POST {base_url}/login/token.php
17
+ 成功回傳 token 字串,失敗拋出 AuthError。
18
+ """
19
+ url = f"{base_url.rstrip('/')}/login/token.php"
20
+ resp = requests.post(url, data={
21
+ "username": username,
22
+ "password": password,
23
+ "service": service,
24
+ }, timeout=30)
25
+ resp.raise_for_status()
26
+ data = resp.json()
27
+
28
+ if "token" in data:
29
+ return data["token"]
30
+
31
+ error_msg = data.get("error", "未知錯誤")
32
+ error_code = data.get("errorcode", "")
33
+ raise AuthError(f"登入失敗 [{error_code}]: {error_msg}")
e3cli/cli.py ADDED
@@ -0,0 +1,61 @@
1
+ """E3CLI 主入口 — 組裝所有子指令。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ import typer
8
+
9
+ from e3cli import __version__
10
+ from e3cli.commands.assignments import app as assignments_app
11
+ from e3cli.commands.courses import app as courses_app
12
+ from e3cli.commands.download import app as download_app
13
+ from e3cli.commands.login import app as login_app
14
+ from e3cli.commands.logout import app as logout_app
15
+ from e3cli.commands.schedule import app as schedule_app
16
+ from e3cli.commands.setup import app as setup_app
17
+ from e3cli.commands.setup import is_first_run, run_setup_wizard
18
+ from e3cli.commands.submit import app as submit_app
19
+ from e3cli.commands.sync import app as sync_app
20
+ from e3cli.i18n import t
21
+
22
+ app = typer.Typer(
23
+ name="e3cli",
24
+ help=t("cli.help"),
25
+ no_args_is_help=True,
26
+ )
27
+
28
+ app.add_typer(login_app, name="login", help=t("cli.login"))
29
+ app.add_typer(logout_app, name="logout", help=t("cli.logout"))
30
+ app.add_typer(courses_app, name="courses", help=t("cli.courses"))
31
+ app.add_typer(assignments_app, name="assignments", help=t("cli.assignments"))
32
+ app.add_typer(download_app, name="download", help=t("cli.download"))
33
+ app.add_typer(submit_app, name="submit", help=t("cli.submit"))
34
+ app.add_typer(sync_app, name="sync", help=t("cli.sync"))
35
+ app.add_typer(schedule_app, name="schedule", help=t("cli.schedule"))
36
+ app.add_typer(setup_app, name="setup", help=t("cli.setup"))
37
+
38
+
39
+ @app.command()
40
+ def version():
41
+ """Show version."""
42
+ typer.echo(f"e3cli {__version__}")
43
+
44
+
45
+ _original_app_call = app.__call__
46
+
47
+
48
+ def _app_with_first_run(*args, **kwargs):
49
+ skip_keywords = {"--help", "-h", "version", "setup", "--show-completion", "--install-completion"}
50
+ if (
51
+ is_first_run()
52
+ and sys.stdin.isatty()
53
+ and not any(arg in skip_keywords for arg in sys.argv[1:])
54
+ ):
55
+ run_setup_wizard()
56
+ if len(sys.argv) <= 1:
57
+ return
58
+ return _original_app_call(*args, **kwargs)
59
+
60
+
61
+ app.__call__ = _app_with_first_run
File without changes
@@ -0,0 +1,29 @@
1
+ """Commands 共用工具。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from rich.console import Console
7
+
8
+ from e3cli.api.client import MoodleClient
9
+ from e3cli.config import load_config, load_token
10
+ from e3cli.i18n import t
11
+ from e3cli.storage.db import Database
12
+
13
+ console = Console()
14
+
15
+
16
+ def get_client() -> MoodleClient:
17
+ """取得已認證的 MoodleClient,token 不存在則提示登入。"""
18
+ cfg = load_config()
19
+ token = load_token()
20
+ if not token:
21
+ console.print(f"[red]{t('common.not_logged_in')}[/red]")
22
+ raise typer.Exit(1)
23
+ return MoodleClient(cfg.moodle.url, token)
24
+
25
+
26
+ def get_db() -> Database:
27
+ """取得 SQLite Database 實例。"""
28
+ cfg = load_config()
29
+ return Database(cfg.storage.db_path)
@@ -0,0 +1,91 @@
1
+ """e3cli assignments"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from datetime import datetime
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+
12
+ from e3cli.api.assignments import get_assignments
13
+ from e3cli.api.courses import get_enrolled_courses
14
+ from e3cli.api.site import get_site_info
15
+ from e3cli.commands._common import get_client, get_db
16
+ from e3cli.i18n import t
17
+
18
+ console = Console()
19
+ app = typer.Typer()
20
+
21
+
22
+ def _format_duedate(ts: int) -> str:
23
+ if ts == 0:
24
+ return t("assign.no_deadline")
25
+ dt = datetime.fromtimestamp(ts)
26
+ remaining = ts - int(time.time())
27
+ days = remaining // 86400
28
+ if remaining < 0:
29
+ return f"[red]{dt:%Y-%m-%d %H:%M} ({t('assign.expired')})[/red]"
30
+ if days <= 3:
31
+ return f"[red]{dt:%Y-%m-%d %H:%M} ({t('assign.days_left', n=days)})[/red]"
32
+ if days <= 7:
33
+ return f"[yellow]{dt:%Y-%m-%d %H:%M} ({t('assign.days_left', n=days)})[/yellow]"
34
+ return f"{dt:%Y-%m-%d %H:%M} ({t('assign.days_left', n=days)})"
35
+
36
+
37
+ @app.callback(invoke_without_command=True)
38
+ def assignments(
39
+ due_soon: int = typer.Option(None, "--due-soon", help=t("assign.opt_due_soon")),
40
+ ):
41
+ """List assignments and deadlines."""
42
+ client = get_client()
43
+ db = get_db()
44
+
45
+ info = get_site_info(client)
46
+ course_list = get_enrolled_courses(client, info["userid"])
47
+ courseids = [c["id"] for c in course_list]
48
+ course_names = {c["id"]: c.get("shortname", "") for c in course_list}
49
+
50
+ if not courseids:
51
+ console.print(f"[yellow]{t('common.no_courses')}[/yellow]")
52
+ raise typer.Exit()
53
+
54
+ data = get_assignments(client, courseids)
55
+ now = int(time.time())
56
+
57
+ table = Table(title=t("assign.title"))
58
+ table.add_column(t("courses.col_id"), style="dim")
59
+ table.add_column(t("assign.col_course"), style="cyan")
60
+ table.add_column(t("assign.col_name"), style="bold")
61
+ table.add_column(t("assign.col_due"))
62
+ table.add_column(t("assign.col_status"))
63
+
64
+ count = 0
65
+ for course in data.get("courses", []):
66
+ cid = course["id"]
67
+ cname = course_names.get(cid, "")
68
+ for a in course.get("assignments", []):
69
+ duedate = a.get("duedate", 0)
70
+
71
+ if due_soon is not None:
72
+ if duedate == 0 or duedate - now > due_soon * 86400 or duedate < now:
73
+ continue
74
+
75
+ db.upsert_assignment(a["id"], cid, cname, a["name"], duedate, now)
76
+
77
+ table.add_row(
78
+ str(a["id"]),
79
+ cname,
80
+ a["name"],
81
+ _format_duedate(duedate),
82
+ a.get("submissionstatus", "new") if "submissionstatus" in a else "—",
83
+ )
84
+ count += 1
85
+
86
+ if count == 0:
87
+ console.print(f"[green]{t('assign.empty')}[/green]")
88
+ else:
89
+ console.print(table)
90
+
91
+ db.close()
@@ -0,0 +1,39 @@
1
+ """e3cli courses"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+
9
+ from e3cli.api.courses import get_enrolled_courses
10
+ from e3cli.api.site import get_site_info
11
+ from e3cli.commands._common import get_client
12
+ from e3cli.i18n import t
13
+
14
+ console = Console()
15
+ app = typer.Typer()
16
+
17
+
18
+ @app.callback(invoke_without_command=True)
19
+ def courses():
20
+ """List all enrolled courses."""
21
+ client = get_client()
22
+ info = get_site_info(client)
23
+ userid = info["userid"]
24
+
25
+ course_list = get_enrolled_courses(client, userid)
26
+
27
+ if not course_list:
28
+ console.print(f"[yellow]{t('courses.empty')}[/yellow]")
29
+ raise typer.Exit()
30
+
31
+ table = Table(title=f"{t('courses.title')} ({info['fullname']})")
32
+ table.add_column(t("courses.col_id"), style="dim")
33
+ table.add_column(t("courses.col_code"), style="cyan")
34
+ table.add_column(t("courses.col_name"), style="bold")
35
+
36
+ for c in course_list:
37
+ table.add_row(str(c["id"]), c.get("shortname", ""), c.get("fullname", ""))
38
+
39
+ console.print(table)
@@ -0,0 +1,105 @@
1
+ """e3cli download"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import time
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.progress import Progress
11
+
12
+ from e3cli.api.courses import get_course_contents, get_enrolled_courses
13
+ from e3cli.api.files import download_file
14
+ from e3cli.api.site import get_site_info
15
+ from e3cli.commands._common import get_client, get_db
16
+ from e3cli.config import load_config
17
+ from e3cli.i18n import t
18
+
19
+ console = Console()
20
+ app = typer.Typer()
21
+
22
+
23
+ def _sanitize(name: str) -> str:
24
+ return re.sub(r'[<>:"/\\|?*]', "_", name).strip().rstrip(".")
25
+
26
+
27
+ @app.callback(invoke_without_command=True)
28
+ def download(
29
+ course: str = typer.Option(None, "--course", "-c", help=t("dl.opt_course")),
30
+ all_courses: bool = typer.Option(False, "--all", "-a", help=t("dl.opt_all")),
31
+ ):
32
+ """Download course materials."""
33
+ if not course and not all_courses:
34
+ console.print(f"[yellow]{t('dl.need_flag')}[/yellow]")
35
+ raise typer.Exit(1)
36
+
37
+ client = get_client()
38
+ db = get_db()
39
+ cfg = load_config()
40
+ download_dir = cfg.storage.download_dir
41
+
42
+ info = get_site_info(client)
43
+ course_list = get_enrolled_courses(client, info["userid"])
44
+
45
+ if course:
46
+ course_lower = course.lower()
47
+ course_list = [
48
+ c for c in course_list
49
+ if course_lower in c.get("shortname", "").lower()
50
+ or course_lower in c.get("fullname", "").lower()
51
+ ]
52
+ if not course_list:
53
+ console.print(f"[red]{t('dl.no_match', q=course)}[/red]")
54
+ raise typer.Exit(1)
55
+
56
+ total_new = 0
57
+ total_skipped = 0
58
+
59
+ for c in course_list:
60
+ cid = c["id"]
61
+ cname = _sanitize(c.get("shortname", str(cid)))
62
+ db.upsert_course(cid, c.get("shortname", ""), c.get("fullname", ""))
63
+
64
+ console.print(f"\n[bold cyan]{c.get('fullname', cname)}[/bold cyan]")
65
+
66
+ contents = get_course_contents(client, cid)
67
+ files_to_download = []
68
+
69
+ for section in contents:
70
+ section_name = _sanitize(section.get("name", "unnamed"))
71
+ for module in section.get("modules", []):
72
+ mid = module.get("id", 0)
73
+ for file_info in module.get("contents", []):
74
+ fname = file_info.get("filename", "")
75
+ furl = file_info.get("fileurl", "")
76
+ fsize = file_info.get("filesize", 0)
77
+ ftime = file_info.get("timemodified", 0)
78
+
79
+ if not fname or not furl:
80
+ continue
81
+
82
+ if db.is_downloaded(cid, mid, fname, ftime):
83
+ total_skipped += 1
84
+ continue
85
+
86
+ dest = download_dir / cname / section_name / fname
87
+ files_to_download.append((cid, mid, fname, furl, fsize, ftime, dest))
88
+
89
+ if not files_to_download:
90
+ console.print(f" [dim]{t('dl.no_new')}[/dim]")
91
+ continue
92
+
93
+ with Progress(console=console) as progress:
94
+ task = progress.add_task(f" {t('dl.progress')}", total=len(files_to_download))
95
+ for cid, mid, fname, furl, fsize, ftime, dest in files_to_download:
96
+ download_file(client, furl, dest)
97
+ db.record_download(
98
+ cid, mid, fname, furl, fsize, ftime,
99
+ str(dest), int(time.time()),
100
+ )
101
+ total_new += 1
102
+ progress.advance(task)
103
+
104
+ console.print(f"\n[green]{t('dl.done', new=total_new, skip=total_skipped)}[/green]")
105
+ db.close()
@@ -0,0 +1,68 @@
1
+ """e3cli login"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import getpass
6
+
7
+ import typer
8
+ from rich.console import Console
9
+
10
+ from e3cli.auth import AuthError, get_token
11
+ from e3cli.config import load_config, save_token
12
+ from e3cli.credential import has_credentials, load_credentials, save_credentials
13
+ from e3cli.i18n import t
14
+
15
+ console = Console()
16
+ app = typer.Typer()
17
+
18
+
19
+ @app.callback(invoke_without_command=True)
20
+ def login(
21
+ username: str = typer.Option(None, "--username", "-u", help=t("login.opt_username")),
22
+ save_password: bool = typer.Option(False, "--save", "-s", help=t("login.opt_save")),
23
+ refresh: bool = typer.Option(False, "--refresh", "-r", help=t("login.opt_refresh")),
24
+ ):
25
+ """Login to NYCU E3 Moodle and save token."""
26
+ cfg = load_config()
27
+
28
+ if refresh:
29
+ creds = load_credentials()
30
+ if not creds:
31
+ console.print(f"[red]{t('login.no_saved')}[/red]")
32
+ raise typer.Exit(1)
33
+ username, password = creds
34
+ console.print(f"[dim]{t('login.refreshing', user=username)}[/dim]")
35
+ else:
36
+ if not username and has_credentials():
37
+ creds = load_credentials()
38
+ if creds:
39
+ use_saved = typer.confirm(t("login.use_saved", user=creds[0]), default=True)
40
+ if use_saved:
41
+ username, password = creds
42
+ else:
43
+ username = typer.prompt(t("login.prompt_user"))
44
+ password = getpass.getpass(t("login.prompt_pass"))
45
+ else:
46
+ username = typer.prompt(t("login.prompt_user"))
47
+ password = getpass.getpass(t("login.prompt_pass"))
48
+ else:
49
+ if not username:
50
+ username = typer.prompt(t("login.prompt_user"))
51
+ password = getpass.getpass(t("login.prompt_pass"))
52
+
53
+ console.print(f"[dim]{t('login.connecting', url=cfg.moodle.url)}[/dim]")
54
+
55
+ try:
56
+ token = get_token(cfg.moodle.url, username, password, cfg.moodle.service)
57
+ except AuthError as e:
58
+ console.print(f"[red]✗ {e}[/red]")
59
+ raise typer.Exit(1)
60
+
61
+ save_token(token)
62
+
63
+ if save_password or refresh:
64
+ save_credentials(username, password)
65
+ console.print(f"[green]{t('login.success_saved')}[/green]")
66
+ else:
67
+ console.print(f"[green]{t('login.success')}[/green]")
68
+ console.print(f"[dim]{t('login.hint_save')}[/dim]")
@@ -0,0 +1,19 @@
1
+ """e3cli logout"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from rich.console import Console
7
+
8
+ from e3cli.credential import clear_credentials
9
+ from e3cli.i18n import t
10
+
11
+ console = Console()
12
+ app = typer.Typer()
13
+
14
+
15
+ @app.callback(invoke_without_command=True)
16
+ def logout():
17
+ """Clear all stored credentials and tokens."""
18
+ clear_credentials()
19
+ console.print(f"[green]{t('logout.done')}[/green]")