model-fortress-cli 0.1.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.
mf/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Model Fortress CLI — scan & red-team your models from CI/CD."""
2
+
3
+ __version__ = "0.1.0"
mf/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
mf/cli.py ADDED
@@ -0,0 +1,302 @@
1
+ """`mf` — the Model Fortress CI/CD command line.
2
+
3
+ Scan or red-team a model from a pipeline and fail the build on a policy-gate breach.
4
+
5
+ mf scan --model-id <id> --tool modelscan # static scan + gate
6
+ mf redteam --model-id <id> --probes dan,encoding # red-team + gate
7
+ mf gate --scan-id <id> | --run-id <id> # verdict for an existing job
8
+ mf export --job-id <id> --format sarif -o out.sarif.json
9
+ mf models list
10
+
11
+ Config comes from flags or the environment:
12
+ MF_API_BASE e.g. https://app.au2mait.com/api (your instance's API base)
13
+ MF_TOKEN a Personal Access Token
14
+ MF_TENANT your tenant id
15
+
16
+ Exit codes: 0 = passed 1 = gate breached / job failed / timed out 2 = usage or transport error
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import json
22
+ import os
23
+ import sys
24
+ import time
25
+ from typing import Any
26
+
27
+ from .client import ApiError, Client, GateTimeout
28
+ from . import __version__
29
+
30
+ # Terminal statuses per job kind.
31
+ SCAN_TERMINAL = {"DONE", "FAILED", "COMPLETED", "SUCCESS", "CLEAN", "ERROR", "CANCELLED"}
32
+ RT_TERMINAL = {"DONE", "PARTIAL", "FAILED", "CANCELLED"}
33
+
34
+ EXT = {"sarif": "sarif.json", "json": "json", "csv": "csv", "pdf": "pdf"}
35
+
36
+
37
+ def _err(msg: str) -> None:
38
+ print(msg, file=sys.stderr)
39
+
40
+
41
+ def _info(msg: str) -> None:
42
+ print(msg, file=sys.stderr)
43
+
44
+
45
+ def _client(args: argparse.Namespace) -> Client:
46
+ return Client(
47
+ api_base=args.api_base,
48
+ token=args.token,
49
+ tenant=args.tenant,
50
+ timeout=args.http_timeout,
51
+ )
52
+
53
+
54
+ def _poll(client: Client, path: str, status_key: str, terminal: set[str], timeout: int, interval: int) -> dict:
55
+ deadline = time.time() + timeout
56
+ last: Any = {}
57
+ while time.time() < deadline:
58
+ last = client.data(client.get(path))
59
+ status = str((last or {}).get(status_key, "")).upper()
60
+ if status in terminal:
61
+ return last
62
+ time.sleep(interval)
63
+ raise GateTimeout(f"timed out after {timeout}s waiting for {status_key} to finish")
64
+
65
+
66
+ def _compliance_gate(client: Client, run_id: str, spec: str) -> int:
67
+ """Fail (exit 1) if any required OWASP LLM category has findings.
68
+ `spec` is a comma/space list of category ids (e.g. "LLM01,LLM02") or "all"."""
69
+ rep = client.data(client.get(f"/model-governance/llm-red-teaming/{run_id}/compliance"))
70
+ cats = (
71
+ ((rep or {}).get("frameworks") or {}).get("owasp_llm_top_10") or {}
72
+ ).get("categories") or []
73
+ want = [s.strip().upper() for s in str(spec).replace(",", " ").split() if s.strip()]
74
+ all_mode = want == ["ALL"]
75
+ failed = [
76
+ str(c.get("id", "")).upper()
77
+ for c in cats
78
+ if str(c.get("status")) == "failed"
79
+ and (all_mode or str(c.get("id", "")).upper() in want)
80
+ ]
81
+ if failed:
82
+ _err(f"FAIL — OWASP categories with findings: {', '.join(sorted(set(failed)))}")
83
+ return 1
84
+ _info(f"PASS — required OWASP categories clean ({spec})")
85
+ return 0
86
+
87
+
88
+ def _emit_gate(args: argparse.Namespace, verdict: dict, job_id: str, kind: str) -> int:
89
+ print(json.dumps(verdict, indent=2))
90
+ if not getattr(args, "gate", True):
91
+ return 0
92
+ gate = (verdict or {}).get("gate", {}) if isinstance(verdict, dict) else {}
93
+ if gate.get("passed"):
94
+ _info(f"PASS — {kind} {job_id}")
95
+ return 0
96
+ if not gate.get("evaluated"):
97
+ # No governing policy / nothing to gate on. Don't fail the build on its own,
98
+ # unless the job itself failed (passed will be False then too, handled below).
99
+ if gate.get("passed") is not False:
100
+ _info(f"PASS — {kind} {job_id} (no policy gate evaluated)")
101
+ return 0
102
+ reason = "gate breached" if gate.get("breached") else f"{kind} did not pass"
103
+ _err(f"FAIL — {reason} (fail_on={gate.get('fail_on')})")
104
+ return 1
105
+
106
+
107
+ # ------------------------------- commands -------------------------------
108
+ def cmd_scan(args: argparse.Namespace) -> int:
109
+ c = _client(args)
110
+ scan_id = args.scan_id
111
+ if not scan_id:
112
+ if not args.model_id:
113
+ _err("provide --scan-id (existing) or --model-id (to trigger a new scan)")
114
+ return 2
115
+ body: dict[str, Any] = {"scan_tool": args.tool}
116
+ if args.metadata:
117
+ body["scan_metadata"] = json.loads(args.metadata)
118
+ created = c.data(c.post(f"/model-governance/model/{args.model_id}/model-scan", body))
119
+ scan_id = created.get("scan_id")
120
+ _info(f"started scan {scan_id}")
121
+ if not args.wait:
122
+ print(scan_id)
123
+ return 0
124
+ verdict = _poll(
125
+ c,
126
+ f"/model-governance/model-scan/{scan_id}/gate",
127
+ status_key="scan_status",
128
+ terminal=SCAN_TERMINAL,
129
+ timeout=args.timeout,
130
+ interval=args.poll,
131
+ )
132
+ return _emit_gate(args, verdict, str(scan_id), "scan")
133
+
134
+
135
+ def cmd_redteam(args: argparse.Namespace) -> int:
136
+ c = _client(args)
137
+ run_id = args.run_id
138
+ if not run_id:
139
+ if not args.model_id:
140
+ _err("provide --run-id (existing) or --model-id (to trigger a new run)")
141
+ return 2
142
+ rto: dict[str, Any] = {}
143
+ if args.probes:
144
+ rto["probes"] = args.probes
145
+ if args.generations:
146
+ rto["generations"] = args.generations
147
+ metadata: dict[str, Any] = {"red_team_options": rto}
148
+ if args.metadata:
149
+ metadata.update(json.loads(args.metadata))
150
+ body = {"llm_red_teaming_tool": args.tool, "llm_red_teaming_metadata": metadata}
151
+ created = c.data(c.post(f"/model-governance/model/{args.model_id}/llm-red-teaming", body))
152
+ run_id = created.get("llm_red_teaming_id")
153
+ _info(f"started red-team run {run_id}")
154
+ if not args.wait:
155
+ print(run_id)
156
+ return 0
157
+ _poll(
158
+ c,
159
+ f"/model-governance/llm-red-teaming/{run_id}",
160
+ status_key="llm_red_teaming_status",
161
+ terminal=RT_TERMINAL,
162
+ timeout=args.timeout,
163
+ interval=args.poll,
164
+ )
165
+ verdict = c.data(c.get(f"/model-governance/llm-red-teaming/{run_id}/gate"))
166
+ rc = _emit_gate(args, verdict, str(run_id), "redteam")
167
+ if getattr(args, "require_pass", None):
168
+ rc = max(rc, _compliance_gate(c, str(run_id), args.require_pass))
169
+ return rc
170
+
171
+
172
+ def cmd_gate(args: argparse.Namespace) -> int:
173
+ c = _client(args)
174
+ if args.scan_id:
175
+ v = c.data(c.get(f"/model-governance/model-scan/{args.scan_id}/gate"))
176
+ return _emit_gate(args, v, args.scan_id, "scan")
177
+ if args.run_id:
178
+ v = c.data(c.get(f"/model-governance/llm-red-teaming/{args.run_id}/gate"))
179
+ rc = _emit_gate(args, v, args.run_id, "redteam")
180
+ if getattr(args, "require_pass", None):
181
+ rc = max(rc, _compliance_gate(c, args.run_id, args.require_pass))
182
+ return rc
183
+ _err("provide --scan-id or --run-id")
184
+ return 2
185
+
186
+
187
+ def cmd_export(args: argparse.Namespace) -> int:
188
+ c = _client(args)
189
+ raw = c.request("GET", f"/jobs/{args.job_id}/export", query=f"format={args.format}", raw=True)
190
+ out = args.output or f"{args.job_id}.{EXT.get(args.format, args.format)}"
191
+ with open(out, "wb") as f:
192
+ f.write(raw)
193
+ _info(f"wrote {out} ({len(raw)} bytes)")
194
+ print(json.dumps({"job_id": args.job_id, "format": args.format, "output": out, "bytes": len(raw)}))
195
+ return 0
196
+
197
+
198
+ def cmd_models(args: argparse.Namespace) -> int:
199
+ c = _client(args)
200
+ resp = c.data(c.get("/model-governance/models", query="page=1&page_size=100"))
201
+ items = resp.get("items", []) if isinstance(resp, dict) else (resp or [])
202
+ if args.json:
203
+ print(json.dumps(items, indent=2))
204
+ return 0
205
+ for m in items:
206
+ print(
207
+ "\t".join(
208
+ [
209
+ str(m.get("model_id", "")),
210
+ str(m.get("model_name", "")),
211
+ str(m.get("model_source", "")),
212
+ str(m.get("model_upload_status", m.get("upload_status", ""))),
213
+ ]
214
+ )
215
+ )
216
+ return 0
217
+
218
+
219
+ # ------------------------------- parser -------------------------------
220
+ def _add_common(p: argparse.ArgumentParser) -> None:
221
+ p.add_argument("--api-base", default=os.environ.get("MF_API_BASE"), help="e.g. https://app.au2mait.com/api [env MF_API_BASE]")
222
+ p.add_argument("--token", default=os.environ.get("MF_TOKEN"), help="Personal Access Token [env MF_TOKEN]")
223
+ p.add_argument("--tenant", default=os.environ.get("MF_TENANT"), help="tenant id [env MF_TENANT]")
224
+ p.add_argument("--http-timeout", type=int, default=int(os.environ.get("MF_HTTP_TIMEOUT", "30")), help="per-request timeout seconds")
225
+
226
+
227
+ def _add_wait(p: argparse.ArgumentParser) -> None:
228
+ p.add_argument("--wait", dest="wait", action="store_true", default=True, help="wait for the job to finish (default)")
229
+ p.add_argument("--no-wait", dest="wait", action="store_false", help="submit and print the id, don't wait")
230
+ p.add_argument("--gate", dest="gate", action="store_true", default=True, help="fail the build on a gate breach (default)")
231
+ p.add_argument("--no-gate", dest="gate", action="store_false", help="print the verdict but always exit 0")
232
+ p.add_argument("--timeout", type=int, default=1800, help="max seconds to wait for completion")
233
+ p.add_argument("--poll", type=int, default=5, help="seconds between status polls")
234
+
235
+
236
+ def build_parser() -> argparse.ArgumentParser:
237
+ ap = argparse.ArgumentParser(prog="mf", description="Model Fortress CI/CD client")
238
+ ap.add_argument("--version", action="version", version=f"mf {__version__}")
239
+ sub = ap.add_subparsers(dest="command", required=True)
240
+
241
+ p = sub.add_parser("scan", help="run a static scan and gate on its policy verdict")
242
+ _add_common(p); _add_wait(p)
243
+ p.add_argument("--model-id", help="model to scan (triggers a new scan)")
244
+ p.add_argument("--scan-id", help="gate on an existing scan instead")
245
+ p.add_argument("--tool", default="modelscan", help="scanner: modelscan | modelaudit | picklescan | gguf-scan")
246
+ p.add_argument("--metadata", help="extra scan_metadata as a JSON string")
247
+ p.set_defaults(func=cmd_scan)
248
+
249
+ p = sub.add_parser("redteam", help="run a red-team assessment and gate on its policy verdict")
250
+ _add_common(p); _add_wait(p)
251
+ p.add_argument("--model-id", help="model to red-team (triggers a new run)")
252
+ p.add_argument("--run-id", help="gate on an existing run instead")
253
+ p.add_argument("--tool", default="model_red_teaming", help="engine: model_red_teaming | openrt | pyrit | art")
254
+ p.add_argument("--probes", help="comma-separated probe spec, e.g. dan,encoding")
255
+ p.add_argument("--generations", type=int, help="generations per probe")
256
+ p.add_argument("--metadata", help="extra llm_red_teaming_metadata as a JSON string")
257
+ p.add_argument("--require-pass", help="fail if these OWASP LLM categories have findings, e.g. LLM01,LLM02 (or 'all')")
258
+ p.set_defaults(func=cmd_redteam)
259
+
260
+ p = sub.add_parser("gate", help="fetch the policy-gate verdict for an existing job")
261
+ _add_common(p)
262
+ p.add_argument("--scan-id")
263
+ p.add_argument("--run-id")
264
+ p.add_argument("--require-pass", help="(run-id only) fail if these OWASP LLM categories have findings, e.g. LLM01,LLM02 (or 'all')")
265
+ p.set_defaults(func=cmd_gate, gate=True)
266
+
267
+ p = sub.add_parser("export", help="download a job's findings (SARIF/JSON/CSV/PDF)")
268
+ _add_common(p)
269
+ p.add_argument("--job-id", required=True, help="a scan id or red-team run id")
270
+ p.add_argument("--format", default="sarif", choices=["sarif", "json", "csv", "pdf"])
271
+ p.add_argument("-o", "--output", help="output file (default <job-id>.<ext>)")
272
+ p.set_defaults(func=cmd_export)
273
+
274
+ p = sub.add_parser("models", help="model inventory")
275
+ psub = p.add_subparsers(dest="models_command", required=True)
276
+ pl = psub.add_parser("list", help="list models")
277
+ _add_common(pl)
278
+ pl.add_argument("--json", action="store_true", help="output JSON")
279
+ pl.set_defaults(func=cmd_models)
280
+
281
+ return ap
282
+
283
+
284
+ def main(argv: list[str] | None = None) -> int:
285
+ args = build_parser().parse_args(argv)
286
+ try:
287
+ return int(args.func(args))
288
+ except GateTimeout as e:
289
+ _err(str(e))
290
+ return 1
291
+ except ApiError as e:
292
+ _err(str(e))
293
+ return 2
294
+ except ValueError as e:
295
+ _err(f"bad --metadata JSON: {e}")
296
+ return 2
297
+ except KeyboardInterrupt:
298
+ return 130
299
+
300
+
301
+ if __name__ == "__main__":
302
+ sys.exit(main())
mf/client.py ADDED
@@ -0,0 +1,88 @@
1
+ """Minimal, dependency-free HTTP client for the Model Fortress API.
2
+
3
+ Stdlib only (urllib) so it runs in any CI image without `pip install` of extras.
4
+ Auth is a Personal Access Token sent as a Bearer token. The base URL points at the
5
+ customer's own instance (SaaS at au2mait.com, or a marketplace-provisioned tenant) —
6
+ nothing here is cloud-specific.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import urllib.error
12
+ import urllib.request
13
+ from typing import Any
14
+
15
+
16
+ class ApiError(Exception):
17
+ """Transport / HTTP / usage error (maps to exit code 2)."""
18
+
19
+ def __init__(self, message: str, *, code: int | None = None) -> None:
20
+ super().__init__(message)
21
+ self.code = code
22
+
23
+
24
+ class GateTimeout(Exception):
25
+ """Timed out waiting for a job to finish (maps to exit code 1 — build fails)."""
26
+
27
+
28
+ class Client:
29
+ def __init__(self, api_base: str, token: str, tenant: str, timeout: int = 30) -> None:
30
+ if not api_base:
31
+ raise ApiError("API base URL is required (--api-base or MF_API_BASE)")
32
+ if not token:
33
+ raise ApiError("a Personal Access Token is required (--token or MF_TOKEN)")
34
+ if not tenant:
35
+ raise ApiError("a tenant id is required (--tenant or MF_TENANT)")
36
+ self.base = api_base.rstrip("/")
37
+ self.token = token
38
+ self.tenant = tenant
39
+ self.timeout = timeout
40
+
41
+ def _url(self, path: str, query: str = "") -> str:
42
+ url = f"{self.base}/tenant/{self.tenant}{path}"
43
+ return f"{url}?{query}" if query else url
44
+
45
+ def request(
46
+ self,
47
+ method: str,
48
+ path: str,
49
+ body: dict | None = None,
50
+ query: str = "",
51
+ raw: bool = False,
52
+ ) -> Any:
53
+ data = json.dumps(body).encode() if body is not None else None
54
+ headers = {"Authorization": f"Bearer {self.token}", "Accept": "*/*"}
55
+ if data is not None:
56
+ headers["Content-Type"] = "application/json"
57
+ req = urllib.request.Request(
58
+ self._url(path, query), data=data, headers=headers, method=method
59
+ )
60
+ try:
61
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
62
+ payload = resp.read()
63
+ except urllib.error.HTTPError as e:
64
+ detail = e.read().decode("utf-8", "ignore")[:400]
65
+ raise ApiError(f"HTTP {e.code} on {method} {path}: {detail}", code=e.code)
66
+ except urllib.error.URLError as e:
67
+ raise ApiError(f"could not reach {self.base}: {e.reason}")
68
+ if raw:
69
+ return payload
70
+ if not payload:
71
+ return {}
72
+ try:
73
+ return json.loads(payload.decode())
74
+ except ValueError:
75
+ raise ApiError("unexpected non-JSON response from the API")
76
+
77
+ def get(self, path: str, query: str = "") -> Any:
78
+ return self.request("GET", path, query=query)
79
+
80
+ def post(self, path: str, body: dict | None = None) -> Any:
81
+ return self.request("POST", path, body=body)
82
+
83
+ @staticmethod
84
+ def data(resp: Any) -> Any:
85
+ """Unwrap the API's { "data": ... } envelope when present."""
86
+ if isinstance(resp, dict) and "data" in resp:
87
+ return resp["data"]
88
+ return resp
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.4
2
+ Name: model-fortress-cli
3
+ Version: 0.1.0
4
+ Summary: Model Fortress CI/CD client — scan & red-team your models from a pipeline and fail the build on a policy-gate breach.
5
+ License: Proprietary
6
+ Keywords: model-fortress,ai-security,ci,sarif,red-teaming,model-scanning
7
+ Requires-Python: >=3.8
8
+ Description-Content-Type: text/markdown
9
+
10
+ # Model Fortress — CI/CD & API
11
+
12
+ Scan and red-team your models from a pipeline and **fail the build on a policy-gate
13
+ breach**. Ships as a small, dependency-free Python CLI (`mf`) over the public API.
14
+
15
+ Cloud-agnostic by design: the CLI talks to **your instance's API base URL** with a
16
+ Personal Access Token, so the same pipeline works whether you bought Model Fortress on
17
+ the **Azure, AWS or GCP marketplace** — only `MF_API_BASE` changes.
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ pip install ./backend/ci # from this repo
23
+ # or, once published: pip install model-fortress-cli
24
+ ```
25
+
26
+ The CLI is stdlib-only (no third-party deps), so it runs in any CI image.
27
+
28
+ ## Configure
29
+
30
+ | Env | Flag | Meaning |
31
+ |-----|------|---------|
32
+ | `MF_API_BASE` | `--api-base` | your instance's API base, e.g. `https://app.au2mait.com/api` |
33
+ | `MF_TOKEN` | `--token` | a Personal Access Token (create one in the app → settings) |
34
+ | `MF_TENANT` | `--tenant` | your tenant id |
35
+
36
+ ## Commands
37
+
38
+ ```bash
39
+ # Static scan: submit, wait, fail on the policy gate
40
+ mf scan --model-id <id> --tool modelscan # modelscan | modelaudit | picklescan | gguf-scan
41
+
42
+ # Red-team: submit, wait, fail on the policy gate
43
+ mf redteam --model-id <id> --probes dan,encoding --generations 5
44
+
45
+ # Verdict for an existing job (no wait)
46
+ mf gate --scan-id <id>
47
+ mf gate --run-id <id>
48
+
49
+ # Download findings (SARIF for code-scanning dashboards, or JSON/CSV/PDF)
50
+ mf export --job-id <id> --format sarif -o mf.sarif.json
51
+
52
+ # Inventory
53
+ mf models list
54
+ ```
55
+
56
+ Useful flags on `scan` / `redteam`: `--no-wait` (submit and print the id),
57
+ `--no-gate` (print the verdict but never fail), `--timeout` (max wait, default 1800s),
58
+ `--poll` (seconds between polls, default 5), `--metadata '<json>'`.
59
+
60
+ ## Exit codes
61
+
62
+ | Code | Meaning |
63
+ |------|---------|
64
+ | `0` | gate passed (or `--no-gate`) |
65
+ | `1` | **gate breached, job failed, or timed out** — fail the build |
66
+ | `2` | usage or transport error (bad flags, unreachable API, auth) |
67
+
68
+ The gate is **fail-closed**: a failed scan/run never passes, a breach fails the build,
69
+ and a job with no governing policy doesn't fail on its own.
70
+
71
+ ## The gate endpoints
72
+
73
+ ```
74
+ GET /tenant/<t>/model-governance/model-scan/<scan_id>/gate
75
+ GET /tenant/<t>/model-governance/llm-red-teaming/<run_id>/gate
76
+ ```
77
+ both return `{ ..._status, policy_id, policy_name, gate: { evaluated, fail_on, breached, passed } }`.
78
+ Auth: `Authorization: Bearer <PAT>`. The "fail on <severity>" threshold comes from the
79
+ Scan Policy governing that model.
80
+
81
+ ## Pipeline examples
82
+
83
+ - `examples/github-actions.yml` — PR gate + SARIF upload to the Security tab
84
+ - `examples/gitlab-ci.yml` — MR gate + SAST report
85
+ - `examples/azure-pipelines.yml`
86
+ - `examples/generic.sh` — Jenkins / CircleCI / cron
87
+
88
+ ## Notes
89
+
90
+ - Create a **Personal Access Token** per pipeline and store it as a CI secret; scope it
91
+ to the tenant you're gating.
92
+ - `mf_scan_gate.py` (the earlier single-file script) still works and is kept for
93
+ back-compat, but the `mf` CLI supersedes it and adds red-teaming, export and inventory.
@@ -0,0 +1,9 @@
1
+ mf/__init__.py,sha256=5ROQqLBChXb6BM15HQyCpCf2hJ4TuJoqrizW1fSb9JY,92
2
+ mf/__main__.py,sha256=4JMK66Wj4uLZTKbF-sT3LAxOsr6buig77PmOkJCRRxw,83
3
+ mf/cli.py,sha256=-VyJ4qKDJAW1b_8wMAz-P8lEm_BJTWnTy1ABbtY9Xns,12370
4
+ mf/client.py,sha256=oDB77u6mJC0hVdE0We2Q1PXtzKILhlRLXtYD9A6ynaM,3200
5
+ model_fortress_cli-0.1.0.dist-info/METADATA,sha256=fbS0X7K43UiwAZakzHyCnUBCkR-vXwV3VO95P_DJ_Pc,3376
6
+ model_fortress_cli-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
7
+ model_fortress_cli-0.1.0.dist-info/entry_points.txt,sha256=LJAmno7KOAoWo4PTgQ-0Q0-zpoOQ74uiSd97hyoRwcE,35
8
+ model_fortress_cli-0.1.0.dist-info/top_level.txt,sha256=XehwrO1-bBgqEOAy6U3hWJPJXt2A2cwgNIsPFieCbZM,3
9
+ model_fortress_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mf = mf.cli:main