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 ADDED
@@ -0,0 +1,3 @@
1
+ """M2F Agent Platform CLI client."""
2
+
3
+ __version__ = "0.2.0"
m2f_cli/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from m2f_cli.main import main
2
+
3
+ main()
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