waidrop-cli 1.0.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.
waidrop_cli/fetch.py ADDED
@@ -0,0 +1,214 @@
1
+ """Range planning, Dropbox downloads, decoding, filtering and dedupe."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from concurrent.futures import ThreadPoolExecutor
8
+ from dataclasses import dataclass
9
+ from datetime import date, datetime, timedelta
10
+
11
+ from cryptography.exceptions import InvalidTag
12
+
13
+ from .archive import ArchiveCodec
14
+ from .chats import ChatEntry
15
+ from .config import DropboxConfig, Window
16
+ from .errors import GuardError, NoMatchesError, RemoteError
17
+
18
+ MAX_WORKERS = 4
19
+ PART_RE = re.compile(r"^part-(\d+)(?:-(\d+))?-(\d+)\.(enc|ndjson)$")
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class FetchTask:
24
+ chat_hash: str
25
+ key: str
26
+
27
+
28
+ def effective_ts(record: dict):
29
+ value = record.get("ts")
30
+ if value is None:
31
+ value = record.get("sortTs")
32
+ if value is None:
33
+ value = record.get("capturedAt")
34
+ return value if isinstance(value, (int, float)) else None
35
+
36
+
37
+ def _media_stored(record: dict) -> bool:
38
+ media = record.get("media")
39
+ return isinstance(media, dict) and media.get("status") == "stored"
40
+
41
+
42
+ def _captured_at(record: dict) -> float:
43
+ value = record.get("capturedAt")
44
+ return value if isinstance(value, (int, float)) else 0
45
+
46
+
47
+ def _prefer(candidate: dict, current: dict) -> bool:
48
+ candidate_store = candidate.get("source") == "store"
49
+ current_store = current.get("source") == "store"
50
+ if candidate_store != current_store:
51
+ return candidate_store
52
+ if _media_stored(candidate) != _media_stored(current):
53
+ return _media_stored(candidate)
54
+ return _captured_at(candidate) > _captured_at(current)
55
+
56
+
57
+ def _dt_from_folder(name: str) -> date | None:
58
+ if not name.startswith("dt="):
59
+ return None
60
+ try:
61
+ return date.fromisoformat(name[3:])
62
+ except ValueError:
63
+ return None
64
+
65
+
66
+ def parse_part_name(name: str, suffix: str | None = None):
67
+ """Return (first_ts, last_ts|None, seq) for a part object."""
68
+ name = name or ""
69
+ if suffix is not None and not name.endswith(suffix):
70
+ return None
71
+ match = PART_RE.match(name)
72
+ if not match:
73
+ return None
74
+ first_ts = int(match.group(1))
75
+ last_ts = int(match.group(2)) if match.group(2) else None
76
+ seq = int(match.group(3))
77
+ return first_ts, last_ts, seq
78
+
79
+
80
+ def _chat_could_have_window(chat: ChatEntry, window: Window) -> bool:
81
+ if chat.last_seen is not None and chat.last_seen < window.start_ms:
82
+ return False
83
+ if chat.first_seen is not None and chat.first_seen >= window.end_ms:
84
+ return False
85
+ return True
86
+
87
+
88
+ def _select_parts(parts: list[dict], window: Window) -> list[dict]:
89
+ """Choose parts that can contain records in [start, end).
90
+
91
+ New names carry lastTs (exact interval). Legacy names only carry firstTs:
92
+ take the last legacy part starting at/before the window, plus every later
93
+ part. Parts are written in chronological order, so everything before that
94
+ anchor ends before the window starts (modulo repaired re-uploads).
95
+ """
96
+ ordered = sorted(parts, key=lambda part: (part["first_ts"], part["seq"]))
97
+ selected = [
98
+ part
99
+ for part in ordered
100
+ if part["last_ts"] is not None
101
+ and part["last_ts"] >= window.start_ms
102
+ and part["first_ts"] < window.end_ms
103
+ ]
104
+
105
+ anchor = -1
106
+ for index, part in enumerate(ordered):
107
+ if part["last_ts"] is None and part["first_ts"] <= window.start_ms:
108
+ anchor = index
109
+ for index, part in enumerate(ordered):
110
+ if part["last_ts"] is not None:
111
+ continue
112
+ if part["first_ts"] >= window.end_ms:
113
+ continue
114
+ if anchor < 0 or index >= anchor:
115
+ selected.append(part)
116
+
117
+ seen = set()
118
+ unique = []
119
+ for part in sorted(selected, key=lambda item: (item["first_ts"], item["seq"])):
120
+ if part["key"] not in seen:
121
+ seen.add(part["key"])
122
+ unique.append(part)
123
+ return unique
124
+
125
+
126
+ def plan_tasks(
127
+ client, cfg: DropboxConfig, codec: ArchiveCodec, chats: list[ChatEntry], window: Window
128
+ ) -> list[FetchTask]:
129
+ low = datetime.fromtimestamp(window.start_ms / 1000, tz=cfg.tz).date() - timedelta(days=1)
130
+ high = datetime.fromtimestamp(window.end_ms / 1000, tz=cfg.tz).date() + timedelta(days=1)
131
+
132
+ tasks: list[FetchTask] = []
133
+ for chat in chats:
134
+ if not _chat_could_have_window(chat, window):
135
+ continue
136
+ base = f"{cfg.account_root}/chat={chat.hash}"
137
+ parts: list[dict] = []
138
+ for item in client.list_folder(base):
139
+ if item.get(".tag") != "folder":
140
+ continue
141
+ day = _dt_from_folder(item.get("name") or "")
142
+ if day is None or not (low <= day <= high):
143
+ continue
144
+ folder_path = item.get("path_display") or item.get("path_lower") or ""
145
+ for part in client.list_folder(folder_path):
146
+ if part.get(".tag") != "file":
147
+ continue
148
+ parsed = parse_part_name(part.get("name") or "", codec.part_suffix)
149
+ if parsed is None:
150
+ continue
151
+ path = part.get("path_display") or part.get("path_lower") or ""
152
+ if not path:
153
+ continue
154
+ first_ts, last_ts, seq = parsed
155
+ parts.append({"key": path, "first_ts": first_ts, "last_ts": last_ts, "seq": seq})
156
+ for part in _select_parts(parts, window):
157
+ tasks.append(FetchTask(chat_hash=chat.hash, key=part["key"]))
158
+
159
+ if not tasks:
160
+ raise NoMatchesError("no archived messages found for the matched chats in this window")
161
+ if len(tasks) > cfg.max_parts:
162
+ raise GuardError(
163
+ f"{len(tasks)} part objects exceed --max-parts {cfg.max_parts}; narrow the window or selectors"
164
+ )
165
+ return tasks
166
+
167
+
168
+ def _parse_part(text: str, key: str) -> list[dict]:
169
+ records: list[dict] = []
170
+ for line_number, line in enumerate(text.splitlines(), 1):
171
+ line = line.strip()
172
+ if not line:
173
+ continue
174
+ try:
175
+ record = json.loads(line)
176
+ except json.JSONDecodeError as exc:
177
+ raise RemoteError(f"invalid NDJSON in {key} line {line_number}: {exc}") from exc
178
+ if isinstance(record, dict) and record.get("msgId"):
179
+ records.append(record)
180
+ return records
181
+
182
+
183
+ def collect(
184
+ client, cfg: DropboxConfig, codec: ArchiveCodec, tasks: list[FetchTask], window: Window
185
+ ) -> list[dict]:
186
+ def fetch(task: FetchTask):
187
+ payload = client.download(task.key)
188
+ if payload is None:
189
+ return task.key, None
190
+ try:
191
+ return task.key, codec.decode_text(payload)
192
+ except (ValueError, InvalidTag) as exc:
193
+ raise RemoteError(f"cannot decrypt {task.key}: {exc}") from exc
194
+
195
+ with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
196
+ payloads = list(pool.map(fetch, tasks))
197
+
198
+ merged: dict[str, dict] = {}
199
+ for key, text in payloads:
200
+ if text is None:
201
+ continue
202
+ for record in _parse_part(text, key):
203
+ ts = effective_ts(record)
204
+ if ts is None or not (window.start_ms <= ts < window.end_ms):
205
+ continue
206
+ msg_id = str(record["msgId"])
207
+ current = merged.get(msg_id)
208
+ if current is None or _prefer(record, current):
209
+ merged[msg_id] = record
210
+
211
+ return sorted(
212
+ merged.values(),
213
+ key=lambda record: (effective_ts(record) or 0, str(record.get("msgId") or "")),
214
+ )
waidrop_cli/mcp_cli.py ADDED
@@ -0,0 +1,293 @@
1
+ """Run the public waidrop CLI as a subprocess."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import subprocess
8
+ import sys
9
+ from dataclasses import dataclass
10
+
11
+ from .errors import CliError, UsageError
12
+ from .mcp_config import ACCOUNT_RE, DEFAULT_ACCOUNT, McpSettings, load_env_file
13
+
14
+ CREATE_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
15
+ CHAT_HEADER = "=== CHAT "
16
+ CHAT_SEPARATOR = " | "
17
+ FAILURE_DETAIL_LIMIT = 2000
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class CliResult:
22
+ returncode: int
23
+ stdout: str
24
+ stderr: str
25
+ timed_out: bool = False
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class LimitedText:
30
+ text: str
31
+ truncated: bool
32
+ included_chats: int
33
+ omitted_chats: int
34
+
35
+
36
+ def base_command() -> list[str]:
37
+ return [sys.executable, "-m", "waidrop_cli"]
38
+
39
+
40
+ def _base_env(settings: McpSettings) -> dict[str, str]:
41
+ env = dict(os.environ)
42
+ for key, value in load_env_file(settings.env_path).items():
43
+ env.setdefault(key, value)
44
+ if settings.account:
45
+ env.setdefault("WAIDROP_ACCOUNT", settings.account)
46
+ return env
47
+
48
+
49
+ def build_env(settings: McpSettings) -> dict[str, str]:
50
+ env = _base_env(settings)
51
+ env.setdefault("PYTHONIOENCODING", "utf-8")
52
+ env.setdefault("PYTHONUTF8", "1")
53
+ return env
54
+
55
+
56
+ def effective_account(settings: McpSettings) -> str:
57
+ account = (_base_env(settings).get("WAIDROP_ACCOUNT") or "").strip() or DEFAULT_ACCOUNT
58
+ if not ACCOUNT_RE.match(account):
59
+ raise UsageError("account alias may only contain letters, digits and . _ -")
60
+ return account
61
+
62
+
63
+ def run_cli(args: list[str], settings: McpSettings) -> CliResult:
64
+ command = base_command() + args
65
+ try:
66
+ completed = subprocess.run(
67
+ command,
68
+ capture_output=True,
69
+ text=True,
70
+ encoding="utf-8",
71
+ errors="replace",
72
+ timeout=settings.cli_timeout_seconds,
73
+ env=build_env(settings),
74
+ check=False,
75
+ creationflags=CREATE_NO_WINDOW,
76
+ )
77
+ except subprocess.TimeoutExpired as exc:
78
+ return CliResult(
79
+ -1,
80
+ _as_text(exc.stdout),
81
+ _as_text(exc.stderr),
82
+ timed_out=True,
83
+ )
84
+ except CliError as exc:
85
+ return CliResult(exc.exit_code, "", str(exc))
86
+ except OSError as exc:
87
+ return CliResult(-1, "", str(exc))
88
+ return CliResult(completed.returncode, completed.stdout, completed.stderr)
89
+
90
+
91
+ def chats_args(
92
+ *,
93
+ names: list[str] | None = None,
94
+ numbers: list[str] | None = None,
95
+ hashes: list[str] | None = None,
96
+ jids: list[str] | None = None,
97
+ ) -> list[str]:
98
+ args = ["chats", "--quiet"]
99
+ for value in names or []:
100
+ args += ["--name", value]
101
+ for value in numbers or []:
102
+ args += ["--number", value]
103
+ for value in hashes or []:
104
+ args += ["--chat", value]
105
+ for value in jids or []:
106
+ args += ["--jid", value]
107
+ return args
108
+
109
+
110
+ def context_args(
111
+ *,
112
+ last: str | None,
113
+ start: str | None,
114
+ end: str | None,
115
+ context_before: str,
116
+ max_chars_per_chat: int,
117
+ max_parts: int,
118
+ tz: str | None,
119
+ names: list[str] | None = None,
120
+ numbers: list[str] | None = None,
121
+ hashes: list[str] | None = None,
122
+ jids: list[str] | None = None,
123
+ ) -> list[str]:
124
+ args = [
125
+ "context",
126
+ "--format",
127
+ "text",
128
+ "--quiet",
129
+ "--context-before",
130
+ context_before,
131
+ "--max-chars",
132
+ str(max_chars_per_chat),
133
+ "--max-parts",
134
+ str(max_parts),
135
+ ]
136
+ if last:
137
+ args += ["--last", last]
138
+ elif start:
139
+ args += ["--from", start]
140
+ if end:
141
+ args += ["--to", end]
142
+ if tz:
143
+ args += ["--tz", tz]
144
+ for value in names or []:
145
+ args += ["--name", value]
146
+ for value in numbers or []:
147
+ args += ["--number", value]
148
+ for value in hashes or []:
149
+ args += ["--chat", value]
150
+ for value in jids or []:
151
+ args += ["--jid", value]
152
+ return args
153
+
154
+
155
+ def limit_cli_text(
156
+ text: str,
157
+ *,
158
+ max_chats: int,
159
+ max_total_chars: int,
160
+ ) -> LimitedText:
161
+ identities = _chat_identities(text)
162
+ if max_chats <= 0 and max_total_chars <= 0:
163
+ return LimitedText(text, False, len(identities), 0)
164
+
165
+ blocks = _split_blocks(text)
166
+ if not blocks:
167
+ if max_total_chars > 0 and len(text) > max_total_chars:
168
+ excerpt = _truncate_at_line(text, max_total_chars)
169
+ return LimitedText(
170
+ excerpt + "\n[waidrop-mcp: output truncated]\n",
171
+ True,
172
+ 0,
173
+ 0,
174
+ )
175
+ return LimitedText(text, False, 0, 0)
176
+
177
+ allowed = set(identities[:max_chats]) if max_chats > 0 else set(identities)
178
+ selected: list[str] = []
179
+ used = 0
180
+ stopped = False
181
+
182
+ for block in blocks:
183
+ identity = _block_identity(block)
184
+ if identity not in allowed:
185
+ continue
186
+ if max_total_chars > 0 and used + len(block) > max_total_chars:
187
+ if not selected:
188
+ excerpt = _truncate_at_line(block, max_total_chars)
189
+ selected.append(excerpt)
190
+ used = len(excerpt)
191
+ stopped = True
192
+ break
193
+ selected.append(block)
194
+ used += len(block)
195
+
196
+ included = {_block_identity(block) for block in selected if _block_identity(block)}
197
+ omitted = len([identity for identity in identities if identity not in included])
198
+ truncated = stopped or omitted > 0
199
+ result = "".join(selected)
200
+
201
+ if truncated:
202
+ if omitted:
203
+ notice = (
204
+ f"[waidrop-mcp: output truncated; omitted {omitted} chat(s). "
205
+ "Narrow the window or raise max_total_chars.]\n"
206
+ )
207
+ else:
208
+ notice = "[waidrop-mcp: output truncated; raise max_total_chars.]\n"
209
+ if not result.endswith("\n"):
210
+ result += "\n"
211
+ result += notice
212
+ return LimitedText(result, truncated, len(included), omitted)
213
+
214
+
215
+ def describe_failure(result: CliResult, action: str, timeout_seconds: float) -> str:
216
+ detail = _clean_detail(result.stderr)
217
+ suffix = f" {detail}" if detail else ""
218
+
219
+ if result.timed_out:
220
+ return (
221
+ f"The waidrop CLI timed out after {timeout_seconds:g}s while trying to "
222
+ f"{action}. Narrow the window, select fewer chats, lower max_parts, "
223
+ f"or raise cli_timeout_seconds.{suffix}"
224
+ )
225
+ if result.returncode == 2:
226
+ return f"WAiDrop CLI configuration or usage error.{suffix}"
227
+ if result.returncode == 3:
228
+ return (
229
+ "No archived chats or messages matched. Try a longer window or a "
230
+ f"different chat selector.{suffix}"
231
+ )
232
+ if result.returncode == 4:
233
+ return (
234
+ "Too many archive parts would be fetched. Narrow the window or chat "
235
+ f"selection, or lower max_parts.{suffix}"
236
+ )
237
+ if result.returncode == 5:
238
+ return (
239
+ "Could not read the Dropbox archive. Check `waidrop auth --status` "
240
+ f"and the archive key.{suffix}"
241
+ )
242
+ if result.returncode == -1:
243
+ return f"Could not start the waidrop CLI.{suffix}"
244
+ return f"WAiDrop CLI failed with exit code {result.returncode}.{suffix}"
245
+
246
+
247
+ def _split_blocks(text: str) -> list[str]:
248
+ blocks = re.split(r"(?=^=== CHAT )", text, flags=re.MULTILINE)
249
+ return [block for block in blocks if block.startswith(CHAT_HEADER)]
250
+
251
+
252
+ def _block_identity(block: str) -> str:
253
+ first_line = block.splitlines()[0] if block else ""
254
+ header = first_line[len(CHAT_HEADER) :] if first_line.startswith(CHAT_HEADER) else first_line
255
+ identity = header.partition(CHAT_SEPARATOR)[0].strip()
256
+ return identity or header.strip()
257
+
258
+
259
+ def _chat_identities(text: str) -> list[str]:
260
+ identities: list[str] = []
261
+ seen: set[str] = set()
262
+ for block in _split_blocks(text):
263
+ identity = _block_identity(block)
264
+ if identity not in seen:
265
+ seen.add(identity)
266
+ identities.append(identity)
267
+ return identities
268
+
269
+
270
+ def _truncate_at_line(text: str, limit: int) -> str:
271
+ if limit <= 0:
272
+ return ""
273
+ excerpt = text[:limit]
274
+ if len(text) > limit:
275
+ newline = excerpt.rfind("\n")
276
+ if newline > 0:
277
+ excerpt = excerpt[: newline + 1]
278
+ return excerpt
279
+
280
+
281
+ def _clean_detail(value: str) -> str:
282
+ detail = (value or "").strip()
283
+ if len(detail) > FAILURE_DETAIL_LIMIT:
284
+ detail = detail[:FAILURE_DETAIL_LIMIT].rstrip() + "..."
285
+ return detail
286
+
287
+
288
+ def _as_text(value) -> str:
289
+ if value is None:
290
+ return ""
291
+ if isinstance(value, bytes):
292
+ return value.decode("utf-8", errors="replace")
293
+ return str(value)
@@ -0,0 +1,191 @@
1
+ """MCP server settings, environment file loading and precedence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from types import SimpleNamespace
11
+
12
+ from .auth import resolve_credentials_path
13
+ from .config import parse_context_before, parse_duration, parse_tz
14
+ from .env_file import load_env_file # noqa: F401 (re-exported for existing callers)
15
+ from .errors import CliError
16
+
17
+ DEFAULT_LAST = "6h"
18
+ DEFAULT_CONTEXT_BEFORE = "30m"
19
+ DEFAULT_MAX_CHARS_PER_CHAT = 0
20
+ DEFAULT_MAX_TOTAL_CHARS = 0
21
+ DEFAULT_MAX_CHATS = 0
22
+ DEFAULT_MAX_PARTS = 1000
23
+ DEFAULT_CLI_TIMEOUT_SECONDS = 120.0
24
+ DEFAULT_ACCOUNT = "primary"
25
+
26
+ ACCOUNT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
27
+
28
+ CONFIG_KEYS = {
29
+ "default_last",
30
+ "default_context_before",
31
+ "max_chars_per_chat",
32
+ "max_total_chars",
33
+ "max_chats",
34
+ "max_parts",
35
+ "cli_timeout_seconds",
36
+ "account",
37
+ "credentials_file",
38
+ "env_file",
39
+ "tz",
40
+ }
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class McpSettings:
45
+ default_last: str = DEFAULT_LAST
46
+ default_context_before: str = DEFAULT_CONTEXT_BEFORE
47
+ max_chars_per_chat: int = DEFAULT_MAX_CHARS_PER_CHAT
48
+ max_total_chars: int = DEFAULT_MAX_TOTAL_CHARS
49
+ max_chats: int = DEFAULT_MAX_CHATS
50
+ max_parts: int = DEFAULT_MAX_PARTS
51
+ cli_timeout_seconds: float = DEFAULT_CLI_TIMEOUT_SECONDS
52
+ account: str = DEFAULT_ACCOUNT
53
+ credentials_file: str | None = None
54
+ env_file: str | None = None
55
+ tz: str | None = None
56
+
57
+ @property
58
+ def credentials_path(self) -> Path:
59
+ return resolve_credentials_path(SimpleNamespace(credentials_file=self.credentials_file))
60
+
61
+ @property
62
+ def env_path(self) -> Path:
63
+ if self.env_file:
64
+ return Path(self.env_file).expanduser()
65
+ return self.credentials_path.parent / "env"
66
+
67
+
68
+ def default_config_path() -> Path:
69
+ explicit = _env_text("WAIDROP_MCP_CONFIG")
70
+ if explicit is not None:
71
+ return Path(explicit).expanduser()
72
+ credentials = resolve_credentials_path(
73
+ SimpleNamespace(credentials_file=_env_text("WAIDROP_CREDENTIALS"))
74
+ )
75
+ return credentials.parent / "mcp.json"
76
+
77
+
78
+ def load_settings() -> McpSettings:
79
+ config_path = default_config_path()
80
+ document = _read_config(config_path)
81
+ values = {key: document[key] for key in CONFIG_KEYS if key in document}
82
+ values = _apply_env(values)
83
+ settings = McpSettings(**values)
84
+ _validate(settings)
85
+ return settings
86
+
87
+
88
+ def _read_config(path: Path) -> dict:
89
+ try:
90
+ text = path.read_text(encoding="utf-8")
91
+ except FileNotFoundError:
92
+ return {}
93
+ except OSError as exc:
94
+ raise ValueError(f"could not read {path}: {exc}") from exc
95
+
96
+ try:
97
+ document = json.loads(text)
98
+ except json.JSONDecodeError as exc:
99
+ raise ValueError(f"{path} is not valid JSON: {exc}") from exc
100
+
101
+ if not isinstance(document, dict):
102
+ raise ValueError(f"{path} must contain a JSON object")
103
+ if document.get("version") != 1:
104
+ raise ValueError(f'{path} must contain "version": 1')
105
+ return document
106
+
107
+
108
+ def _apply_env(values: dict) -> dict:
109
+ merged = dict(values)
110
+ overrides = {
111
+ "default_last": "WAIDROP_MCP_DEFAULT_LAST",
112
+ "default_context_before": "WAIDROP_MCP_DEFAULT_CONTEXT_BEFORE",
113
+ "max_chars_per_chat": "WAIDROP_MCP_MAX_CHARS",
114
+ "max_total_chars": "WAIDROP_MCP_MAX_TOTAL_CHARS",
115
+ "max_chats": "WAIDROP_MCP_MAX_CHATS",
116
+ "max_parts": "WAIDROP_MCP_MAX_PARTS",
117
+ "cli_timeout_seconds": "WAIDROP_MCP_TIMEOUT",
118
+ "env_file": "WAIDROP_MCP_ENV_FILE",
119
+ }
120
+ for field, env_name in overrides.items():
121
+ raw = _env_text(env_name)
122
+ if raw is None:
123
+ continue
124
+ if field in {"max_chars_per_chat", "max_total_chars", "max_chats", "max_parts"}:
125
+ merged[field] = _parse_int(raw, env_name)
126
+ elif field == "cli_timeout_seconds":
127
+ merged[field] = _parse_float(raw, env_name)
128
+ else:
129
+ merged[field] = raw
130
+ return merged
131
+
132
+
133
+ def _validate(settings: McpSettings) -> None:
134
+ for field in ("default_last", "default_context_before", "account"):
135
+ if not isinstance(getattr(settings, field), str):
136
+ raise ValueError(f"{field} must be a string")
137
+ for field in ("max_chars_per_chat", "max_total_chars", "max_chats", "max_parts"):
138
+ value = getattr(settings, field)
139
+ if not isinstance(value, int) or isinstance(value, bool):
140
+ raise ValueError(f"{field} must be an integer")
141
+ if not isinstance(settings.cli_timeout_seconds, (int, float)) or isinstance(
142
+ settings.cli_timeout_seconds, bool
143
+ ):
144
+ raise ValueError("cli_timeout_seconds must be a number")
145
+ if settings.max_chars_per_chat < 0:
146
+ raise ValueError("max_chars_per_chat must be zero or positive")
147
+ if settings.max_total_chars < 0:
148
+ raise ValueError("max_total_chars must be zero or positive")
149
+ if settings.max_chats < 0:
150
+ raise ValueError("max_chats must be zero or positive")
151
+ if not 1 <= settings.max_parts <= 1000:
152
+ raise ValueError("max_parts must be between 1 and 1000")
153
+ if not 1 <= settings.cli_timeout_seconds <= 600:
154
+ raise ValueError("cli_timeout_seconds must be between 1 and 600")
155
+ if not ACCOUNT_RE.match(settings.account or ""):
156
+ raise ValueError("account alias may only contain letters, digits and . _ -")
157
+ try:
158
+ parse_duration(settings.default_last)
159
+ except CliError as exc:
160
+ raise ValueError(f"invalid default_last: {exc}") from exc
161
+ try:
162
+ parse_context_before(settings.default_context_before)
163
+ except CliError as exc:
164
+ raise ValueError(f"invalid default_context_before: {exc}") from exc
165
+ if settings.tz:
166
+ try:
167
+ parse_tz(settings.tz)
168
+ except CliError as exc:
169
+ raise ValueError(f"invalid tz: {exc}") from exc
170
+
171
+
172
+ def _env_text(name: str) -> str | None:
173
+ value = os.environ.get(name)
174
+ if value is None:
175
+ return None
176
+ value = value.strip()
177
+ return value or None
178
+
179
+
180
+ def _parse_int(raw: str, name: str) -> int:
181
+ try:
182
+ return int(raw)
183
+ except ValueError as exc:
184
+ raise ValueError(f"{name} must be an integer") from exc
185
+
186
+
187
+ def _parse_float(raw: str, name: str) -> float:
188
+ try:
189
+ return float(raw)
190
+ except ValueError as exc:
191
+ raise ValueError(f"{name} must be a number") from exc