fusion-cli 1.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.
Files changed (46) hide show
  1. fusion/__init__.py +3 -0
  2. fusion/_skills/fusion-analyst/SKILL.md +50 -0
  3. fusion/_skills/fusion-analyst/references/assess.md +12 -0
  4. fusion/_skills/fusion-analyst/references/compare.md +12 -0
  5. fusion/_skills/fusion-analyst/references/export.md +18 -0
  6. fusion/_skills/fusion-analyst/references/fusion-conventions.md +149 -0
  7. fusion/_skills/fusion-analyst/references/report.md +13 -0
  8. fusion/_skills/fusion-analyst/scripts/export.py +64 -0
  9. fusion/_skills/fusion-intake/SKILL.md +128 -0
  10. fusion/_skills/fusion-intake/references/convert.md +104 -0
  11. fusion/_skills/fusion-intake/references/delivery.md +107 -0
  12. fusion/_skills/fusion-intake/references/fusion-conventions.md +149 -0
  13. fusion/_skills/fusion-intake/references/gate.md +107 -0
  14. fusion/_skills/fusion-intake/scripts/convert.py +836 -0
  15. fusion/_skills/fusion-intake/scripts/gate.py +267 -0
  16. fusion/_skills/fusion-librarian/SKILL.md +64 -0
  17. fusion/_skills/fusion-librarian/references/archive.md +21 -0
  18. fusion/_skills/fusion-librarian/references/create.md +14 -0
  19. fusion/_skills/fusion-librarian/references/cross-reference.md +45 -0
  20. fusion/_skills/fusion-librarian/references/fusion-conventions.md +149 -0
  21. fusion/_skills/fusion-librarian/references/promote.md +24 -0
  22. fusion/_skills/fusion-librarian/references/query.md +17 -0
  23. fusion/_skills/fusion-librarian/references/reflect.md +47 -0
  24. fusion/_skills/fusion-librarian/references/restructure.md +20 -0
  25. fusion/_skills/fusion-librarian/references/tag.md +13 -0
  26. fusion/_skills/fusion-librarian/scripts/link-repair.py +371 -0
  27. fusion/_skills/fusion-planner/SKILL.md +62 -0
  28. fusion/_skills/fusion-planner/references/close.md +12 -0
  29. fusion/_skills/fusion-planner/references/create-activity.md +38 -0
  30. fusion/_skills/fusion-planner/references/fusion-conventions.md +149 -0
  31. fusion/_skills/fusion-planner/references/horizon.md +20 -0
  32. fusion/bucket.py +77 -0
  33. fusion/checker.py +248 -0
  34. fusion/cli.py +406 -0
  35. fusion/document.py +155 -0
  36. fusion/hub.py +78 -0
  37. fusion/indexer.py +75 -0
  38. fusion/ledger.py +106 -0
  39. fusion/manifest.py +33 -0
  40. fusion/scaffold.py +120 -0
  41. fusion/setup.py +300 -0
  42. fusion/views.py +111 -0
  43. fusion_cli-1.1.0.dist-info/METADATA +67 -0
  44. fusion_cli-1.1.0.dist-info/RECORD +46 -0
  45. fusion_cli-1.1.0.dist-info/WHEEL +4 -0
  46. fusion_cli-1.1.0.dist-info/entry_points.txt +2 -0
