uqadm 2026.22.0.dev0__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.
uqadm/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Unique admin CLI package (uqadm)."""
2
+
3
+ __version__ = "0.1.0"
uqadm/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from uqadm.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
uqadm/chat/__init__.py ADDED
@@ -0,0 +1,200 @@
1
+ """Chat sub-app — send messages and inspect history."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Annotated, Optional
7
+
8
+ import typer
9
+
10
+ from uqadm.chat.history import cmd_history
11
+ from uqadm.chat.send import cmd_send
12
+
13
+ chat_app = typer.Typer(
14
+ name="chat",
15
+ help="Chat with an assistant or inspect chat history.",
16
+ no_args_is_help=True,
17
+ )
18
+
19
+ _STOP_ON_VALUES = {"stoppedStreamingAt", "completedAt"}
20
+ _SLOT_HELP = (
21
+ "Credential slot (loads .{SLOT}.env). "
22
+ "Omit to use the configured default (see `uqadm env set-default`)."
23
+ )
24
+
25
+
26
+ def _get_cwd(ctx: typer.Context) -> Path | None:
27
+ return (ctx.obj or {}).get("cwd")
28
+
29
+
30
+ @chat_app.command(
31
+ "send", short_help="Send a message to an assistant and print the reply."
32
+ )
33
+ def chat_send(
34
+ ctx: typer.Context,
35
+ assistant_id: Annotated[
36
+ str,
37
+ typer.Argument(
38
+ metavar="ASSISTANT_ID",
39
+ help="The assistant ID to send the message to.",
40
+ ),
41
+ ],
42
+ slot: Annotated[
43
+ Optional[str],
44
+ typer.Option("--slot", help=_SLOT_HELP),
45
+ ] = None,
46
+ text: Annotated[
47
+ Optional[str],
48
+ typer.Option("--text", help="Message text (alternative to --file or stdin)."),
49
+ ] = None,
50
+ file: Annotated[
51
+ Optional[Path],
52
+ typer.Option(
53
+ "--file",
54
+ help="Read message from this file (alternative to --text or stdin).",
55
+ exists=True,
56
+ dir_okay=False,
57
+ ),
58
+ ] = None,
59
+ chat_id: Annotated[
60
+ Optional[str],
61
+ typer.Option("--chat-id", help="Continue an existing chat thread."),
62
+ ] = None,
63
+ tool_choices: Annotated[
64
+ Optional[list[str]],
65
+ typer.Option(
66
+ "--tool",
67
+ help="Force a specific tool (repeatable: --tool web_search --tool code_interpreter).",
68
+ ),
69
+ ] = None,
70
+ poll_interval: Annotated[
71
+ float,
72
+ typer.Option(
73
+ "--poll-interval",
74
+ help="Seconds between completion polls.",
75
+ show_default=True,
76
+ ),
77
+ ] = 1.0,
78
+ max_wait: Annotated[
79
+ float,
80
+ typer.Option(
81
+ "--max-wait",
82
+ help="Maximum seconds to wait for a response.",
83
+ show_default=True,
84
+ ),
85
+ ] = 300.0,
86
+ stop_on: Annotated[
87
+ str,
88
+ typer.Option(
89
+ "--stop-on",
90
+ help="Stop condition: stoppedStreamingAt or completedAt.",
91
+ show_default=True,
92
+ ),
93
+ ] = "stoppedStreamingAt",
94
+ as_json: Annotated[
95
+ bool,
96
+ typer.Option("--json", help="Print the full Space.Message as JSON."),
97
+ ] = False,
98
+ ) -> None:
99
+ """Send a prompt to an assistant and print the reply.
100
+
101
+ The chat_id of the new (or continued) thread is printed to stderr so you
102
+ can pass it to --chat-id in a follow-up call.
103
+
104
+ Examples:
105
+
106
+ uqadm chat send asst_abc123 --text "Hello"
107
+ uqadm chat send asst_abc123 --text "Follow up" --chat-id chat_xyz
108
+ uqadm chat send asst_abc123 --slot prod --file ./prompt.txt
109
+ uqadm chat send asst_abc123 --text "Search this" --tool web_search
110
+ uqadm chat send asst_abc123 --text "Run code" --tool code_interpreter --tool web_search
111
+ echo "Summarize this" | uqadm chat send asst_abc123
112
+ """
113
+ if stop_on not in _STOP_ON_VALUES:
114
+ typer.echo(
115
+ f"--stop-on must be one of: {', '.join(sorted(_STOP_ON_VALUES))}",
116
+ err=True,
117
+ )
118
+ raise typer.Exit(2)
119
+ cmd_send(
120
+ assistant_id,
121
+ slot=slot,
122
+ text=text,
123
+ file=file,
124
+ chat_id=chat_id,
125
+ tool_choices=tool_choices,
126
+ poll_interval=poll_interval,
127
+ max_wait=max_wait,
128
+ stop_on=stop_on,
129
+ as_json=as_json,
130
+ cwd=_get_cwd(ctx),
131
+ )
132
+
133
+
134
+ @chat_app.command("history", short_help="Fetch and display chat history.")
135
+ def chat_history(
136
+ ctx: typer.Context,
137
+ chat_id: Annotated[
138
+ str,
139
+ typer.Argument(
140
+ metavar="CHAT_ID",
141
+ help="The chat ID to fetch history for.",
142
+ ),
143
+ ],
144
+ slot: Annotated[
145
+ Optional[str],
146
+ typer.Option("--slot", help=_SLOT_HELP),
147
+ ] = None,
148
+ max_tokens: Annotated[
149
+ int,
150
+ typer.Option(
151
+ "--max-tokens",
152
+ help="Token budget for the history window.",
153
+ show_default=True,
154
+ ),
155
+ ] = 8000,
156
+ percent: Annotated[
157
+ float,
158
+ typer.Option(
159
+ "--percent",
160
+ help="Fraction of max_tokens allocated to history.",
161
+ show_default=True,
162
+ ),
163
+ ] = 0.15,
164
+ max_messages: Annotated[
165
+ int,
166
+ typer.Option(
167
+ "--max-messages",
168
+ help="Maximum number of messages to consider.",
169
+ show_default=True,
170
+ ),
171
+ ] = 4,
172
+ show_full: Annotated[
173
+ bool,
174
+ typer.Option(
175
+ "--full", help="Show the full history instead of the selected window."
176
+ ),
177
+ ] = False,
178
+ as_json: Annotated[
179
+ bool,
180
+ typer.Option("--json", help="Print raw message structures as JSON."),
181
+ ] = False,
182
+ ) -> None:
183
+ """Fetch chat history and display the selected token window (or full with --full).
184
+
185
+ Examples:
186
+
187
+ uqadm chat history chat_xyz
188
+ uqadm chat history chat_xyz --full --json
189
+ uqadm chat history chat_xyz --slot prod
190
+ """
191
+ cmd_history(
192
+ chat_id,
193
+ slot=slot,
194
+ max_tokens=max_tokens,
195
+ percent=percent,
196
+ max_messages=max_messages,
197
+ show_full=show_full,
198
+ as_json=as_json,
199
+ cwd=_get_cwd(ctx),
200
+ )
uqadm/chat/history.py ADDED
@@ -0,0 +1,77 @@
1
+ """Fetch and display chat history for a given chat ID."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import typer
10
+ from unique_sdk.utils.chat_history import load_history
11
+
12
+ from uqadm.chat.render import print_framed_history
13
+ from uqadm.core.auth_debug import echo_credential_debug_if_auth_failure
14
+ from uqadm.core.env import MissingSlotEnvFileError, config_for_slot
15
+ from uqadm.core.slot import MissingDefaultSlotError, resolve_slot
16
+
17
+
18
+ def cmd_history(
19
+ chat_id: str,
20
+ *,
21
+ slot: str | None,
22
+ max_tokens: int,
23
+ percent: float,
24
+ max_messages: int,
25
+ show_full: bool,
26
+ as_json: bool,
27
+ cwd: Path | None,
28
+ ) -> None:
29
+ """Load chat history and display the selected window."""
30
+ try:
31
+ resolved_slot = resolve_slot(slot)
32
+ except MissingDefaultSlotError as exc:
33
+ typer.echo(str(exc), err=True)
34
+ raise typer.Exit(2)
35
+
36
+ try:
37
+ cfg = config_for_slot(resolved_slot, cwd=cwd)
38
+ except MissingSlotEnvFileError as exc:
39
+ typer.echo(str(exc), err=True)
40
+ raise typer.Exit(2)
41
+
42
+ try:
43
+ import unique_sdk
44
+
45
+ unique_sdk.api_key = cfg.api_key
46
+ unique_sdk.app_id = cfg.app_id
47
+ unique_sdk.api_base = cfg.api_base
48
+
49
+ if show_full:
50
+ # Message.list returns the complete untruncated conversation.
51
+ # load_history always drops the last 2 messages (context-injection
52
+ # behaviour) so we bypass it for --full.
53
+ list_result = unique_sdk.Message.list(
54
+ user_id=cfg.user_id,
55
+ company_id=cfg.company_id,
56
+ chatId=chat_id,
57
+ )
58
+ target: list[dict[str, Any]] = [dict(m) for m in list_result.data]
59
+ else:
60
+ _, selected_history = load_history(
61
+ cfg.user_id,
62
+ cfg.company_id,
63
+ chat_id,
64
+ max_tokens,
65
+ percentOfMaxTokens=percent,
66
+ maxMessages=max_messages,
67
+ )
68
+ target = selected_history
69
+ except Exception as exc:
70
+ typer.echo(f"chat history failed: {exc}", err=True)
71
+ echo_credential_debug_if_auth_failure(cfg, exc, label="chat history")
72
+ raise typer.Exit(1)
73
+
74
+ if as_json:
75
+ typer.echo(json.dumps(target, indent=2, default=str))
76
+ else:
77
+ print_framed_history(target)
uqadm/chat/render.py ADDED
@@ -0,0 +1,115 @@
1
+ """Shared terminal rendering helpers for chat commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import sys
7
+ from collections.abc import Mapping
8
+ from typing import Any
9
+
10
+ import typer
11
+
12
+ # Strips ANSI/OSC/DCS/SOS/PM/APC escape sequences and raw C0/C1 control bytes
13
+ # from untrusted server content before it reaches the terminal.
14
+ #
15
+ # Preserved intentionally: \t (0x09), \n (0x0a) — legitimate text content.
16
+ # \r (0x0d) is normalised away to prevent carriage-return overwrite attacks.
17
+ _CONTROL_RE = re.compile(
18
+ r"\x1b"
19
+ r"(?:"
20
+ r"\[[0-9;:<=>?]*[ -/]*[@-~]" # CSI — SGR, cursor movement, etc.
21
+ r"|\][^\x07\x1b]*(?:\x07|\x1b\\)" # OSC — title, hyperlink, iTerm2, …
22
+ r"|[PX^_][^\x1b]*(?:\x1b\\|$)" # DCS / SOS / PM / APC
23
+ r"|[@-~]" # Fe/Fp two-byte sequences (ESC M, ESC c RIS, ESC 7/8, …)
24
+ r")"
25
+ r"|[\x00-\x08\x0b\x0c\x0e-\x1f\x7f\x80-\x9f]" # raw C0/C1 (keep \t \n)
26
+ )
27
+
28
+
29
+ def _sanitize(value: str) -> str:
30
+ """Strip terminal control sequences from untrusted server-supplied text."""
31
+ cleaned = _CONTROL_RE.sub("", value)
32
+ # Normalise \r\n → \n and lone \r → \n to prevent carriage-return overwrite.
33
+ cleaned = cleaned.replace("\r\n", "\n").replace("\r", "\n")
34
+ return cleaned
35
+
36
+
37
+ _ROLE_LABEL = {
38
+ "user": "You",
39
+ "assistant": "Assistant",
40
+ "system": "System",
41
+ }
42
+
43
+
44
+ def rule() -> None:
45
+ width = typer.get_terminal_size().columns if sys.stdout.isatty() else 60
46
+ typer.echo("─" * width)
47
+
48
+
49
+ def _render_references(references: list[dict[str, Any]]) -> None:
50
+ rule()
51
+ typer.echo("References")
52
+ for ref in sorted(references, key=lambda r: r.get("sequenceNumber", 0)):
53
+ seq = ref.get("sequenceNumber", "?")
54
+ name = _sanitize(ref.get("name") or ref.get("source") or "(unknown)")
55
+ url = _sanitize(ref.get("url") or "")
56
+ line = f" [{seq}] {name}"
57
+ if url:
58
+ line += f" {url}"
59
+ typer.echo(line)
60
+
61
+
62
+ def _render_assessments(assessments: list[dict[str, Any]]) -> None:
63
+ rule()
64
+ typer.echo("Evaluation")
65
+ for a in assessments:
66
+ status = _sanitize(a.get("status") or "")
67
+ label = _sanitize(a.get("label") or "")
68
+ title = _sanitize(a.get("title") or "")
69
+ explanation = _sanitize((a.get("explanation") or "").strip())
70
+ header_parts = [p for p in [status, label, title] if p]
71
+ typer.echo(f" {' · '.join(header_parts)}" if header_parts else "")
72
+ if explanation:
73
+ typer.echo(f" {explanation}")
74
+
75
+
76
+ def print_framed_message(result: Mapping[str, Any]) -> None:
77
+ """Print a single assistant reply with chat_id, answer, references, evaluation."""
78
+ chat_id = _sanitize(result.get("chatId") or result.get("chat_id") or "")
79
+ reply_text = _sanitize((result.get("text") or "").strip())
80
+ references: list[dict[str, Any]] = result.get("references") or []
81
+ assessments: list[dict[str, Any]] = result.get("assessment") or []
82
+
83
+ rule()
84
+ typer.echo(f"chat_id: {chat_id}")
85
+ rule()
86
+ typer.echo(reply_text)
87
+
88
+ if references:
89
+ _render_references(references)
90
+ if assessments:
91
+ _render_assessments(assessments)
92
+
93
+ rule()
94
+
95
+
96
+ def print_framed_history(messages: list[dict[str, Any]]) -> None:
97
+ """Print a sequence of chat messages, each in its own framed block."""
98
+ for msg in messages:
99
+ raw_role = _sanitize((msg.get("role") or "unknown").lower())
100
+ label = _ROLE_LABEL.get(raw_role, raw_role.capitalize())
101
+ text = _sanitize((msg.get("text") or msg.get("content") or "").strip())
102
+ references: list[dict[str, Any]] = msg.get("references") or []
103
+ assessments: list[dict[str, Any]] = msg.get("assessment") or []
104
+
105
+ rule()
106
+ typer.echo(label)
107
+ rule()
108
+ typer.echo(text)
109
+
110
+ if references:
111
+ _render_references(references)
112
+ if assessments:
113
+ _render_assessments(assessments)
114
+
115
+ rule()
uqadm/chat/send.py ADDED
@@ -0,0 +1,103 @@
1
+ """Send a message to an assistant and wait for completion."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Literal, cast
10
+
11
+ import typer
12
+ from unique_sdk import Space
13
+ from unique_sdk.utils.chat_in_space import send_message_and_wait_for_completion
14
+
15
+ from uqadm.chat.render import print_framed_message
16
+ from uqadm.core.auth_debug import echo_credential_debug_if_auth_failure
17
+ from uqadm.core.env import MissingSlotEnvFileError, config_for_slot
18
+ from uqadm.core.slot import MissingDefaultSlotError, resolve_slot
19
+
20
+ StopCondition = Literal["stoppedStreamingAt", "completedAt"]
21
+
22
+
23
+ def _read_message_text(
24
+ text: str | None,
25
+ file: Path | None,
26
+ ) -> str:
27
+ """Resolve message body: --text wins, then --file, then stdin."""
28
+ if text is not None:
29
+ return text
30
+ if file is not None:
31
+ return file.read_text(encoding="utf-8")
32
+ if not sys.stdin.isatty():
33
+ return sys.stdin.read()
34
+ typer.echo(
35
+ "Error: provide message via --text TEXT, --file PATH, or pipe to stdin.",
36
+ err=True,
37
+ )
38
+ raise typer.Exit(2)
39
+
40
+
41
+ def cmd_send(
42
+ assistant_id: str,
43
+ *,
44
+ slot: str | None,
45
+ text: str | None,
46
+ file: Path | None,
47
+ chat_id: str | None,
48
+ tool_choices: list[str] | None,
49
+ poll_interval: float,
50
+ max_wait: float,
51
+ stop_on: str,
52
+ as_json: bool,
53
+ cwd: Path | None,
54
+ ) -> None:
55
+ """Send a message to an assistant and print the reply."""
56
+ try:
57
+ resolved_slot = resolve_slot(slot)
58
+ except MissingDefaultSlotError as exc:
59
+ typer.echo(str(exc), err=True)
60
+ raise typer.Exit(2)
61
+
62
+ message_text = _read_message_text(text, file)
63
+
64
+ try:
65
+ cfg = config_for_slot(resolved_slot, cwd=cwd)
66
+ except MissingSlotEnvFileError as exc:
67
+ typer.echo(str(exc), err=True)
68
+ raise typer.Exit(2)
69
+
70
+ try:
71
+ import unique_sdk
72
+
73
+ unique_sdk.api_key = cfg.api_key
74
+ unique_sdk.app_id = cfg.app_id
75
+ unique_sdk.api_base = cfg.api_base
76
+
77
+ result: Space.Message = asyncio.run(
78
+ send_message_and_wait_for_completion(
79
+ user_id=cfg.user_id,
80
+ company_id=cfg.company_id,
81
+ assistant_id=assistant_id,
82
+ text=message_text,
83
+ chat_id=chat_id,
84
+ tool_choices=tool_choices or None,
85
+ poll_interval=poll_interval,
86
+ max_wait=max_wait,
87
+ stop_condition=cast(StopCondition, stop_on),
88
+ )
89
+ )
90
+ except TimeoutError:
91
+ typer.echo(
92
+ f"Timed out after {max_wait}s waiting for assistant response.", err=True
93
+ )
94
+ raise typer.Exit(1)
95
+ except Exception as exc:
96
+ typer.echo(f"chat send failed: {exc}", err=True)
97
+ echo_credential_debug_if_auth_failure(cfg, exc, label="chat send")
98
+ raise typer.Exit(1)
99
+
100
+ if as_json:
101
+ typer.echo(json.dumps(dict(result), indent=2, default=str))
102
+ else:
103
+ print_framed_message(result)
uqadm/cli.py ADDED
@@ -0,0 +1,69 @@
1
+ """Typer entry point for ``uqadm``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Annotated, Optional
8
+
9
+ import typer
10
+
11
+ from uqadm import __version__
12
+ from uqadm.chat import chat_app
13
+ from uqadm.core.env import MissingSlotEnvFileError
14
+ from uqadm.env import env_app
15
+ from uqadm.install import install_command
16
+ from uqadm.space import space_app
17
+
18
+ app = typer.Typer(
19
+ name="uqadm",
20
+ help="Unique admin CLI — space admin (uses same UNIQUE_* env as unique-cli).",
21
+ context_settings={"help_option_names": ["-h", "--help"]},
22
+ )
23
+
24
+ app.add_typer(space_app, name="space")
25
+ app.add_typer(chat_app, name="chat")
26
+ app.add_typer(env_app, name="env")
27
+ app.command("install")(install_command)
28
+
29
+
30
+ @app.callback(invoke_without_command=True)
31
+ def root_callback(
32
+ ctx: typer.Context,
33
+ cwd: Annotated[
34
+ Optional[Path],
35
+ typer.Option(
36
+ "--cwd",
37
+ help=(
38
+ "Directory for per-slot env files (.{slot}.env or {slot}.env). "
39
+ "Defaults to ~/.uqadm/envs/ then process cwd."
40
+ ),
41
+ file_okay=False,
42
+ dir_okay=True,
43
+ ),
44
+ ] = None,
45
+ version: Annotated[
46
+ bool,
47
+ typer.Option(
48
+ "--version",
49
+ help="Show version and exit.",
50
+ is_eager=True,
51
+ ),
52
+ ] = False,
53
+ ) -> None:
54
+ if version:
55
+ typer.echo(f"uqadm {__version__}")
56
+ raise typer.Exit()
57
+ ctx.ensure_object(dict)
58
+ ctx.obj["cwd"] = cwd
59
+ if ctx.invoked_subcommand is None:
60
+ typer.echo(ctx.get_help())
61
+ raise typer.Exit()
62
+
63
+
64
+ def main() -> None:
65
+ try:
66
+ app()
67
+ except MissingSlotEnvFileError as exc:
68
+ typer.echo(str(exc), err=True)
69
+ sys.exit(2)
uqadm/core/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Shared infrastructure for uqadm (paths, env loading, slot resolution, auth debug)."""
@@ -0,0 +1,85 @@
1
+ """Print resolved credential context when API calls fail with auth-like errors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from unique_sdk import AuthenticationError
7
+ from unique_sdk.cli.config import Config
8
+
9
+
10
+ def is_likely_auth_failure(exc: BaseException) -> bool:
11
+ """Heuristic: 401, AuthenticationError, or common unauthorized wording."""
12
+ if isinstance(exc, AuthenticationError):
13
+ return True
14
+ status = getattr(exc, "http_status", None)
15
+ if status == 401:
16
+ return True
17
+ lowered = str(exc).lower()
18
+ if "unauthorized" in lowered or "401" in lowered:
19
+ return True
20
+ return False
21
+
22
+
23
+ def _describe_api_key(api_key: str) -> str:
24
+ if not api_key:
25
+ return (
26
+ "(empty — often OK on localhost / secured cluster; otherwise set "
27
+ "UNIQUE_API_KEY)"
28
+ )
29
+ return f"set, length {len(api_key)} (redacted)"
30
+
31
+
32
+ def _describe_optional(value: str, *, empty_label: str) -> str:
33
+ stripped = value.strip()
34
+ if not stripped:
35
+ return empty_label
36
+ return repr(stripped)
37
+
38
+
39
+ def format_credential_debug_lines(
40
+ cfg: Config,
41
+ *,
42
+ label: str | None = None,
43
+ exc: BaseException | None = None,
44
+ ) -> list[str]:
45
+ """Human-readable lines (no full secrets); suitable for stderr."""
46
+ title = "Credential snapshot (values after slot env load + SDK normalization)"
47
+ lines: list[str] = [title + ":"]
48
+ if label:
49
+ lines.append(f" Context: {label}")
50
+ lines.extend(
51
+ [
52
+ f" UNIQUE_USER_ID: {cfg.user_id!r}",
53
+ f" UNIQUE_COMPANY_ID: {cfg.company_id!r}",
54
+ f" UNIQUE_APP_ID: {_describe_optional(cfg.app_id, empty_label='(empty)')}",
55
+ f" UNIQUE_API_BASE: {cfg.api_base!r}",
56
+ f" UNIQUE_API_KEY: {_describe_api_key(cfg.api_key)}",
57
+ ]
58
+ )
59
+ if exc is not None:
60
+ status = getattr(exc, "http_status", None)
61
+ if status is not None:
62
+ lines.append(f" API HTTP status: {status!r}")
63
+ rid = getattr(exc, "request_id", None)
64
+ if rid:
65
+ lines.append(f" API Request-Id: {rid!r}")
66
+ lines.append(
67
+ " (Toolkit-style unique_auth_*, unique_app_*, and unique_api_* names in "
68
+ "the env file are copied into UNIQUE_* when the latter are unset; "
69
+ "UNIQUE_* wins when both are set.)"
70
+ )
71
+ return lines
72
+
73
+
74
+ def echo_credential_debug_if_auth_failure(
75
+ cfg: Config,
76
+ exc: BaseException,
77
+ *,
78
+ label: str | None = None,
79
+ ) -> None:
80
+ """If ``exc`` looks like an auth problem, print ``cfg`` to stderr."""
81
+ if not is_likely_auth_failure(exc):
82
+ return
83
+ typer.echo("", err=True)
84
+ for line in format_credential_debug_lines(cfg, label=label, exc=exc):
85
+ typer.echo(line, err=True)
@@ -0,0 +1,38 @@
1
+ """Read and write ``~/.uqadm/config.toml`` (default slot and other settings)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tomllib
6
+ from typing import Any
7
+
8
+ import tomli_w
9
+
10
+ from uqadm.core.paths import config_path
11
+
12
+
13
+ def load_config() -> dict[str, Any]:
14
+ """Return the parsed config, or an empty dict if the file does not exist."""
15
+ path = config_path()
16
+ if not path.is_file():
17
+ return {}
18
+ with path.open("rb") as fh:
19
+ return tomllib.load(fh)
20
+
21
+
22
+ def save_config(data: dict[str, Any]) -> None:
23
+ """Persist ``data`` to ``config.toml``, creating parent dirs as needed."""
24
+ path = config_path()
25
+ path.parent.mkdir(parents=True, exist_ok=True)
26
+ path.write_bytes(tomli_w.dumps(data).encode())
27
+
28
+
29
+ def get_default_slot() -> str | None:
30
+ """Return the configured default slot name, or ``None`` if unset."""
31
+ return load_config().get("default_slot")
32
+
33
+
34
+ def set_default_slot(slot: str) -> None:
35
+ """Persist ``slot`` as the default in ``config.toml``."""
36
+ data = load_config()
37
+ data["default_slot"] = slot
38
+ save_config(data)