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.
- fusion/__init__.py +3 -0
- fusion/_skills/fusion-analyst/SKILL.md +50 -0
- fusion/_skills/fusion-analyst/references/assess.md +12 -0
- fusion/_skills/fusion-analyst/references/compare.md +12 -0
- fusion/_skills/fusion-analyst/references/export.md +18 -0
- fusion/_skills/fusion-analyst/references/fusion-conventions.md +149 -0
- fusion/_skills/fusion-analyst/references/report.md +13 -0
- fusion/_skills/fusion-analyst/scripts/export.py +64 -0
- fusion/_skills/fusion-intake/SKILL.md +128 -0
- fusion/_skills/fusion-intake/references/convert.md +104 -0
- fusion/_skills/fusion-intake/references/delivery.md +107 -0
- fusion/_skills/fusion-intake/references/fusion-conventions.md +149 -0
- fusion/_skills/fusion-intake/references/gate.md +107 -0
- fusion/_skills/fusion-intake/scripts/convert.py +836 -0
- fusion/_skills/fusion-intake/scripts/gate.py +267 -0
- fusion/_skills/fusion-librarian/SKILL.md +64 -0
- fusion/_skills/fusion-librarian/references/archive.md +21 -0
- fusion/_skills/fusion-librarian/references/create.md +14 -0
- fusion/_skills/fusion-librarian/references/cross-reference.md +45 -0
- fusion/_skills/fusion-librarian/references/fusion-conventions.md +149 -0
- fusion/_skills/fusion-librarian/references/promote.md +24 -0
- fusion/_skills/fusion-librarian/references/query.md +17 -0
- fusion/_skills/fusion-librarian/references/reflect.md +47 -0
- fusion/_skills/fusion-librarian/references/restructure.md +20 -0
- fusion/_skills/fusion-librarian/references/tag.md +13 -0
- fusion/_skills/fusion-librarian/scripts/link-repair.py +371 -0
- fusion/_skills/fusion-planner/SKILL.md +62 -0
- fusion/_skills/fusion-planner/references/close.md +12 -0
- fusion/_skills/fusion-planner/references/create-activity.md +38 -0
- fusion/_skills/fusion-planner/references/fusion-conventions.md +149 -0
- fusion/_skills/fusion-planner/references/horizon.md +20 -0
- fusion/bucket.py +77 -0
- fusion/checker.py +248 -0
- fusion/cli.py +406 -0
- fusion/document.py +155 -0
- fusion/hub.py +78 -0
- fusion/indexer.py +75 -0
- fusion/ledger.py +106 -0
- fusion/manifest.py +33 -0
- fusion/scaffold.py +120 -0
- fusion/setup.py +300 -0
- fusion/views.py +111 -0
- fusion_cli-1.1.0.dist-info/METADATA +67 -0
- fusion_cli-1.1.0.dist-info/RECORD +46 -0
- fusion_cli-1.1.0.dist-info/WHEEL +4 -0
- 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()
|