fusion/cli.py ADDED
@@ -0,0 +1,406 @@
1
+ """fusion — the notary. Records, checks, composes; never judges."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import sys
7
+ from dataclasses import asdict
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+
11
+ from . import __version__, bucket, checker, hub, indexer, ledger, scaffold, views
12
+
13
+
14
+ # ── plumbing ─────────────────────────────────────────────────────────────
15
+
16
+ def _fail(message: str, as_json: bool = False) -> int:
17
+ if as_json:
18
+ print(json.dumps({"ok": False, "error": message}, ensure_ascii=False))
19
+ else:
20
+ print(f"fusion: {message}", file=sys.stderr)
21
+ return 1
22
+
23
+
24
+ def _emit(payload, as_json: bool, human: str) -> None:
25
+ if as_json:
26
+ print(json.dumps(payload, ensure_ascii=False, indent=2, default=str))
27
+ elif human:
28
+ print(human)
29
+
30
+
31
+ def _root_from(args, attr: str = "path") -> Path | None:
32
+ """Resolve the bucket root from a positional path / --bucket / cwd."""
33
+ explicit = getattr(args, attr, None)
34
+ if explicit:
35
+ root = Path(explicit).expanduser()
36
+ return root if root.is_dir() else None
37
+ return bucket.find_root(Path.cwd())
38
+
39
+
40
+ def _parse_at(raw: str | None) -> datetime | None:
41
+ return datetime.strptime(raw, "%Y-%m-%d %H:%M") if raw else None
42
+
43
+
44
+ # ── commands ─────────────────────────────────────────────────────────────
45
+
46
+ def cmd_new(args) -> int:
47
+ actor = ledger.resolve_actor(args.as_)
48
+ try:
49
+ root, warnings = scaffold.new_bucket(
50
+ Path(args.path), name=args.name, kind=args.kind,
51
+ description=args.description, actor=actor,
52
+ )
53
+ except scaffold.ScaffoldError as exc:
54
+ return _fail(str(exc), args.json)
55
+ name = args.name or root.name
56
+ for w in warnings:
57
+ print(f"warning: {w}", file=sys.stderr)
58
+ _emit(
59
+ {"bucket": name, "path": str(root), "warnings": warnings},
60
+ args.json,
61
+ f"bucket '{name}' born at {root} — registered in the hub. Go live in it.",
62
+ )
63
+ return 0
64
+
65
+
66
+ def cmd_hub(args) -> int:
67
+ if args.hub_cmd == "add":
68
+ root = Path(args.path).expanduser().resolve()
69
+ b = bucket.load(root)
70
+ if b.frontmatter is None:
71
+ return _fail(f"not a bucket (no readable BUCKET.md): {root}", args.json)
72
+ missing = [f for f in ("name", "kind", "description")
73
+ if not str(b.frontmatter.get(f) or "").strip()]
74
+ if missing:
75
+ return _fail(f"BUCKET.md missing: {', '.join(missing)}", args.json)
76
+ try:
77
+ hub.add(hub.HubEntry(b.name, b.kind, hub.display_path(root),
78
+ b.description))
79
+ except ValueError as exc:
80
+ return _fail(str(exc), args.json)
81
+ _emit({"registered": b.name}, args.json,
82
+ f"'{b.name}' registered in the hub.")
83
+ return 0
84
+ if args.hub_cmd == "remove":
85
+ if not hub.remove(args.name):
86
+ return _fail(f"no bucket named '{args.name}' in the hub", args.json)
87
+ _emit({"removed": args.name}, args.json,
88
+ f"'{args.name}' retired from the hub. The files live on.")
89
+ return 0
90
+ entries = hub.load()
91
+ human = "\n".join(
92
+ f"- **{e.name}** · {e.kind} · {e.path} — {e.description}"
93
+ for e in entries
94
+ ) or "the hub is empty — `fusion new` a bucket."
95
+ _emit([asdict(e) for e in entries], args.json, human)
96
+ return 0
97
+
98
+
99
+ def cmd_log(args) -> int:
100
+ root = _root_from(args, "bucket")
101
+ if root is None or not (root / "BUCKET.md").is_file():
102
+ given = getattr(args, "bucket", None)
103
+ return _fail(f"no bucket at: {given}" if given else
104
+ "not inside a bucket (no BUCKET.md found) — use --bucket",
105
+ args.json)
106
+ if args.verb is None:
107
+ entries = views.filter_since(ledger.read(root), args.since)
108
+ human = "\n".join(
109
+ f"{e.date} {ledger.format_line(e)[2:]}" for e in entries
110
+ ) or "the ledger is empty — nothing has happened yet."
111
+ _emit([asdict(e) for e in entries], args.json, human)
112
+ return 0
113
+ if args.object is None:
114
+ return _fail("log needs an object: fusion log <verb> <object>", args.json)
115
+ actor = ledger.resolve_actor(args.as_)
116
+ try:
117
+ entry = ledger.append(root, actor, args.verb, args.object,
118
+ note=args.note, at=_parse_at(args.at))
119
+ except ValueError as exc:
120
+ return _fail(str(exc), args.json)
121
+ _emit(asdict(entry), args.json,
122
+ f"logged: {entry.date} {ledger.format_line(entry)[2:]}")
123
+ return 0
124
+
125
+
126
+ def cmd_index(args) -> int:
127
+ root = _root_from(args)
128
+ if root is None or not (root / "BUCKET.md").is_file():
129
+ given = getattr(args, "path", None)
130
+ return _fail(f"no bucket at: {given}" if given else
131
+ "not inside a bucket (no BUCKET.md found)", args.json)
132
+ actor = ledger.resolve_actor(args.as_)
133
+ results = indexer.write_indexes(root, actor=actor)
134
+ human = "\n".join(
135
+ f"{r['zone']}/ — {r['documents']} documents — "
136
+ + ("regenerated" if r["changed"] else "unchanged")
137
+ for r in results
138
+ )
139
+ _emit(results, args.json, human)
140
+ return 0
141
+
142
+
143
+ def cmd_check(args) -> int:
144
+ root = _root_from(args)
145
+ if root is None:
146
+ given = getattr(args, "path", None)
147
+ return _fail(f"no bucket at: {given}" if given else
148
+ "not inside a bucket and no path given", args.json)
149
+ findings = checker.check(root)
150
+ errors = [f for f in findings if f.level == "error"]
151
+ warnings = [f for f in findings if f.level == "warning"]
152
+ name = bucket.load(root).name or root.name
153
+ if args.json:
154
+ _emit(
155
+ {
156
+ "bucket": name,
157
+ "ok": not errors,
158
+ "errors": [asdict(f) for f in errors],
159
+ "warnings": [asdict(f) for f in warnings],
160
+ },
161
+ True, "",
162
+ )
163
+ else:
164
+ for f in findings:
165
+ print(f" {f.code} · {f.path} — {f.message}")
166
+ verdict = (
167
+ "clean, carry on." if not findings
168
+ else "the notary objects." if errors
169
+ else "drift, not damage."
170
+ )
171
+ plural_e = "" if len(errors) == 1 else "s"
172
+ plural_w = "" if len(warnings) == 1 else "s"
173
+ print(f"{name}: {len(errors)} error{plural_e} · "
174
+ f"{len(warnings)} warning{plural_w} — {verdict}")
175
+ return 1 if errors else 0
176
+
177
+
178
+ def cmd_status(args) -> int:
179
+ root = _root_from(args)
180
+ if root is None or not (root / "BUCKET.md").is_file():
181
+ given = getattr(args, "path", None)
182
+ return _fail(f"no bucket at: {given}" if given else
183
+ "not inside a bucket (no BUCKET.md found)", args.json)
184
+ s = views.status(root, since=args.since)
185
+ lines = [f"{s['bucket']} — {s['documents']} documents"]
186
+ for label in ("auroras", "types", "activities"):
187
+ if s[label]:
188
+ pairs = " · ".join(f"{k} {v}" for k, v in s[label].items())
189
+ lines.append(f" {label}: {pairs}")
190
+ if s["ledger"]:
191
+ lines.append(" recent:")
192
+ lines += [
193
+ f" {e['date']} {e['time']} · {e['actor']} · {e['verb']} · {e['obj']}"
194
+ for e in s["ledger"][-5:]
195
+ ]
196
+ _emit(s, args.json, "\n".join(lines))
197
+ return 0
198
+
199
+
200
+ def _render_items(items) -> list[str]:
201
+ return [
202
+ f" · [{i['bucket']}] {i['title']} — {i['path']}"
203
+ + (f" ({i['status']})" if i["status"] else "")
204
+ for i in items
205
+ ]
206
+
207
+
208
+ def cmd_today(args) -> int:
209
+ t = views.today()
210
+ lines = [f"Today across {len(t['buckets'])} bucket"
211
+ f"{'s' if len(t['buckets']) != 1 else ''}"]
212
+ for aurora, items in t["groups"].items():
213
+ lines.append(f" {aurora}:")
214
+ lines += _render_items(items)
215
+ if not t["groups"]:
216
+ lines.append(" nothing demands you — the wide-open day.")
217
+ _emit(t, args.json, "\n".join(lines))
218
+ return 0
219
+
220
+
221
+ def cmd_agenda(args) -> int:
222
+ a = views.agenda()
223
+ lines = []
224
+ if a["dated"]:
225
+ lines.append("dated:")
226
+ lines += [
227
+ f" {i['date']} · [{i['bucket']}] {i['title']} — {i['path']}"
228
+ for i in a["dated"]
229
+ ]
230
+ if a["active"]:
231
+ lines.append("active, undated:")
232
+ lines += _render_items(a["active"])
233
+ if not lines:
234
+ lines = ["the horizon is clear."]
235
+ _emit(a, args.json, "\n".join(lines))
236
+ return 0
237
+
238
+
239
+ def cmd_setup(args) -> int:
240
+ from fusion import setup as setup_mod
241
+ import os as _os
242
+ home = Path.home()
243
+ skills_dir = Path(
244
+ args.skills_dir
245
+ or _os.environ.get("FUSION_SKILLS_DIR")
246
+ or home / ".agents" / "skills").expanduser()
247
+ no_agents = args.no_agents or _os.environ.get("FUSION_NO_AGENTS") == "1"
248
+ try:
249
+ report = setup_mod.run_setup(home, skills_dir, args.force,
250
+ no_agents, args.remove)
251
+ except setup_mod.SetupError as exc:
252
+ return _fail(str(exc), args.json)
253
+ lines = _render_setup(report)
254
+ _emit(report, args.json, "\n".join(lines))
255
+ return 0
256
+
257
+
258
+ def _render_setup(report: dict) -> list[str]:
259
+ lines = []
260
+ if "removed" in report:
261
+ for r in report["removed"]:
262
+ lines.append(f" {r['action']:>10} {r['skill']} ({r['agent']})")
263
+ lines.append("done. final step: " + report["next"][0])
264
+ return lines
265
+ lines.append(f"fusion-cli {report['cli']['version']}")
266
+ lines.append(f"skills → {report['skills']['dir']}")
267
+ for r in report["skills"]["results"]:
268
+ lines.append(f" {r['action']:>10} {r['skill']}")
269
+ for a in report["agents"]:
270
+ lines.append(f" {a['agent']}: {a['action']} — {a['detail']}")
271
+ for adv in report["advice"]:
272
+ lines.append(f" advice: {adv['text']}")
273
+ lines.append("next: " + " · ".join(report["next"]))
274
+ return lines
275
+
276
+
277
+ # ── parser ───────────────────────────────────────────────────────────────
278
+
279
+ def build_parser() -> argparse.ArgumentParser:
280
+ parser = argparse.ArgumentParser(
281
+ prog="fusion",
282
+ description="Fusion — the notary. Records, checks, composes; never judges.",
283
+ )
284
+ parser.add_argument("--version", action="version",
285
+ version=f"fusion {__version__}")
286
+ sub = parser.add_subparsers(dest="command")
287
+
288
+ def flag_json(p):
289
+ p.add_argument("--json", action="store_true",
290
+ help="machine output — agents parse, never scrape")
291
+
292
+ def flag_as(p):
293
+ p.add_argument("--as", dest="as_", metavar="ACTOR",
294
+ help="who holds the pen (default: FUSION_ACTOR, then "
295
+ "the OS username)")
296
+
297
+ p = sub.add_parser("new", help="scaffold a bucket and register it", description="Scaffold a conformant bucket at PATH — six zones, BUCKET.md, an opened ledger — and register it in the hub.")
298
+ p.add_argument("path", help="directory to create (missing or empty)")
299
+ p.add_argument("--name", help="bucket name (default: directory name)")
300
+ p.add_argument("--kind", default="personal",
301
+ help="personal · company · studio · club · …")
302
+ p.add_argument("--description",
303
+ help="one line on what this bucket is for (lands in "
304
+ "BUCKET.md and the hub)")
305
+ flag_as(p); flag_json(p)
306
+ p.set_defaults(func=cmd_new)
307
+
308
+ p = sub.add_parser("hub", help="list, register, or retire buckets", description="The registry at ~/.fusion/hub.md (override: FUSION_HUB) — the agent's map of your buckets. No subcommand: list.")
309
+ hub_sub = p.add_subparsers(dest="hub_cmd")
310
+ flag_json(p)
311
+ pa = hub_sub.add_parser("add", help="register an existing bucket", description="Register an existing bucket (reads its BUCKET.md).")
312
+ pa.add_argument("path", help="bucket root to register")
313
+ flag_json(pa)
314
+ pr = hub_sub.add_parser("remove", help="retire a bucket from the hub", description="Retire a bucket from the hub. Files stay where they are.")
315
+ pr.add_argument("name", help="registered bucket name")
316
+ flag_json(pr)
317
+ p.set_defaults(func=cmd_hub, hub_cmd=None)
318
+ pa.set_defaults(func=cmd_hub, hub_cmd="add")
319
+ pr.set_defaults(func=cmd_hub, hub_cmd="remove")
320
+
321
+ p = sub.add_parser("log", help="append to the ledger, or read it", description="Append one signed entry to the append-only ledger — or, with no arguments, read it.", epilog="Read mode: 'fusion log' alone prints the ledger; --since DATE or --since last-reflection narrows it. The bucket resolves from --bucket, else by walking up from the current directory.")
322
+ p.add_argument("verb", nargs="?",
323
+ help=f"one of: {', '.join(ledger.VERBS)}")
324
+ p.add_argument("object", nargs="?", help="what was acted on — a path, or 'a → b' for a move")
325
+ p.add_argument("--note")
326
+ p.add_argument("--at", metavar="'YYYY-MM-DD HH:MM'",
327
+ help="entry timestamp (default: now)")
328
+ p.add_argument("--since", metavar="DATE|last-reflection",
329
+ help="read mode: entries since a date or the last reflection")
330
+ p.add_argument("--bucket", help="bucket root (default: walk up from cwd)")
331
+ flag_as(p); flag_json(p)
332
+ p.set_defaults(func=cmd_log)
333
+
334
+ p = sub.add_parser("index", help="regenerate INDEX.md in library/ and activities/", description="Regenerate library/INDEX.md and activities/INDEX.md from the documents on disk. Deterministic: same tree, same bytes.")
335
+ p.add_argument("path", nargs="?", help="bucket root (default: walk up from the current directory)")
336
+ flag_as(p); flag_json(p)
337
+ p.set_defaults(func=cmd_index)
338
+
339
+ p = sub.add_parser("check", help="audit a bucket against the convention", description="Audit a bucket against the convention (SPEC §11). Exit 1 on errors; warnings never fail the check.")
340
+ p.add_argument("path", nargs="?", help="bucket root (default: walk up from the current directory)")
341
+ flag_json(p)
342
+ p.set_defaults(func=cmd_check)
343
+
344
+ p = sub.add_parser("status", help="one bucket at a glance", description="One bucket at a glance: documents per zone, active work, the ledger's recent tail.")
345
+ p.add_argument("path", nargs="?", help="bucket root (default: walk up from the current directory)")
346
+ p.add_argument("--since", metavar="DATE|last-reflection")
347
+ flag_json(p)
348
+ p.set_defaults(func=cmd_status)
349
+
350
+ p = sub.add_parser("today", help="the composed day, across the hub", description="The composed morning across every hub bucket: commitments and active work, grouped by aurora.")
351
+ flag_json(p)
352
+ p.set_defaults(func=cmd_today)
353
+
354
+ p = sub.add_parser("agenda", help="the wider horizon, across the hub", description="The wider horizon across the hub: dated items first (due:/date:), then active work.")
355
+ flag_json(p)
356
+ p.set_defaults(func=cmd_agenda)
357
+
358
+ p = sub.add_parser("setup", help="install skills into agents; --remove undoes it",
359
+ description="Install the bundled skills to the canonical "
360
+ "skills directory and link them into every detected agent. "
361
+ "Idempotent; never destroys what it did not create.")
362
+ p.add_argument("--force", action="store_true",
363
+ help="replace foreign entries setup would otherwise leave")
364
+ p.add_argument("--remove", action="store_true",
365
+ help="undo setup (attribution-checked), print the uninstall closer")
366
+ p.add_argument("--skills-dir", help="canonical destination (default ~/.agents/skills; env FUSION_SKILLS_DIR)")
367
+ p.add_argument("--no-agents", action="store_true",
368
+ help="skip agent fan-out (env FUSION_NO_AGENTS=1)")
369
+ flag_json(p)
370
+ p.set_defaults(func=cmd_setup)
371
+
372
+ return parser
373
+
374
+
375
+ def main(argv: list[str] | None = None) -> int:
376
+ parser = build_parser()
377
+ args = parser.parse_args(argv)
378
+ if not getattr(args, "func", None):
379
+ parser.print_help()
380
+ return 0
381
+ try:
382
+ return args.func(args)
383
+ except SystemExit:
384
+ raise
385
+ except Exception as exc: # the notary reports surprises, never crashes bare
386
+ return _fail(f"unexpected: {type(exc).__name__}: {exc}",
387
+ getattr(args, "json", False))
388
+
389
+
390
+ def _utf8_streams() -> None:
391
+ """Reconfigure stdout/stderr to UTF-8. Windows consoles default to a
392
+ legacy codepage (cp1252, etc.) that can't encode arrows, mid-dots, and
393
+ other prose characters the CLI's output contract relies on. Guarded:
394
+ detached or otherwise odd streams (embedded contexts, redirected
395
+ pipes without a reconfigure method) must never crash the CLI."""
396
+ for _stream in (sys.stdout, sys.stderr):
397
+ if hasattr(_stream, "reconfigure"):
398
+ try:
399
+ _stream.reconfigure(encoding="utf-8", errors="replace")
400
+ except (ValueError, OSError):
401
+ pass
402
+
403
+
404
+ def main_entry() -> None: # console-script shim
405
+ _utf8_streams()
406
+ sys.exit(main())
fusion/document.py ADDED
@@ -0,0 +1,155 @@
1
+ """Read Fusion documents — the liberal reader (SPEC §0, §4)."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+ import yaml
9
+
10
+ AURORAS: tuple[str, ...] = (
11
+ "commitments", "focus", "ops", "collab",
12
+ "life", "explore", "archive", "library",
13
+ )
14
+
15
+ FILENAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*\.md$")
16
+ MAX_STEM = 60
17
+
18
+ _LINK_RE = re.compile(r"\[[^\]]*\]\(([^)]+)\)")
19
+ _EXTERNAL = ("http://", "https://", "mailto:", "#")
20
+ _FENCE_OPEN_RE = re.compile(r"^(\s*)(`{3,}|~{3,})")
21
+ _INLINE_CODE_RE = re.compile(r"(`+)((?:(?!\1)[^\n])*?)\1")
22
+
23
+
24
+ def _blank_code(body: str) -> str:
25
+ """Blank fenced blocks and inline code spans so link scanning never
26
+ reads code as prose (SPEC's W4 is about prose links, not examples).
27
+ Fenced blocks — a ``` or ~~~ opener to its closer inclusive — are
28
+ replaced with equal-length whitespace, line by line; an unterminated
29
+ fence is liberal by design and swallows to end of file rather than
30
+ risk a false-positive broken link. Inline `` `...` `` spans on a
31
+ single line are blanked the same way, non-greedy — the delimiter is
32
+ a run of N backticks (CommonMark's literal-backtick idiom, e.g.
33
+ `` `` `code with ` backtick` `` ``), closing only on the next run of
34
+ the SAME length, so a shorter backtick run inside the span is just
35
+ content, not a terminator."""
36
+ lines = body.split("\n")
37
+ out = []
38
+ i = 0
39
+ n = len(lines)
40
+ while i < n:
41
+ opener = _FENCE_OPEN_RE.match(lines[i])
42
+ if not opener:
43
+ out.append(lines[i])
44
+ i += 1
45
+ continue
46
+ fence_char, fence_len = opener.group(2)[0], len(opener.group(2))
47
+ closer_re = re.compile(
48
+ r"^\s*" + re.escape(fence_char) + "{" + str(fence_len) + ",}\\s*$"
49
+ )
50
+ out.append(" " * len(lines[i]))
51
+ i += 1
52
+ while i < n:
53
+ out.append(" " * len(lines[i]))
54
+ closed = bool(closer_re.match(lines[i]))
55
+ i += 1
56
+ if closed:
57
+ break
58
+ blanked = "\n".join(out)
59
+ return _INLINE_CODE_RE.sub(lambda m: " " * len(m.group(0)), blanked)
60
+
61
+
62
+ @dataclass
63
+ class Document:
64
+ path: Path
65
+ frontmatter: dict | None
66
+ fm_error: str | None
67
+ summary_first: bool
68
+ summary_line: str | None
69
+ links: list[str] = field(default_factory=list)
70
+
71
+ def _get(self, key: str):
72
+ return (self.frontmatter or {}).get(key)
73
+
74
+ @property
75
+ def title(self):
76
+ return self._get("title")
77
+
78
+ @property
79
+ def type(self):
80
+ return self._get("type")
81
+
82
+ @property
83
+ def aurora(self):
84
+ return self._get("aurora")
85
+
86
+ @property
87
+ def status(self):
88
+ return self._get("status")
89
+
90
+
91
+ def split_frontmatter(text: str) -> tuple[dict | None, str | None, str]:
92
+ """Return (frontmatter, error, body). Liberal: never raises."""
93
+ if not text.startswith("---\n"):
94
+ return None, "no frontmatter block", text
95
+ end = text.find("\n---\n", 3)
96
+ if end == -1:
97
+ return None, "unterminated frontmatter block", text
98
+ raw, body = text[4 : end + 1], text[end + 5 :]
99
+ try:
100
+ data = yaml.safe_load(raw)
101
+ except yaml.YAMLError as exc:
102
+ return None, f"invalid YAML: {exc}", body
103
+ if not isinstance(data, dict):
104
+ return None, "frontmatter is not a mapping", body
105
+ return data, None, body
106
+
107
+
108
+ def _summary(body: str) -> tuple[bool, str | None]:
109
+ """Summary-first per SPEC §4: '## Summary', ≥1 line, then a '---' line."""
110
+ lines = body.split("\n")
111
+ i = 0
112
+ while i < len(lines) and not lines[i].strip():
113
+ i += 1
114
+ if i == len(lines) or lines[i].strip() != "## Summary":
115
+ return False, None
116
+ first: str | None = None
117
+ for line in lines[i + 1 :]:
118
+ stripped = line.strip()
119
+ if stripped == "---":
120
+ return first is not None, first
121
+ if stripped and first is None:
122
+ first = line
123
+ return False, first
124
+
125
+
126
+ def _links(body: str) -> list[str]:
127
+ out = []
128
+ for target in _LINK_RE.findall(_blank_code(body)):
129
+ if not target.startswith(_EXTERNAL):
130
+ out.append(target)
131
+ return out
132
+
133
+
134
+ def read_document(path: Path) -> Document:
135
+ try:
136
+ text = path.read_text(encoding="utf-8-sig")
137
+ except (OSError, UnicodeDecodeError) as exc:
138
+ return Document(
139
+ path=path,
140
+ frontmatter=None,
141
+ fm_error=f"unreadable file: {exc}",
142
+ summary_first=False,
143
+ summary_line=None,
144
+ links=[],
145
+ )
146
+ fm, fm_error, body = split_frontmatter(text)
147
+ summary_first, summary_line = _summary(body)
148
+ return Document(
149
+ path=path,
150
+ frontmatter=fm,
151
+ fm_error=fm_error,
152
+ summary_first=summary_first,
153
+ summary_line=summary_line,
154
+ links=_links(body),
155
+ )
fusion/hub.py ADDED
@@ -0,0 +1,78 @@
1
+ """~/.fusion/hub.md — the machine's registry of buckets (SPEC §1)."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import re
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ _ENTRY_RE = re.compile(r"^- \*\*(.+?)\*\* · ([^·]+?) · (.+?) — (.*)$")
10
+
11
+
12
+ @dataclass
13
+ class HubEntry:
14
+ name: str
15
+ kind: str
16
+ path: str
17
+ description: str
18
+
19
+
20
+ def hub_path() -> Path:
21
+ return Path(os.environ.get("FUSION_HUB", "~/.fusion/hub.md")).expanduser()
22
+
23
+
24
+ def display_path(root: Path) -> str:
25
+ """Absolute path, ~-contracted when under the user's home."""
26
+ try:
27
+ return "~/" + root.relative_to(Path.home()).as_posix()
28
+ except ValueError:
29
+ return str(root)
30
+
31
+
32
+ def load() -> list[HubEntry]:
33
+ path = hub_path()
34
+ if not path.exists():
35
+ return []
36
+ entries = []
37
+ for line in path.read_text(encoding="utf-8").split("\n"):
38
+ m = _ENTRY_RE.match(line)
39
+ if m:
40
+ entries.append(HubEntry(*(g.strip() for g in m.groups())))
41
+ return entries
42
+
43
+
44
+ def _one_line(value: str) -> str:
45
+ return " ".join(str(value).split())
46
+
47
+
48
+ def save(entries: list[HubEntry]) -> None:
49
+ path = hub_path()
50
+ path.parent.mkdir(parents=True, exist_ok=True)
51
+ lines = ["# Fusion Hub", ""]
52
+ lines += [
53
+ f"- **{_one_line(e.name)}** · {_one_line(e.kind)} · "
54
+ f"{_one_line(e.path)} — {_one_line(e.description)}"
55
+ for e in entries
56
+ ]
57
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n")
58
+
59
+
60
+ def add(entry: HubEntry) -> None:
61
+ entries = load()
62
+ if any(e.name == entry.name for e in entries):
63
+ raise ValueError(f"bucket '{entry.name}' is already registered")
64
+ entries.append(entry)
65
+ save(entries)
66
+
67
+
68
+ def remove(name: str) -> bool:
69
+ entries = load()
70
+ kept = [e for e in entries if e.name != name]
71
+ if len(kept) == len(entries):
72
+ return False
73
+ save(kept)
74
+ return True
75
+
76
+
77
+ def resolve(entry: HubEntry) -> Path:
78
+ return Path(entry.path).expanduser()