m2f-cli 0.2.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.
- m2f_cli/__init__.py +3 -0
- m2f_cli/__main__.py +3 -0
- m2f_cli/api_client.py +365 -0
- m2f_cli/config.py +61 -0
- m2f_cli/i18n.py +326 -0
- m2f_cli/main.py +16 -0
- m2f_cli/tui/__init__.py +5 -0
- m2f_cli/tui/app.py +276 -0
- m2f_cli/tui/components/__init__.py +1 -0
- m2f_cli/tui/components/api_key_setup.py +65 -0
- m2f_cli/tui/components/file_input.py +75 -0
- m2f_cli/tui/components/logo.py +32 -0
- m2f_cli/tui/components/main_menu.py +85 -0
- m2f_cli/tui/components/message.py +21 -0
- m2f_cli/tui/components/progress.py +199 -0
- m2f_cli/tui/components/project_table.py +189 -0
- m2f_cli/tui/theme.py +16 -0
- m2f_cli/tui/workflows/__init__.py +1 -0
- m2f_cli/tui/workflows/config.py +130 -0
- m2f_cli/tui/workflows/fill_sorry.py +627 -0
- m2f_cli/tui/workflows/formalize.py +400 -0
- m2f_cli/tui/workflows/project_detail.py +32 -0
- m2f_cli/tui/workflows/project_list.py +48 -0
- m2f_cli/tui/workflows/upload.py +161 -0
- m2f_cli-0.2.0.dist-info/METADATA +10 -0
- m2f_cli-0.2.0.dist-info/RECORD +29 -0
- m2f_cli-0.2.0.dist-info/WHEEL +5 -0
- m2f_cli-0.2.0.dist-info/entry_points.txt +2 -0
- m2f_cli-0.2.0.dist-info/top_level.txt +1 -0
m2f_cli/__init__.py
ADDED
m2f_cli/__main__.py
ADDED
m2f_cli/api_client.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""HTTP client for the M2F Agent Platform backend API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any, BinaryIO, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
# Bypass proxy for local backend connections
|
|
11
|
+
for _key in ("http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY"):
|
|
12
|
+
os.environ.pop(_key, None)
|
|
13
|
+
os.environ.setdefault("no_proxy", "localhost,127.0.0.1")
|
|
14
|
+
|
|
15
|
+
from m2f_cli.config import load_config
|
|
16
|
+
|
|
17
|
+
DEFAULT_TIMEOUT = 30.0
|
|
18
|
+
UPLOAD_TIMEOUT = 300.0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class APIError(Exception):
|
|
22
|
+
"""Raised when an API call returns a non-success status."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, status_code: int, detail: str) -> None:
|
|
25
|
+
self.status_code = status_code
|
|
26
|
+
self.detail = detail
|
|
27
|
+
super().__init__(f"HTTP {status_code}: {detail}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class M2FClient:
|
|
31
|
+
"""Synchronous HTTP client wrapping the M2F backend API."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
base_url: Optional[str] = None,
|
|
36
|
+
api_key: Optional[str] = None,
|
|
37
|
+
access_token: Optional[str] = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
config = load_config()
|
|
40
|
+
self.base_url = (base_url or config.server_url or "").rstrip("/")
|
|
41
|
+
self.api_key = api_key or config.api_key
|
|
42
|
+
self.access_token = access_token or config.access_token
|
|
43
|
+
|
|
44
|
+
if not self.base_url:
|
|
45
|
+
raise APIError(0, "Server URL is not configured. Run `m2f login` or `m2f auth` first.")
|
|
46
|
+
|
|
47
|
+
self._client = httpx.Client(
|
|
48
|
+
base_url=self.base_url,
|
|
49
|
+
timeout=DEFAULT_TIMEOUT,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# ------------------------------------------------------------------
|
|
53
|
+
# Internal helpers
|
|
54
|
+
# ------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
def _auth_headers(self) -> Dict[str, str]:
|
|
57
|
+
"""Return the appropriate Authorization header."""
|
|
58
|
+
if self.api_key:
|
|
59
|
+
return {"Authorization": self.api_key}
|
|
60
|
+
if self.access_token:
|
|
61
|
+
return {"Authorization": f"Bearer {self.access_token}"}
|
|
62
|
+
return {}
|
|
63
|
+
|
|
64
|
+
def _request(
|
|
65
|
+
self,
|
|
66
|
+
method: str,
|
|
67
|
+
path: str,
|
|
68
|
+
*,
|
|
69
|
+
json: Any = None,
|
|
70
|
+
data: Any = None,
|
|
71
|
+
files: Any = None,
|
|
72
|
+
params: Any = None,
|
|
73
|
+
timeout: Optional[float] = None,
|
|
74
|
+
headers: Optional[Dict[str, str]] = None,
|
|
75
|
+
) -> Any:
|
|
76
|
+
"""Send an HTTP request and return the parsed JSON body."""
|
|
77
|
+
merged_headers = {**self._auth_headers(), **(headers or {})}
|
|
78
|
+
resp = self._client.request(
|
|
79
|
+
method,
|
|
80
|
+
path,
|
|
81
|
+
json=json,
|
|
82
|
+
data=data,
|
|
83
|
+
files=files,
|
|
84
|
+
params=params,
|
|
85
|
+
headers=merged_headers,
|
|
86
|
+
timeout=timeout or DEFAULT_TIMEOUT,
|
|
87
|
+
)
|
|
88
|
+
if resp.status_code >= 400:
|
|
89
|
+
try:
|
|
90
|
+
detail = resp.json().get("detail", resp.text)
|
|
91
|
+
except Exception:
|
|
92
|
+
detail = resp.text
|
|
93
|
+
raise APIError(resp.status_code, detail)
|
|
94
|
+
if resp.status_code == 204:
|
|
95
|
+
return None
|
|
96
|
+
try:
|
|
97
|
+
return resp.json()
|
|
98
|
+
except Exception:
|
|
99
|
+
return resp.text
|
|
100
|
+
|
|
101
|
+
# ------------------------------------------------------------------
|
|
102
|
+
# Auth endpoints
|
|
103
|
+
# ------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
def login(self, email: str, password: str) -> Dict[str, Any]:
|
|
106
|
+
"""POST /api/v1/auth/login"""
|
|
107
|
+
return self._request(
|
|
108
|
+
"POST",
|
|
109
|
+
"/api/v1/auth/login",
|
|
110
|
+
json={"email": email, "password": password},
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def me(self) -> Dict[str, Any]:
|
|
114
|
+
"""GET /api/v1/auth/me"""
|
|
115
|
+
return self._request("GET", "/api/v1/auth/me")
|
|
116
|
+
|
|
117
|
+
# ------------------------------------------------------------------
|
|
118
|
+
# Project endpoints
|
|
119
|
+
# ------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
def create_project(self, name: str, source_type: str = "upload") -> Dict[str, Any]:
|
|
122
|
+
"""POST /api/v1/projects"""
|
|
123
|
+
return self._request(
|
|
124
|
+
"POST",
|
|
125
|
+
"/api/v1/projects",
|
|
126
|
+
json={"name": name, "source_type": source_type},
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def list_projects(
|
|
130
|
+
self,
|
|
131
|
+
page: int = 1,
|
|
132
|
+
page_size: int = 20,
|
|
133
|
+
source_type: Optional[str] = None,
|
|
134
|
+
status_in: Optional[List[str]] = None,
|
|
135
|
+
) -> Dict[str, Any]:
|
|
136
|
+
"""GET /api/v1/projects — returns {items, total, page, page_size}."""
|
|
137
|
+
params: List[tuple[str, Any]] = [("page", page), ("page_size", page_size)]
|
|
138
|
+
if source_type:
|
|
139
|
+
params.append(("source_type", source_type))
|
|
140
|
+
if status_in:
|
|
141
|
+
for s in status_in:
|
|
142
|
+
params.append(("status", s))
|
|
143
|
+
return self._request("GET", "/api/v1/projects", params=params)
|
|
144
|
+
|
|
145
|
+
def get_project(self, project_id: str) -> Dict[str, Any]:
|
|
146
|
+
"""GET /api/v1/projects/{project_id}"""
|
|
147
|
+
return self._request("GET", f"/api/v1/projects/{project_id}")
|
|
148
|
+
|
|
149
|
+
def get_project_progress(self, project_id: str) -> Dict[str, Any]:
|
|
150
|
+
"""GET /api/v1/projects/{project_id}/progress"""
|
|
151
|
+
return self._request("GET", f"/api/v1/projects/{project_id}/progress")
|
|
152
|
+
|
|
153
|
+
def get_project_logs(self, project_id: str, offset: int = 0) -> Dict[str, Any]:
|
|
154
|
+
"""GET /api/v1/projects/{project_id}/logs"""
|
|
155
|
+
return self._request(
|
|
156
|
+
"GET",
|
|
157
|
+
f"/api/v1/projects/{project_id}/logs",
|
|
158
|
+
params={"offset": offset},
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# ------------------------------------------------------------------
|
|
162
|
+
# Upload endpoints
|
|
163
|
+
# ------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
def upload_init(
|
|
166
|
+
self,
|
|
167
|
+
project_id: str,
|
|
168
|
+
filename: str,
|
|
169
|
+
total_chunks: int,
|
|
170
|
+
) -> Dict[str, Any]:
|
|
171
|
+
"""POST /api/v1/upload/{project_id}/init (multipart form)."""
|
|
172
|
+
return self._request(
|
|
173
|
+
"POST",
|
|
174
|
+
f"/api/v1/upload/{project_id}/init",
|
|
175
|
+
data={
|
|
176
|
+
"filename": filename,
|
|
177
|
+
"total_chunks": str(total_chunks),
|
|
178
|
+
},
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def upload_chunk(
|
|
182
|
+
self,
|
|
183
|
+
project_id: str,
|
|
184
|
+
upload_id: str,
|
|
185
|
+
chunk_index: int,
|
|
186
|
+
chunk_data: BinaryIO,
|
|
187
|
+
) -> Dict[str, Any]:
|
|
188
|
+
"""POST /api/v1/upload/{project_id}/chunk"""
|
|
189
|
+
return self._request(
|
|
190
|
+
"POST",
|
|
191
|
+
f"/api/v1/upload/{project_id}/chunk",
|
|
192
|
+
data={
|
|
193
|
+
"upload_id": upload_id,
|
|
194
|
+
"chunk_index": str(chunk_index),
|
|
195
|
+
},
|
|
196
|
+
files={"file": ("chunk", chunk_data, "application/octet-stream")},
|
|
197
|
+
timeout=UPLOAD_TIMEOUT,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
def upload_complete(self, project_id: str, upload_id: str) -> Dict[str, Any]:
|
|
201
|
+
"""POST /api/v1/upload/{project_id}/complete"""
|
|
202
|
+
return self._request(
|
|
203
|
+
"POST",
|
|
204
|
+
f"/api/v1/upload/{project_id}/complete",
|
|
205
|
+
data={"upload_id": upload_id},
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# ------------------------------------------------------------------
|
|
209
|
+
# Formalize endpoints
|
|
210
|
+
# ------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
def create_formalize_job(
|
|
213
|
+
self,
|
|
214
|
+
statement_prompt: str,
|
|
215
|
+
formalize_proof: bool = False,
|
|
216
|
+
proof_prompt: Optional[str] = None,
|
|
217
|
+
) -> Dict[str, Any]:
|
|
218
|
+
"""POST /api/v1/formalize-jobs"""
|
|
219
|
+
payload: Dict[str, Any] = {
|
|
220
|
+
"statement_prompt": statement_prompt,
|
|
221
|
+
"formalize_proof": formalize_proof,
|
|
222
|
+
}
|
|
223
|
+
if proof_prompt:
|
|
224
|
+
payload["proof_prompt"] = proof_prompt
|
|
225
|
+
return self._request("POST", "/api/v1/formalize-jobs", json=payload)
|
|
226
|
+
|
|
227
|
+
def list_formalize_jobs(self, page: int = 1, page_size: int = 50, project_id: str | None = None) -> Dict[str, Any]:
|
|
228
|
+
"""GET /api/v1/formalize-jobs"""
|
|
229
|
+
params: Dict[str, Any] = {"page": page, "page_size": page_size}
|
|
230
|
+
if project_id:
|
|
231
|
+
params["project_id"] = project_id
|
|
232
|
+
return self._request("GET", "/api/v1/formalize-jobs", params=params)
|
|
233
|
+
|
|
234
|
+
def get_formalize_job(self, job_id: str) -> Dict[str, Any]:
|
|
235
|
+
"""GET /api/v1/formalize-jobs/{job_id}"""
|
|
236
|
+
return self._request("GET", f"/api/v1/formalize-jobs/{job_id}")
|
|
237
|
+
|
|
238
|
+
def get_formalize_job_output(self, job_id: str) -> str:
|
|
239
|
+
"""GET /api/v1/formalize-jobs/{job_id}/output"""
|
|
240
|
+
return self._request("GET", f"/api/v1/formalize-jobs/{job_id}/output")
|
|
241
|
+
|
|
242
|
+
def get_formalize_job_log(self, job_id: str) -> str:
|
|
243
|
+
"""GET /api/v1/formalize-jobs/{job_id}/log"""
|
|
244
|
+
return self._request("GET", f"/api/v1/formalize-jobs/{job_id}/log")
|
|
245
|
+
|
|
246
|
+
# ------------------------------------------------------------------
|
|
247
|
+
# CLI Fill Sorry endpoints
|
|
248
|
+
# ------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
def create_cli_job(
|
|
251
|
+
self,
|
|
252
|
+
mode: str = "item",
|
|
253
|
+
need_proof: bool = True,
|
|
254
|
+
target_file: Optional[str] = None,
|
|
255
|
+
) -> Dict[str, Any]:
|
|
256
|
+
"""POST /api/v1/json-jobs/cli"""
|
|
257
|
+
payload: Dict[str, Any] = {
|
|
258
|
+
"mode": mode,
|
|
259
|
+
"need_proof": need_proof,
|
|
260
|
+
}
|
|
261
|
+
if target_file:
|
|
262
|
+
payload["target_file"] = target_file
|
|
263
|
+
return self._request("POST", "/api/v1/json-jobs/cli", json=payload)
|
|
264
|
+
|
|
265
|
+
def upload_job_zip(
|
|
266
|
+
self,
|
|
267
|
+
job_id: str,
|
|
268
|
+
zip_data: BinaryIO,
|
|
269
|
+
target_file: Optional[str] = None,
|
|
270
|
+
) -> Dict[str, Any]:
|
|
271
|
+
"""PUT /api/v1/json-jobs/{job_id}/upload-zip"""
|
|
272
|
+
data = {}
|
|
273
|
+
if target_file:
|
|
274
|
+
data["target_file"] = target_file
|
|
275
|
+
return self._request(
|
|
276
|
+
"PUT",
|
|
277
|
+
f"/api/v1/json-jobs/{job_id}/upload-zip",
|
|
278
|
+
data=data or None,
|
|
279
|
+
files={"file": ("project.zip", zip_data, "application/zip")},
|
|
280
|
+
timeout=UPLOAD_TIMEOUT,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
def download_artifact(self, job_id: str, artifact: str) -> bytes:
|
|
284
|
+
"""GET /api/v1/json-jobs/{job_id}/download/{artifact} - returns raw bytes."""
|
|
285
|
+
resp = self._client.request(
|
|
286
|
+
"GET",
|
|
287
|
+
f"/api/v1/json-jobs/{job_id}/download/{artifact}",
|
|
288
|
+
headers=self._auth_headers(),
|
|
289
|
+
timeout=UPLOAD_TIMEOUT,
|
|
290
|
+
)
|
|
291
|
+
if resp.status_code >= 400:
|
|
292
|
+
raise APIError(resp.status_code, "Download failed")
|
|
293
|
+
return resp.content
|
|
294
|
+
|
|
295
|
+
def list_cli_jobs(self, page: int = 1, page_size: int = 50) -> Dict[str, Any]:
|
|
296
|
+
"""GET /api/v1/formalize-jobs?source_type=cli"""
|
|
297
|
+
return self._request(
|
|
298
|
+
"GET", "/api/v1/formalize-jobs",
|
|
299
|
+
params={"page": page, "page_size": page_size, "source_type": "cli"},
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
def get_job_file_content(self, job_id: str, path: str) -> Dict[str, Any]:
|
|
303
|
+
"""GET /api/v1/json-jobs/{job_id}/file-content?path=xxx"""
|
|
304
|
+
return self._request(
|
|
305
|
+
"GET",
|
|
306
|
+
f"/api/v1/json-jobs/{job_id}/file-content",
|
|
307
|
+
params={"path": path},
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
# ------------------------------------------------------------------
|
|
311
|
+
# Sorry CLI (Prover) endpoints
|
|
312
|
+
# ------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
def sorry_upload(
|
|
315
|
+
self,
|
|
316
|
+
tar_gz_data: bytes,
|
|
317
|
+
project_name: str = "CLI Upload",
|
|
318
|
+
) -> Dict[str, Any]:
|
|
319
|
+
"""POST /api/v1/sorry-cli/upload — upload tar.gz, creates job."""
|
|
320
|
+
resp = self._client.request(
|
|
321
|
+
"POST",
|
|
322
|
+
"/api/v1/sorry-cli/upload",
|
|
323
|
+
content=tar_gz_data,
|
|
324
|
+
headers={
|
|
325
|
+
**self._auth_headers(),
|
|
326
|
+
"Content-Type": "application/gzip",
|
|
327
|
+
"X-Project-Name": project_name,
|
|
328
|
+
},
|
|
329
|
+
timeout=UPLOAD_TIMEOUT,
|
|
330
|
+
)
|
|
331
|
+
if resp.status_code >= 400:
|
|
332
|
+
try:
|
|
333
|
+
detail = resp.json().get("detail", resp.text)
|
|
334
|
+
except Exception:
|
|
335
|
+
detail = resp.text
|
|
336
|
+
raise APIError(resp.status_code, detail)
|
|
337
|
+
return resp.json()
|
|
338
|
+
|
|
339
|
+
def sorry_list_files(self, job_id: str) -> Dict[str, Any]:
|
|
340
|
+
"""GET /api/v1/sorry-cli/{job_id}/files"""
|
|
341
|
+
return self._request("GET", f"/api/v1/sorry-cli/{job_id}/files")
|
|
342
|
+
|
|
343
|
+
def sorry_list_sorries(self, job_id: str, path: str) -> Dict[str, Any]:
|
|
344
|
+
"""GET /api/v1/sorry-cli/{job_id}/sorries?path=..."""
|
|
345
|
+
return self._request("GET", f"/api/v1/sorry-cli/{job_id}/sorries", params={"path": path})
|
|
346
|
+
|
|
347
|
+
def sorry_run(self, job_id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
348
|
+
"""POST /api/v1/sorry-cli/{job_id}/run — queue compile+prove via Worker."""
|
|
349
|
+
return self._request("POST", f"/api/v1/sorry-cli/{job_id}/run", json=payload)
|
|
350
|
+
|
|
351
|
+
def sorry_status(self, job_id: str) -> Dict[str, Any]:
|
|
352
|
+
"""GET /api/v1/sorry-cli/{job_id} — read status from DB."""
|
|
353
|
+
return self._request("GET", f"/api/v1/sorry-cli/{job_id}")
|
|
354
|
+
|
|
355
|
+
def sorry_read_file(self, job_id: str, path: str) -> Dict[str, Any]:
|
|
356
|
+
"""GET /api/v1/sorry-cli/{job_id}/file?path=..."""
|
|
357
|
+
return self._request("GET", f"/api/v1/sorry-cli/{job_id}/file", params={"path": path})
|
|
358
|
+
|
|
359
|
+
def sorry_history(self, job_id: str, limit: int = 50) -> Dict[str, Any]:
|
|
360
|
+
"""GET /api/v1/sorry-cli/{job_id}/history"""
|
|
361
|
+
return self._request("GET", f"/api/v1/sorry-cli/{job_id}/history", params={"limit": limit})
|
|
362
|
+
|
|
363
|
+
def sorry_tasks(self, job_id: str, limit: int = 50) -> Dict[str, Any]:
|
|
364
|
+
"""GET /api/v1/sorry-cli/{job_id}/tasks"""
|
|
365
|
+
return self._request("GET", f"/api/v1/sorry-cli/{job_id}/tasks", params={"limit": limit})
|
m2f_cli/config.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Configuration management for M2F CLI.
|
|
2
|
+
|
|
3
|
+
Stores configuration at ~/.m2f/config.json.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
CONFIG_DIR = Path.home() / ".m2f"
|
|
15
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class M2FConfig(BaseModel):
|
|
19
|
+
"""CLI configuration model."""
|
|
20
|
+
|
|
21
|
+
server_url: Optional[str] = None
|
|
22
|
+
api_key: Optional[str] = None
|
|
23
|
+
access_token: Optional[str] = None
|
|
24
|
+
refresh_token: Optional[str] = None
|
|
25
|
+
username: Optional[str] = None
|
|
26
|
+
language: str = "zh"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _ensure_config_dir() -> None:
|
|
30
|
+
"""Create ~/.m2f/ directory if it does not exist."""
|
|
31
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load_config() -> M2FConfig:
|
|
35
|
+
"""Load configuration from disk. Returns defaults if file is missing."""
|
|
36
|
+
if not CONFIG_FILE.exists():
|
|
37
|
+
return M2FConfig()
|
|
38
|
+
try:
|
|
39
|
+
data = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
|
|
40
|
+
return M2FConfig(**data)
|
|
41
|
+
except (json.JSONDecodeError, TypeError):
|
|
42
|
+
return M2FConfig()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def save_config(config: M2FConfig) -> None:
|
|
46
|
+
"""Persist configuration to disk."""
|
|
47
|
+
_ensure_config_dir()
|
|
48
|
+
CONFIG_FILE.write_text(
|
|
49
|
+
json.dumps(config.model_dump(), indent=2, ensure_ascii=False),
|
|
50
|
+
encoding="utf-8",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def update_config(**kwargs) -> M2FConfig:
|
|
55
|
+
"""Load config, update given fields, save, and return the new config."""
|
|
56
|
+
config = load_config()
|
|
57
|
+
for key, value in kwargs.items():
|
|
58
|
+
if hasattr(config, key):
|
|
59
|
+
setattr(config, key, value)
|
|
60
|
+
save_config(config)
|
|
61
|
+
return config
|