factmesh 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.
factmesh/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Reference client for the claim exchange protocol (SPEC.md)."""
2
+
3
+ __version__ = "0.1.0"
factmesh/check.py ADDED
@@ -0,0 +1,155 @@
1
+ """Running a claim's check against the web (SPEC.md §4.4, §4.5)."""
2
+
3
+ import ipaddress
4
+ import json
5
+ from dataclasses import dataclass
6
+ from urllib.parse import urljoin
7
+
8
+ from .claim import Rejected, check_binding, url_parts
9
+
10
+ MAX_REDIRECTS = 3
11
+ REDIRECT_STATUSES = {301, 302, 303, 307, 308}
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Response:
16
+ status: int
17
+ headers: dict
18
+ body: bytes
19
+
20
+
21
+ class FetchError(Exception):
22
+ """No conclusion: network failure, timeout, or size limit."""
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class CheckResult:
27
+ result: str # "pass", "fail", or "error"
28
+ detail: str = ""
29
+ final_url: str = ""
30
+
31
+
32
+ def is_public(address):
33
+ ip = ipaddress.ip_address(address)
34
+ if ip.version == 6 and ip.ipv4_mapped:
35
+ ip = ip.ipv4_mapped
36
+ return ip.is_global and not ip.is_multicast
37
+
38
+
39
+ def _resolve_pointer(doc, pointer):
40
+ if pointer == "":
41
+ return doc
42
+ node = doc
43
+ for raw in pointer[1:].split("/"):
44
+ token = raw.replace("~1", "/").replace("~0", "~")
45
+ if isinstance(node, dict) and token in node:
46
+ node = node[token]
47
+ elif (isinstance(node, list) and token.isdigit()
48
+ and (token == "0" or not token.startswith("0")) and int(token) < len(node)):
49
+ node = node[int(token)]
50
+ else:
51
+ raise KeyError(pointer)
52
+ return node
53
+
54
+
55
+ def json_equal(a, b):
56
+ """Strict JSON equality for scalars: bool is not a number, 1 == 1.0."""
57
+ if isinstance(a, bool) or isinstance(b, bool):
58
+ return type(a) is type(b) and a == b
59
+ if isinstance(a, (int, float)) and isinstance(b, (int, float)):
60
+ return a == b
61
+ return type(a) is type(b) and a == b
62
+
63
+
64
+ def _compare(check, resp):
65
+ kind = check["kind"]
66
+ if kind == "http-status":
67
+ ok = resp.status == check["expect"]
68
+ return CheckResult("pass" if ok else "fail", f"status {resp.status}")
69
+ if resp.status != 200:
70
+ return CheckResult("fail", f"status {resp.status}")
71
+ try:
72
+ text = resp.body.decode("utf-8")
73
+ except UnicodeDecodeError:
74
+ return CheckResult("fail", "body is not UTF-8")
75
+ if kind == "text-contains":
76
+ ok = check["expect"] in text
77
+ return CheckResult("pass" if ok else "fail", "text found" if ok else "text not found")
78
+ try:
79
+ doc = json.loads(text)
80
+ except ValueError:
81
+ return CheckResult("fail", "body is not JSON")
82
+ except RecursionError:
83
+ return CheckResult("fail", "JSON nested too deeply")
84
+ try:
85
+ value = _resolve_pointer(doc, check["pointer"])
86
+ except KeyError:
87
+ return CheckResult("fail", "pointer does not resolve")
88
+ if isinstance(value, (dict, list)):
89
+ return CheckResult("fail", "pointer refers to an object or array")
90
+ ok = json_equal(value, check["expect"])
91
+ return CheckResult("pass" if ok else "fail", f"value {json.dumps(value)[:80]}")
92
+
93
+
94
+ def run_check(topic, check, fetcher):
95
+ """Run one check. `fetcher` provides resolve(host) and get(url, address).
96
+
97
+ Exceptions other than FetchError propagate, so a test harness can stop a
98
+ receiver that makes a request it should not.
99
+ """
100
+ got = fetch(topic, check["url"], check["kind"] != "http-status", fetcher)
101
+ if isinstance(got, CheckResult):
102
+ return got
103
+ resp, url = got
104
+ r = _compare(check, resp)
105
+ return CheckResult(r.result, r.detail, url)
106
+
107
+
108
+ def observe_json(topic, url, pointer, fetcher):
109
+ """The current value at `pointer` in the JSON at `url`, fetched under the
110
+ same rules as a check. Returns (value, None) or (None, reason)."""
111
+ got = fetch(topic, url, True, fetcher)
112
+ if isinstance(got, CheckResult):
113
+ return None, got.detail
114
+ resp, _ = got
115
+ if resp.status != 200:
116
+ return None, f"status {resp.status}"
117
+ try:
118
+ value = _resolve_pointer(json.loads(resp.body.decode("utf-8")), pointer)
119
+ except (ValueError, KeyError, RecursionError, UnicodeDecodeError):
120
+ return None, "no value at pointer"
121
+ if isinstance(value, (dict, list)):
122
+ return None, "pointer refers to an object or array"
123
+ return value, None
124
+
125
+
126
+ def fetch(topic, url, follow, fetcher):
127
+ """GET `url` under SPEC.md §4.4, following redirects inside the topic
128
+ binding if `follow`. Returns (Response, final url) or an error
129
+ CheckResult."""
130
+ for hop in range(MAX_REDIRECTS + 1):
131
+ host, _ = url_parts(url)
132
+ try:
133
+ addresses = fetcher.resolve(host)
134
+ except FetchError as e:
135
+ return CheckResult("error", f"resolve {host}: {e}", url)
136
+ if not addresses or not all(is_public(a) for a in addresses):
137
+ return CheckResult("error", f"{host} resolves to a non-public address", url)
138
+ try:
139
+ resp = fetcher.get(url, addresses[0])
140
+ except FetchError as e:
141
+ return CheckResult("error", str(e), url)
142
+
143
+ if not (follow and resp.status in REDIRECT_STATUSES):
144
+ return resp, url
145
+ if hop == MAX_REDIRECTS:
146
+ return CheckResult("error", "too many redirects", url)
147
+ location = resp.headers.get("location")
148
+ if not location:
149
+ return CheckResult("error", "redirect without location", url)
150
+ url = urljoin(url, location)
151
+ try:
152
+ check_binding(topic, url)
153
+ except Rejected as e:
154
+ return CheckResult("error", f"redirect not allowed: {e}", url)
155
+ raise AssertionError("unreachable")
factmesh/claim.py ADDED
@@ -0,0 +1,279 @@
1
+ """Claim files: parsing and the offline rules of SPEC.md §2–§4."""
2
+
3
+ import hashlib
4
+ import json
5
+ import re
6
+ from dataclasses import dataclass, field
7
+ from datetime import datetime, timedelta, timezone
8
+
9
+ import yaml
10
+
11
+ MAX_CONTENT_BYTES = 8192
12
+ MAX_LIFETIME = timedelta(days=7)
13
+ MAX_DEPTH = 4
14
+ MAX_URL_LENGTH = 300
15
+ ALLOWLIST = frozenset({"registry.npmjs.org", "pypi.org", "api.github.com", "github.com"})
16
+
17
+ CHECK_FIELDS = {
18
+ "http-status": {"kind", "url", "expect"},
19
+ "json-field-equals": {"kind", "url", "pointer", "expect"},
20
+ "text-contains": {"kind", "url", "expect"},
21
+ }
22
+ QUESTION_FIELDS = {
23
+ "http-status": ("kind", "url"),
24
+ "json-field-equals": ("kind", "url", "pointer"),
25
+ "text-contains": ("kind", "url", "expect"),
26
+ }
27
+
28
+ _NPM_PART = r"[a-z0-9][a-z0-9._~-]{0,213}"
29
+ TOPIC_PATTERNS = {
30
+ "npm": re.compile(rf"(?:@{_NPM_PART}/)?{_NPM_PART}"),
31
+ "pypi": re.compile(r"[a-z0-9](?:[a-z0-9]|-(?!-))*(?<!-)"),
32
+ "github": re.compile(r"[a-z0-9](?:[a-z0-9]|-(?!-)){0,38}/[a-z0-9._-]{1,100}"),
33
+ }
34
+ _TIME_STRING = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z")
35
+
36
+
37
+ class Rejected(Exception):
38
+ """A claim or event broke a rule. `reason` is a SPEC.md §7 code."""
39
+
40
+ def __init__(self, reason, detail=""):
41
+ super().__init__(f"{reason}: {detail}" if detail else reason)
42
+ self.reason = reason
43
+ self.detail = detail
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class Claim:
48
+ topic: str
49
+ check: dict
50
+ generated_by: str
51
+ generated_at: datetime
52
+ stale_after: datetime
53
+ status: str
54
+ header: dict = field(repr=False)
55
+ content: str = field(repr=False)
56
+
57
+ @property
58
+ def key(self):
59
+ return claim_key(self.check)
60
+
61
+ @property
62
+ def retracted(self):
63
+ return self.status == "deprecated"
64
+
65
+
66
+ # --- YAML -------------------------------------------------------------------
67
+
68
+ class _StrictLoader(yaml.SafeLoader):
69
+ """SafeLoader that refuses repeated mapping keys."""
70
+
71
+
72
+ def _construct_mapping(loader, node, deep=False):
73
+ seen = set()
74
+ for key_node, _ in node.value:
75
+ key = loader.construct_object(key_node, deep=deep)
76
+ if key in seen:
77
+ raise Rejected("bad-header", f"repeated key {key!r}")
78
+ seen.add(key)
79
+ return yaml.SafeLoader.construct_mapping(loader, node, deep=deep)
80
+
81
+
82
+ _StrictLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG,
83
+ _construct_mapping)
84
+
85
+
86
+ def _depth(value):
87
+ if isinstance(value, dict):
88
+ return 1 + max((_depth(v) for v in value.values()), default=0)
89
+ if isinstance(value, list):
90
+ return 1 + max((_depth(v) for v in value), default=0)
91
+ return 0
92
+
93
+
94
+ def parse_header(content):
95
+ """Split a claim file and load its YAML header (SPEC.md §2.1)."""
96
+ if not content.startswith("---\n"):
97
+ raise Rejected("bad-header", "file does not start with a --- line")
98
+ lines = content.split("\n")
99
+ try:
100
+ end = lines.index("---", 1)
101
+ except ValueError:
102
+ raise Rejected("bad-header", "no closing --- line") from None
103
+ text = "\n".join(lines[1:end])
104
+ try:
105
+ for token in yaml.scan(text, Loader=yaml.SafeLoader):
106
+ if isinstance(token, (yaml.AnchorToken, yaml.AliasToken, yaml.TagToken)):
107
+ raise Rejected("bad-header", f"{type(token).__name__[:-5].lower()} not allowed")
108
+ header = yaml.load(text, Loader=_StrictLoader)
109
+ except yaml.YAMLError as e:
110
+ raise Rejected("bad-header", str(e).splitlines()[0]) from None
111
+ if not isinstance(header, dict):
112
+ raise Rejected("bad-header", "header is not a mapping")
113
+ if _depth(header) > MAX_DEPTH:
114
+ raise Rejected("bad-header", f"nests deeper than {MAX_DEPTH} levels")
115
+ return header
116
+
117
+
118
+ # --- fields -----------------------------------------------------------------
119
+
120
+ def parse_time(value, name):
121
+ if isinstance(value, datetime):
122
+ if value.tzinfo is None or value.utcoffset() != timedelta(0):
123
+ raise Rejected("bad-field", f"{name} must be UTC with a Z suffix")
124
+ return value.astimezone(timezone.utc)
125
+ if isinstance(value, str) and _TIME_STRING.fullmatch(value):
126
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
127
+ raise Rejected("bad-field", f"{name} is not a UTC date-time")
128
+
129
+
130
+ def _fields(header):
131
+ if header.get("type") != "Claim":
132
+ raise Rejected("bad-field", "type is not Claim")
133
+ for name in ("topic", "check", "generated", "stale_after"):
134
+ if name not in header:
135
+ raise Rejected("bad-field", f"missing {name}")
136
+ if not isinstance(header["topic"], str):
137
+ raise Rejected("bad-field", "topic is not a string")
138
+ gen = header["generated"]
139
+ if not isinstance(gen, dict) or not isinstance(gen.get("by"), str) or not gen["by"]:
140
+ raise Rejected("bad-field", "generated.by missing")
141
+ if "at" not in gen:
142
+ raise Rejected("bad-field", "generated.at missing")
143
+ generated_at = parse_time(gen["at"], "generated.at")
144
+ stale_after = parse_time(header["stale_after"], "stale_after")
145
+ if stale_after <= generated_at:
146
+ raise Rejected("bad-field", "stale_after is not after generated.at")
147
+ if stale_after - generated_at > MAX_LIFETIME:
148
+ raise Rejected("bad-field", "lifetime over 7 days")
149
+ status = header.get("status", "stable")
150
+ if status not in ("stable", "deprecated"):
151
+ raise Rejected("bad-field", f"unknown status {status!r}")
152
+ for name, kind in (("title", str), ("tags", list), ("sources", list)):
153
+ if name in header and not isinstance(header[name], kind):
154
+ raise Rejected("bad-field", f"{name} has the wrong type")
155
+ return gen["by"], generated_at, stale_after, status
156
+
157
+
158
+ def validate_topic(topic):
159
+ prefix, sep, name = topic.partition(":")
160
+ pattern = TOPIC_PATTERNS.get(prefix)
161
+ if not sep or pattern is None or not pattern.fullmatch(name):
162
+ raise Rejected("bad-topic", topic)
163
+ if prefix == "github" and name.split("/")[1] in (".", ".."):
164
+ raise Rejected("bad-topic", topic)
165
+ return prefix, name
166
+
167
+
168
+ def _is_json_scalar(v):
169
+ return v is None or isinstance(v, (str, bool, int, float))
170
+
171
+
172
+ def validate_check(check):
173
+ if not isinstance(check, dict):
174
+ raise Rejected("bad-check", "check is not a mapping")
175
+ kind = check.get("kind")
176
+ if kind not in CHECK_FIELDS:
177
+ raise Rejected("bad-check", f"unknown kind {kind!r}")
178
+ if set(check) != CHECK_FIELDS[kind]:
179
+ raise Rejected("bad-check", f"fields must be exactly {sorted(CHECK_FIELDS[kind])}")
180
+ if not isinstance(check["url"], str):
181
+ raise Rejected("bad-check", "url is not a string")
182
+ expect = check["expect"]
183
+ if kind == "http-status":
184
+ if not isinstance(expect, int) or isinstance(expect, bool) or not 100 <= expect <= 599:
185
+ raise Rejected("bad-check", "expect must be an integer 100-599")
186
+ elif kind == "json-field-equals":
187
+ pointer = check["pointer"]
188
+ if not isinstance(pointer, str) or (pointer and not pointer.startswith("/")):
189
+ raise Rejected("bad-check", "pointer is not a JSON Pointer")
190
+ if not _is_json_scalar(expect):
191
+ raise Rejected("bad-check", "expect must be a JSON scalar")
192
+ elif not isinstance(expect, str) or not 8 <= len(expect) <= 200:
193
+ raise Rejected("bad-check", "expect must be a string of 8-200 characters")
194
+
195
+
196
+ # --- URLs -------------------------------------------------------------------
197
+
198
+ def url_parts(url):
199
+ """Return (host, path segments) or raise bad-url (SPEC.md §4.2).
200
+
201
+ Works on the string as written: no normalization, so `..` and encoded
202
+ characters cannot slip past the binding rules.
203
+ """
204
+ if len(url) > MAX_URL_LENGTH:
205
+ raise Rejected("bad-url", f"longer than {MAX_URL_LENGTH} characters")
206
+ if not url.startswith("https://"):
207
+ raise Rejected("bad-url", "scheme must be https")
208
+ if any(c in url for c in "%?#") or any(c.isspace() for c in url):
209
+ raise Rejected("bad-url", "no %, query, fragment, or whitespace allowed")
210
+ rest = url[len("https://"):]
211
+ host, slash, path = rest.partition("/")
212
+ if "@" in host or ":" in host:
213
+ raise Rejected("bad-url", "no user information or port allowed")
214
+ if host not in ALLOWLIST:
215
+ raise Rejected("bad-url", f"host {host!r} not on the allowlist")
216
+ return host, path.split("/") if slash else []
217
+
218
+
219
+ def check_binding(topic, url):
220
+ """Raise topic-mismatch unless `url` is an allowed path for `topic` (§4.3)."""
221
+ prefix, name = validate_topic(topic)
222
+ host, segs = url_parts(url)
223
+ if not segs or any(s in ("", ".", "..") for s in segs):
224
+ raise Rejected("topic-mismatch", "empty or dot path segment")
225
+ name_segs = name.split("/")
226
+ n = len(name_segs)
227
+
228
+ ok = False
229
+ if prefix == "npm" and host == "registry.npmjs.org":
230
+ ok = (segs == ["-", "package", *name_segs, "dist-tags"]
231
+ or segs[:n] == name_segs and len(segs) in (n, n + 1))
232
+ elif prefix == "pypi" and host == "pypi.org":
233
+ ok = segs[:2] == ["pypi", name] and segs[-1] == "json" and len(segs) in (3, 4)
234
+ elif prefix == "github" and host == "api.github.com":
235
+ ok = segs[:3] == ["repos", *name_segs]
236
+ elif prefix == "github" and host == "github.com":
237
+ ok = segs[:2] == name_segs
238
+ if not ok:
239
+ raise Rejected("topic-mismatch", f"{url} is not allowed for {topic}")
240
+
241
+
242
+ # --- whole claim ------------------------------------------------------------
243
+
244
+ def claim_key(check):
245
+ """First 32 hex chars of SHA-256 over the check's question fields (§5.1)."""
246
+ names = QUESTION_FIELDS.get(check.get("kind"), ("kind", "url"))
247
+ q = {k: check[k] for k in names if k in check}
248
+ canon = json.dumps(q, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
249
+ return hashlib.sha256(canon.encode()).hexdigest()[:32]
250
+
251
+
252
+ def parse_claim(content):
253
+ """Parse and statically validate a claim file (SPEC.md §7 steps 5-11)."""
254
+ if len(content.encode()) > MAX_CONTENT_BYTES:
255
+ raise Rejected("too-large", f"over {MAX_CONTENT_BYTES} bytes")
256
+ header = parse_header(content)
257
+ by, generated_at, stale_after, status = _fields(header)
258
+ validate_topic(header["topic"])
259
+ validate_check(header["check"])
260
+ url_parts(header["check"]["url"])
261
+ check_binding(header["topic"], header["check"]["url"])
262
+ return Claim(topic=header["topic"], check=header["check"], generated_by=by,
263
+ generated_at=generated_at, stale_after=stale_after, status=status,
264
+ header=header, content=content)
265
+
266
+
267
+ def claim_text(topic, kind, url, expect, pointer, hours, agent, now: datetime):
268
+ """Build a claim file. Values are written as JSON scalars, which YAML
269
+ reads back unchanged."""
270
+ z = lambda t: t.strftime("%Y-%m-%dT%H:%M:%SZ")
271
+ check = [f" kind: {json.dumps(kind)}", f" url: {json.dumps(url)}"]
272
+ if pointer is not None:
273
+ check.append(f" pointer: {json.dumps(pointer)}")
274
+ check.append(f" expect: {json.dumps(expect)}")
275
+ return ("---\ntype: Claim\n"
276
+ f"topic: {json.dumps(topic)}\n"
277
+ f"generated: {{ by: {json.dumps(agent)}, at: {z(now)} }}\n"
278
+ f"stale_after: {z(now + timedelta(hours=hours))}\n"
279
+ "check:\n" + "\n".join(check) + "\n---\n")
factmesh/cli.py ADDED
@@ -0,0 +1,181 @@
1
+ """Command line: check, sign, publish, and look up claims; run the MCP server;
2
+ show or rotate the key."""
3
+
4
+ import argparse
5
+ import asyncio
6
+ import json
7
+ import os
8
+ import sys
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+
12
+ from . import keys
13
+ from .check import run_check
14
+ from .claim import Rejected, parse_claim
15
+ from .fetch import Fetcher
16
+ from .receiver import Receiver
17
+ from .relay import RelayPool
18
+ from .reputation import Reputation
19
+ from .store import Store
20
+ from .validate import validate_event, validate_file
21
+
22
+ EXIT_CODES = {"pass": 0, "fail": 1, "rejected": 2, "error": 3, "retracted": 4}
23
+
24
+
25
+ def _now(value):
26
+ if value is None:
27
+ return datetime.now(timezone.utc)
28
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
29
+
30
+
31
+ def cmd_check(args):
32
+ text = Path(args.path).read_text()
33
+ now = _now(args.now)
34
+ if args.path.endswith(".json"):
35
+ doc = json.loads(text)
36
+ result = validate_event(doc.get("event", doc), now, Fetcher())
37
+ else:
38
+ result = validate_file(text, now, Fetcher())
39
+ line = result.result
40
+ if result.reason:
41
+ line += f": {result.reason}"
42
+ if result.detail:
43
+ line += f" ({result.detail})"
44
+ print(line)
45
+ return EXIT_CODES[result.result]
46
+
47
+
48
+ def cmd_sign(args):
49
+ try:
50
+ claim = parse_claim(Path(args.path).read_text())
51
+ except Rejected as e:
52
+ print(f"not signed: {e}", file=sys.stderr)
53
+ return EXIT_CODES["rejected"]
54
+ ev = keys.load_signer().claim_event(claim, int(claim.generated_at.timestamp()))
55
+ print(json.dumps(ev, indent=2, ensure_ascii=False))
56
+ return 0
57
+
58
+
59
+ def cmd_publish(args):
60
+ try:
61
+ claim = parse_claim(Path(args.path).read_text())
62
+ except Rejected as e:
63
+ print(f"not published: {e}", file=sys.stderr)
64
+ return EXIT_CODES["rejected"]
65
+ if claim.retracted:
66
+ print("publishing a retraction; its check is not run", file=sys.stderr)
67
+ else:
68
+ r = run_check(claim.topic, claim.check, Fetcher())
69
+ if r.result != "pass":
70
+ print(f"not published: own check gave {r.result} ({r.detail})", file=sys.stderr)
71
+ return EXIT_CODES[r.result]
72
+ ev = keys.load_signer().claim_event(claim, int(claim.generated_at.timestamp()))
73
+ status = asyncio.run(RelayPool(args.relay or None).publish(ev))
74
+ for url, s in status.items():
75
+ print(f"{url} {s}")
76
+ confirmed = sum(s == "confirmed" for s in status.values())
77
+ print(f"{confirmed} of {len(status)} relays confirmed event {ev['id']}")
78
+ return 0 if confirmed else EXIT_CODES["error"]
79
+
80
+
81
+ def cmd_lookup(args):
82
+ receiver = Receiver(RelayPool(args.relay or None), Store(), Reputation(), Fetcher())
83
+ found, stats = asyncio.run(receiver.lookup(args.topic))
84
+ print(json.dumps({"facts": found, "stats": stats.__dict__}, indent=2))
85
+ return 0
86
+
87
+
88
+ def cmd_seed(args):
89
+ from .event import Signer
90
+ from .seed import load_targets, run_seed
91
+ try:
92
+ targets = load_targets(args.packages)
93
+ except ValueError as e:
94
+ print(e, file=sys.stderr)
95
+ return 2
96
+ if args.key_env:
97
+ secret = os.environ.get(args.key_env, "").strip()
98
+ if len(secret) != 64:
99
+ print(f"{args.key_env} must hold a 64-character hex key", file=sys.stderr)
100
+ return 2
101
+ signer = Signer(bytes.fromhex(secret))
102
+ else:
103
+ signer = keys.load_signer()
104
+ report = asyncio.run(run_seed(targets, RelayPool(args.relay or None), signer, Fetcher(),
105
+ valid_hours=args.valid_hours,
106
+ refresh_hours=args.refresh_hours, dry_run=args.dry_run))
107
+ print(f"key {signer.pubkey}")
108
+ for topic, value in report.would_publish:
109
+ print(f"would publish {topic} = {value}")
110
+ unconfirmed = 0
111
+ for topic, value, status in report.published:
112
+ ok = sum(s == "confirmed" for s in status.values())
113
+ unconfirmed += ok == 0
114
+ print(f"published {topic} = {value} ({ok}/{len(status)} relays)")
115
+ for topic, why in report.skipped:
116
+ print(f"skipped {topic}: {why}")
117
+ print(f"{len(report.published)} published, {len(report.would_publish)} would publish, "
118
+ f"{len(report.fresh)} already current, {len(report.skipped)} skipped")
119
+ return 1 if unconfirmed else 0
120
+
121
+
122
+ def cmd_mcp(args):
123
+ from .mcp_server import main as serve
124
+ serve()
125
+ return 0
126
+
127
+
128
+ def cmd_key(args):
129
+ signer = keys.rotate() if args.rotate else keys.load_signer()
130
+ print(signer.pubkey)
131
+ return 0
132
+
133
+
134
+ def main(argv=None):
135
+ ap = argparse.ArgumentParser(prog="factmesh", description=__doc__)
136
+ sub = ap.add_subparsers(required=True)
137
+
138
+ p = sub.add_parser("check", help="validate a claim file (.md) or event (.json) "
139
+ "and run its check against the web")
140
+ p.add_argument("path")
141
+ p.add_argument("--now", help="override the current time (ISO 8601, UTC)")
142
+ p.set_defaults(func=cmd_check)
143
+
144
+ p = sub.add_parser("sign", help="sign a claim file with this installation's key")
145
+ p.add_argument("path")
146
+ p.set_defaults(func=cmd_sign)
147
+
148
+ p = sub.add_parser("publish", help="check a claim file yourself, sign it, and "
149
+ "send it to relays")
150
+ p.add_argument("path")
151
+ p.add_argument("--relay", action="append", help="relay URL (repeatable)")
152
+ p.set_defaults(func=cmd_publish)
153
+
154
+ p = sub.add_parser("lookup", help="fetch, verify, and print facts for a topic")
155
+ p.add_argument("topic")
156
+ p.add_argument("--relay", action="append", help="relay URL (repeatable)")
157
+ p.set_defaults(func=cmd_lookup)
158
+
159
+ p = sub.add_parser("seed", help="publish latest-version claims for a package list")
160
+ p.add_argument("--packages", default="seed/packages.toml")
161
+ p.add_argument("--key-env", help="environment variable holding the signing key (hex)")
162
+ p.add_argument("--valid-hours", type=int, default=24)
163
+ p.add_argument("--refresh-hours", type=int, default=6,
164
+ help="republish when the current claim expires within this many hours")
165
+ p.add_argument("--dry-run", action="store_true", help="show what would be published")
166
+ p.add_argument("--relay", action="append", help="relay URL (repeatable)")
167
+ p.set_defaults(func=cmd_seed)
168
+
169
+ p = sub.add_parser("mcp", help="run the MCP server on stdio, for agents")
170
+ p.set_defaults(func=cmd_mcp)
171
+
172
+ p = sub.add_parser("key", help="print this installation's public key")
173
+ p.add_argument("--rotate", action="store_true", help="replace it with a new key")
174
+ p.set_defaults(func=cmd_key)
175
+
176
+ args = ap.parse_args(argv)
177
+ return args.func(args)
178
+
179
+
180
+ if __name__ == "__main__":
181
+ sys.exit(main())
factmesh/config.py ADDED
@@ -0,0 +1,48 @@
1
+ """User settings, in <home>/config.toml. Created with defaults on first use so
2
+ the user can find and edit it."""
3
+
4
+ import tomllib
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+ from . import __version__
9
+ from .home import home, write_atomic
10
+ from .relay import DEFAULT_RELAYS
11
+
12
+ DEFAULT_TEXT = f'''# factmesh client settings.
13
+
14
+ # Let your agent publish claims to the network. Off by default: with it off,
15
+ # the agent can look up and verify facts but never sends anything.
16
+ publish = false
17
+
18
+ # Relays to read from and publish to.
19
+ relays = [
20
+ {"".join(f' "{r}",{chr(10)}' for r in DEFAULT_RELAYS)}]
21
+
22
+ # How published claims name their author. Name the software, never a person.
23
+ agent = "factmesh-mcp/{__version__}"
24
+ '''
25
+
26
+
27
+ @dataclass
28
+ class Config:
29
+ path: Path
30
+ publish: bool = False
31
+ relays: list = field(default_factory=lambda: list(DEFAULT_RELAYS))
32
+ agent: str = f"factmesh-mcp/{__version__}"
33
+
34
+
35
+ def load(path: Path | None = None) -> Config:
36
+ path = Path(path) if path else home() / "config.toml"
37
+ if not path.exists():
38
+ write_atomic(path, DEFAULT_TEXT.encode())
39
+ data = tomllib.loads(path.read_text())
40
+ cfg = Config(path=path)
41
+ if isinstance(data.get("publish"), bool):
42
+ cfg.publish = data["publish"]
43
+ if isinstance(data.get("relays"), list) and data["relays"]:
44
+ cfg.relays = [r for r in data["relays"]
45
+ if isinstance(r, str) and r.startswith(("wss://", "ws://"))]
46
+ if isinstance(data.get("agent"), str) and data["agent"].strip():
47
+ cfg.agent = data["agent"].strip()
48
+ return cfg