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/indexer.py ADDED
@@ -0,0 +1,75 @@
1
+ """INDEX.md generation — two implementations, identical bytes (SPEC §8)."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+
7
+ from . import ledger
8
+ from .document import read_document
9
+
10
+ MARKER = "<!-- generated by fusion index — do not edit -->"
11
+ INDEXED_ZONES: tuple[str, ...] = ("library", "activities")
12
+
13
+
14
+ def _section_key(section: str) -> tuple[int, str]:
15
+ if section == "./":
16
+ return (0, section)
17
+ if section.startswith("archive/"):
18
+ return (2, section)
19
+ return (1, section)
20
+
21
+
22
+ def _zone_documents(zone_dir: Path) -> list[Path]:
23
+ """Every document file in the zone — the single filter both the index
24
+ content and the ledger's document count derive from."""
25
+ return [
26
+ p
27
+ for p in sorted(zone_dir.rglob("*.md"))
28
+ if p.name != "INDEX.md"
29
+ and not any(part.startswith(".")
30
+ for part in p.relative_to(zone_dir).parts)
31
+ ]
32
+
33
+
34
+ def generate(zone_dir: Path, zone_name: str) -> str:
35
+ sections: dict[str, list] = {}
36
+ for path in _zone_documents(zone_dir):
37
+ rel = path.relative_to(zone_dir)
38
+ parent = rel.parent.as_posix()
39
+ section = "./" if parent == "." else parent + "/"
40
+ sections.setdefault(section, []).append((rel, read_document(path)))
41
+
42
+ lines = [MARKER, f"# {zone_name.capitalize()} Index"]
43
+ for section in sorted(sections, key=_section_key):
44
+ lines += ["", f"## {section}", ""]
45
+ for rel, doc in sorted(sections[section], key=lambda t: t[0].as_posix()):
46
+ title = doc.title or rel.stem
47
+ summary = doc.summary_line or ""
48
+ aurora = doc.aurora or ""
49
+ lines.append(f"- [{title}]({rel.as_posix()}) — {summary} ({aurora})")
50
+ return "\n".join(lines) + "\n"
51
+
52
+
53
+ def write_indexes(
54
+ bucket_root: Path,
55
+ actor: str | None = None,
56
+ at: datetime | None = None,
57
+ ) -> list[dict]:
58
+ results = []
59
+ for zone in INDEXED_ZONES:
60
+ zone_dir = bucket_root / zone
61
+ if not zone_dir.is_dir():
62
+ continue
63
+ content = generate(zone_dir, zone)
64
+ index_path = zone_dir / "INDEX.md"
65
+ old = index_path.read_bytes() if index_path.exists() else None
66
+ changed = old != content.encode("utf-8")
67
+ if changed:
68
+ index_path.write_text(content, encoding="utf-8", newline="\n")
69
+ count = len(_zone_documents(zone_dir))
70
+ if changed and actor:
71
+ plural = "s" if count != 1 else ""
72
+ ledger.append(bucket_root, actor, "indexed",
73
+ f"{zone}/ ({count} document{plural})", at=at)
74
+ results.append({"zone": zone, "documents": count, "changed": changed})
75
+ return results
fusion/ledger.py ADDED
@@ -0,0 +1,106 @@
1
+ """LEDGER.md — append-only, chronological, exactly one writer (SPEC §6)."""
2
+ from __future__ import annotations
3
+
4
+ import getpass
5
+ import os
6
+ import re
7
+ from dataclasses import dataclass
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+
11
+ VERBS: tuple[str, ...] = (
12
+ "created", "converted", "classified", "indexed", "moved",
13
+ "promoted", "archived", "restructured", "shipped", "reflected",
14
+ "noted",
15
+ )
16
+
17
+ _ENTRY_RE = re.compile(r"^- (\d{2}:\d{2}) · (\S+) · (\S+) · (.*)$")
18
+ _NOTE_SEP = ' — "'
19
+
20
+
21
+ @dataclass
22
+ class Entry:
23
+ date: str
24
+ time: str
25
+ actor: str
26
+ verb: str
27
+ obj: str
28
+ note: str | None = None
29
+
30
+
31
+ def parse(text: str) -> list[Entry]:
32
+ """Liberal reader: lines that don't match the grammar are ignored."""
33
+ entries: list[Entry] = []
34
+ date: str | None = None
35
+ for line in text.split("\n"):
36
+ if line.startswith("## "):
37
+ date = line[3:].strip()
38
+ continue
39
+ m = _ENTRY_RE.match(line)
40
+ if not (m and date):
41
+ continue
42
+ obj, note = m.group(4), None
43
+ if _NOTE_SEP in obj and obj.endswith('"'):
44
+ obj, _, rest = obj.partition(_NOTE_SEP)
45
+ note = rest[:-1]
46
+ entries.append(Entry(date, m.group(1), m.group(2), m.group(3), obj, note))
47
+ return entries
48
+
49
+
50
+ def read(bucket_root: Path) -> list[Entry]:
51
+ path = bucket_root / "LEDGER.md"
52
+ if not path.exists():
53
+ return []
54
+ return parse(path.read_text(encoding="utf-8"))
55
+
56
+
57
+ def format_line(entry: Entry) -> str:
58
+ line = f"- {entry.time} · {entry.actor} · {entry.verb} · {entry.obj}"
59
+ if entry.note:
60
+ line += f' — "{entry.note}"'
61
+ return line
62
+
63
+
64
+ def append(
65
+ bucket_root: Path,
66
+ actor: str,
67
+ verb: str,
68
+ obj: str,
69
+ note: str | None = None,
70
+ at: datetime | None = None,
71
+ ) -> Entry:
72
+ """The one writer. Strict: refuses verbs outside the eleven.
73
+
74
+ Actors must be single tokens; objects and notes are collapsed to one
75
+ line — the register must round-trip through its own reader.
76
+ """
77
+ if verb not in VERBS:
78
+ raise ValueError(f"verb must be one of: {', '.join(VERBS)}")
79
+ if not actor or actor != actor.strip() or any(c.isspace() for c in actor) or "·" in actor:
80
+ raise ValueError(
81
+ f"actor must be a single token with no spaces or '·': {actor!r}"
82
+ )
83
+ obj = " ".join(obj.split())
84
+ if note is not None:
85
+ note = " ".join(note.split())
86
+ at = at or datetime.now()
87
+ entry = Entry(at.strftime("%Y-%m-%d"), at.strftime("%H:%M"),
88
+ actor, verb, obj, note)
89
+ path = bucket_root / "LEDGER.md"
90
+ text = path.read_text(encoding="utf-8") if path.exists() else "# Ledger\n"
91
+ lines = text.rstrip("\n").split("\n")
92
+ heading = f"## {entry.date}"
93
+ last_heading = next(
94
+ (l for l in reversed(lines) if l.startswith("## ")), None
95
+ )
96
+ if last_heading == heading:
97
+ lines.append(format_line(entry))
98
+ else:
99
+ lines.extend(["", heading, format_line(entry)])
100
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n")
101
+ return entry
102
+
103
+
104
+ def resolve_actor(explicit: str | None = None) -> str:
105
+ """--as > FUSION_ACTOR > OS username. The pen always has a name."""
106
+ return explicit or os.environ.get("FUSION_ACTOR") or getpass.getuser()
fusion/manifest.py ADDED
@@ -0,0 +1,33 @@
1
+ """sources/MANIFEST.md — the register of preserved originals (SPEC §7)."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ _ROW_RE = re.compile(r"^\| (.+?) \| (.+?) \| (.+?) \| (.+?) \| (.+?) \|$")
9
+
10
+
11
+ @dataclass
12
+ class ManifestRow:
13
+ file: str
14
+ added: str
15
+ by: str
16
+ sha256: str
17
+ library: str
18
+
19
+
20
+ def read(bucket_root: Path) -> list[ManifestRow]:
21
+ path = bucket_root / "sources" / "MANIFEST.md"
22
+ if not path.exists():
23
+ return []
24
+ rows = []
25
+ for line in path.read_text(encoding="utf-8").split("\n"):
26
+ m = _ROW_RE.match(line)
27
+ if not m:
28
+ continue
29
+ cells = [c.strip() for c in m.groups()]
30
+ if cells[0] == "file" or set(cells[0]) <= {"-"}:
31
+ continue # header and separator rows
32
+ rows.append(ManifestRow(*cells))
33
+ return rows
fusion/scaffold.py ADDED
@@ -0,0 +1,120 @@
1
+ """fusion new — a complete bucket, born conformant (SPEC §2, §3)."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import subprocess
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+
9
+ import yaml
10
+
11
+ from . import hub, indexer, ledger
12
+ from .bucket import ZONES
13
+
14
+ BUCKET_TEMPLATE = """---
15
+ name: {name}
16
+ kind: {kind}
17
+ description: {description}
18
+ fusion_version: "1.0"
19
+ created: {created}
20
+ inbox_max_age_days: 7
21
+ reflection_cadence: weekly
22
+ ---
23
+
24
+ {description_body}
25
+
26
+ ## Conventions
27
+
28
+ ### Rules
29
+
30
+ ### Delegations
31
+ """
32
+
33
+
34
+ def _yaml_scalar(value: str) -> str:
35
+ """Render a string as a safe single-line YAML scalar (quoted only when needed)."""
36
+ style = '"' if "\n" in value else None
37
+ return yaml.safe_dump(
38
+ value, allow_unicode=True, width=2**31 - 1, default_style=style
39
+ ).split("\n")[0]
40
+
41
+ MANIFEST_HEADER = (
42
+ "# Manifest\n\n| file | added | by | sha256 | library |\n"
43
+ "|---|---|---|---|---|\n"
44
+ )
45
+
46
+
47
+ class ScaffoldError(Exception):
48
+ pass
49
+
50
+
51
+ def new_bucket(
52
+ path: Path,
53
+ name: str | None = None,
54
+ kind: str = "personal",
55
+ description: str | None = None,
56
+ actor: str = "human",
57
+ at: datetime | None = None,
58
+ register: bool = True,
59
+ ) -> tuple[Path, list[str]]:
60
+ root = path.expanduser().resolve()
61
+ name = name or root.name
62
+ description = description or f"A {kind} bucket. Describe me."
63
+ at = at or datetime.now()
64
+
65
+ if root.exists() and not root.is_dir():
66
+ raise ScaffoldError(f"target exists and is not a directory: {root}")
67
+ if root.exists() and any(root.iterdir()):
68
+ raise ScaffoldError(f"target exists and is not empty: {root}")
69
+ if register and any(e.name == name for e in hub.load()):
70
+ raise ScaffoldError(f"bucket '{name}' is already registered in the hub")
71
+
72
+ for zone in ZONES:
73
+ (root / zone).mkdir(parents=True, exist_ok=True)
74
+ for zone in ("inbox", "workbench", "output"):
75
+ (root / zone / ".gitkeep").write_text("", encoding="utf-8", newline="\n")
76
+ (root / "BUCKET.md").write_text(
77
+ BUCKET_TEMPLATE.format(
78
+ name=_yaml_scalar(name), kind=_yaml_scalar(kind),
79
+ description=_yaml_scalar(description),
80
+ description_body=description,
81
+ created=at.strftime("%Y-%m-%d"),
82
+ ),
83
+ encoding="utf-8", newline="\n",
84
+ )
85
+ (root / "sources" / "MANIFEST.md").write_text(
86
+ MANIFEST_HEADER, encoding="utf-8", newline="\n"
87
+ )
88
+ ledger.append(root, actor, "created", "BUCKET.md", note="bucket born", at=at)
89
+ indexer.write_indexes(root, actor=None)
90
+
91
+ warnings: list[str] = []
92
+ for cmd in (
93
+ ["git", "init", "-q"],
94
+ ["git", "add", "-A"],
95
+ ["git", "commit", "-q", "-m", "fusion new: bucket born"],
96
+ ):
97
+ try:
98
+ result = subprocess.run(
99
+ cmd, cwd=root, capture_output=True, text=True, timeout=30,
100
+ env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
101
+ )
102
+ if result.returncode != 0:
103
+ warnings.append(
104
+ f"{' '.join(cmd)}: {result.stderr.strip() or 'failed'}"
105
+ )
106
+ break
107
+ except FileNotFoundError:
108
+ warnings.append("git not found — bucket created without a repository")
109
+ break
110
+ except subprocess.TimeoutExpired:
111
+ warnings.append(f"{' '.join(cmd)}: timed out after 30s")
112
+ break
113
+
114
+ if register:
115
+ try:
116
+ hub.add(hub.HubEntry(name, kind, hub.display_path(root), description))
117
+ except (ValueError, OSError) as exc:
118
+ warnings.append(f"hub registration failed: {exc}")
119
+
120
+ return root, warnings
fusion/setup.py ADDED
@@ -0,0 +1,300 @@
1
+ """fusion setup — the installer brain. Bootstraps hand off here; every
2
+ mechanical decision about skills placement lives in this module so it is
3
+ written once, runs identically on macOS/Linux/Windows, and is tested.
4
+
5
+ Never destroys content it did not create: foreign entries are warned and
6
+ left unless --force. Writes only under the given directories.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import importlib.metadata
12
+ import importlib.resources
13
+ import os
14
+ import shutil
15
+ from pathlib import Path
16
+
17
+
18
+ class SetupError(Exception):
19
+ """A setup step that cannot proceed."""
20
+
21
+
22
+ def payload_root() -> Path:
23
+ bundled = importlib.resources.files("fusion") / "_skills"
24
+ p = Path(str(bundled))
25
+ if p.is_dir() and sorted(p.glob("fusion-*")):
26
+ return p
27
+ repo = Path(__file__).resolve().parents[3] / "skills"
28
+ if repo.is_dir() and sorted(repo.glob("fusion-*")):
29
+ return repo
30
+ raise SetupError(
31
+ "no skills payload: neither bundled fusion/_skills nor a repo "
32
+ "skills/ directory — reinstall fusion-cli"
33
+ )
34
+
35
+
36
+ def payload_version() -> str:
37
+ try:
38
+ return importlib.metadata.version("fusion-cli")
39
+ except importlib.metadata.PackageNotFoundError:
40
+ return "dev"
41
+
42
+
43
+ def tree_digest(path: Path) -> str:
44
+ h = hashlib.sha256()
45
+ for f in sorted(p for p in path.rglob("*") if p.is_file()):
46
+ h.update(f.relative_to(path).as_posix().encode())
47
+ h.update(b"\0")
48
+ h.update(f.read_bytes())
49
+ h.update(b"\0")
50
+ return h.hexdigest()
51
+
52
+
53
+ def install_canonical(payload: Path, skills_dir: Path,
54
+ force: bool) -> list[dict]:
55
+ skills_dir.mkdir(parents=True, exist_ok=True)
56
+ results = []
57
+ for skill in sorted(payload.glob("fusion-*")):
58
+ dest = skills_dir / skill.name
59
+ if dest.is_symlink():
60
+ if not force:
61
+ results.append({
62
+ "skill": skill.name, "action": "left",
63
+ "detail": f"{dest} is a symlink you manage — "
64
+ f"--force replaces it"})
65
+ continue
66
+ dest.unlink()
67
+ shutil.copytree(skill, dest)
68
+ results.append({"skill": skill.name, "action": "replaced",
69
+ "detail": str(dest)})
70
+ continue
71
+ if dest.is_dir():
72
+ if tree_digest(dest) == tree_digest(skill):
73
+ results.append({"skill": skill.name, "action": "up-to-date",
74
+ "detail": str(dest)})
75
+ continue
76
+ shutil.rmtree(dest)
77
+ shutil.copytree(skill, dest)
78
+ results.append({"skill": skill.name, "action": "updated",
79
+ "detail": str(dest)})
80
+ continue
81
+ if dest.exists():
82
+ if not force:
83
+ results.append({"skill": skill.name, "action": "left",
84
+ "detail": f"{dest} is a file — "
85
+ f"--force replaces"})
86
+ continue
87
+ dest.unlink()
88
+ shutil.copytree(skill, dest)
89
+ results.append({"skill": skill.name, "action": "replaced",
90
+ "detail": str(dest)})
91
+ continue
92
+ shutil.copytree(skill, dest)
93
+ results.append({"skill": skill.name, "action": "installed",
94
+ "detail": str(dest)})
95
+ return results
96
+
97
+
98
+ # One row per agent. mode "link": the agent does not read the standard
99
+ # ~/.agents/skills dir, so each fusion-* skill gets a symlink (or copy)
100
+ # in its own skills dir. mode "standard": the agent reads the standard
101
+ # dir — creating links there too would load every skill twice.
102
+ AGENTS = [
103
+ {"name": "Claude Code", "marker": ".claude",
104
+ "skills_subdir": ".claude/skills", "mode": "link",
105
+ "docs_url": "https://code.claude.com/docs/en/skills"},
106
+ {"name": "Codex", "marker": ".codex",
107
+ "skills_subdir": ".codex/skills", "mode": "link",
108
+ "docs_url": "https://developers.openai.com/codex/skills"},
109
+ {"name": "Pi", "marker": ".pi",
110
+ "skills_subdir": ".pi/agent/skills", "mode": "link",
111
+ "docs_url": "https://pi.dev/docs/latest/skills"},
112
+ {"name": "Cursor", "marker": ".cursor",
113
+ "skills_subdir": ".agents/skills", "mode": "standard",
114
+ "docs_url": "https://cursor.com/docs/skills"},
115
+ {"name": "Gemini CLI", "marker": ".gemini",
116
+ "skills_subdir": ".agents/skills", "mode": "standard",
117
+ "docs_url": "https://geminicli.com/docs/cli/skills"},
118
+ {"name": "opencode", "marker": ".config/opencode",
119
+ "skills_subdir": ".agents/skills", "mode": "standard",
120
+ "docs_url": "https://opencode.ai/docs/skills"},
121
+ {"name": "Goose", "marker": ".config/goose",
122
+ "skills_subdir": ".agents/skills", "mode": "standard",
123
+ "docs_url": "https://block.github.io/goose/docs/mcp/skills-mcp"},
124
+ ]
125
+
126
+
127
+ def detect_agents(home: Path) -> list[dict]:
128
+ found = []
129
+ for row in AGENTS:
130
+ if (home / row["marker"]).is_dir():
131
+ found.append({**row, "skills_dir": home / row["skills_subdir"]})
132
+ return found
133
+
134
+
135
+ def _points_into(link: Path, canonical: Path) -> bool:
136
+ try:
137
+ return link.resolve().is_relative_to(canonical.resolve())
138
+ except OSError:
139
+ return False
140
+
141
+
142
+ def fan_out(canonical: Path, agents: list[dict], force: bool) -> list[dict]:
143
+ results = []
144
+ skills = sorted(canonical.glob("fusion-*"))
145
+ for agent in agents:
146
+ if agent["mode"] == "standard":
147
+ if canonical.resolve() != agent["skills_dir"].resolve():
148
+ detail = (f"reads ~/.agents/skills — but your skills are "
149
+ f"at {canonical}, which this agent does not read")
150
+ else:
151
+ detail = "reads ~/.agents/skills — nothing to do"
152
+ results.append({"agent": agent["name"], "skill": "*",
153
+ "action": "standard", "detail": detail})
154
+ continue
155
+ agent["skills_dir"].mkdir(parents=True, exist_ok=True)
156
+ for skill in skills:
157
+ target = agent["skills_dir"] / skill.name
158
+ row = {"agent": agent["name"], "skill": skill.name}
159
+ replaced = False
160
+ if target.is_symlink():
161
+ if _points_into(target, canonical):
162
+ results.append({**row, "action": "up-to-date",
163
+ "detail": str(target)})
164
+ continue
165
+ if not force:
166
+ results.append({**row, "action": "left",
167
+ "detail": f"{target} links elsewhere — "
168
+ f"--force replaces"})
169
+ continue
170
+ target.unlink()
171
+ replaced = True
172
+ elif target.is_dir():
173
+ if tree_digest(target) == tree_digest(skill):
174
+ pass # our current copy, refresh below keeps it current
175
+ elif (target / ".fusion-setup").is_file():
176
+ pass # our stale copy (sentinel proves provenance) — refresh
177
+ elif not force:
178
+ results.append({**row, "action": "left",
179
+ "detail": f"{target} exists and is not "
180
+ f"ours — --force replaces"})
181
+ continue
182
+ else:
183
+ replaced = True
184
+ shutil.rmtree(target)
185
+ elif target.exists():
186
+ if not force:
187
+ results.append({**row, "action": "left",
188
+ "detail": f"{target} is a file — "
189
+ f"--force replaces"})
190
+ continue
191
+ target.unlink()
192
+ replaced = True
193
+ try:
194
+ os.symlink(skill, target, target_is_directory=True)
195
+ results.append({**row,
196
+ "action": "replaced" if replaced else "linked",
197
+ "detail": str(target)})
198
+ except OSError:
199
+ shutil.copytree(skill, target)
200
+ (target / ".fusion-setup").write_text(
201
+ f"{payload_version()}\n{tree_digest(skill)}\n",
202
+ encoding="utf-8", newline="\n")
203
+ results.append({**row,
204
+ "action": "replaced" if replaced else "copied",
205
+ "detail": f"{target} (symlinks unavailable — "
206
+ f"re-run setup after upgrades)"})
207
+ return results
208
+
209
+
210
+ def remove_all(canonical: Path, home: Path) -> list[dict]:
211
+ results = []
212
+ for agent in detect_agents(home):
213
+ if agent["mode"] == "standard":
214
+ continue
215
+ entries = (sorted(agent["skills_dir"].glob("fusion-*"))
216
+ if agent["skills_dir"].is_dir() else [])
217
+ for entry in entries:
218
+ row = {"agent": agent["name"], "skill": entry.name}
219
+ if entry.is_symlink() and _points_into(entry, canonical):
220
+ entry.unlink()
221
+ results.append({**row, "action": "removed",
222
+ "detail": str(entry)})
223
+ elif entry.is_dir() and not entry.is_symlink() \
224
+ and (canonical / entry.name).is_dir() \
225
+ and ((entry / ".fusion-setup").is_file()
226
+ or tree_digest(entry) == tree_digest(canonical / entry.name)):
227
+ shutil.rmtree(entry)
228
+ results.append({**row, "action": "removed",
229
+ "detail": str(entry)})
230
+ else:
231
+ results.append({**row, "action": "left",
232
+ "detail": f"{entry} is not attributable to "
233
+ f"setup — left in place"})
234
+ for skill in sorted(canonical.glob("fusion-*")):
235
+ row = {"agent": "canonical", "skill": skill.name}
236
+ if skill.is_symlink():
237
+ results.append({**row, "action": "left",
238
+ "detail": f"{skill} is a symlink you manage"})
239
+ continue
240
+ shutil.rmtree(skill)
241
+ results.append({**row, "action": "removed", "detail": str(skill)})
242
+ return results
243
+
244
+
245
+ def environment_advice(home: Path) -> list[dict]:
246
+ import os as _os
247
+ import subprocess
248
+ advice = []
249
+ if shutil.which("fusion") is None:
250
+ # spec §4d: fix PATH via uv's own mechanism when allowed
251
+ if _os.environ.get("FUSION_NO_MODIFY_PATH") != "1" \
252
+ and shutil.which("uv") is not None:
253
+ done = subprocess.run(["uv", "tool", "update-shell"],
254
+ capture_output=True).returncode == 0
255
+ advice.append({
256
+ "topic": "path",
257
+ "text": "uv tool update-shell ran — restart your shell so "
258
+ "`fusion` resolves" if done else
259
+ "uv tool update-shell failed — add uv's tool bin "
260
+ "dir to PATH yourself (`uv tool dir --bin`)"})
261
+ else:
262
+ advice.append({
263
+ "topic": "path",
264
+ "text": "the uv tool bin dir is not on PATH — run: "
265
+ "uv tool update-shell (or manage PATH yourself)"})
266
+ if shutil.which("git") is None:
267
+ advice.append({
268
+ "topic": "git",
269
+ "text": "git not found — buckets are git repos; install git"})
270
+ if shutil.which("soffice") is None:
271
+ advice.append({
272
+ "topic": "libreoffice",
273
+ "text": "LibreOffice (soffice) not found — only docx/pptx/"
274
+ "legacy-office/html intake needs it "
275
+ "(brew install --cask libreoffice · apt install "
276
+ "libreoffice · winget install LibreOffice.LibreOffice)"})
277
+ return advice
278
+
279
+
280
+ def run_setup(home: Path, skills_dir: Path, force: bool,
281
+ no_agents: bool, remove: bool) -> dict:
282
+ if remove:
283
+ results = remove_all(skills_dir, home)
284
+ return {"ok": True, "removed": results,
285
+ "next": ["uv tool uninstall fusion-cli"]}
286
+ payload = payload_root()
287
+ skills = install_canonical(payload, skills_dir, force)
288
+ agents = [] if no_agents else fan_out(
289
+ skills_dir, detect_agents(home), force)
290
+ return {
291
+ "ok": True,
292
+ "cli": {"version": payload_version()},
293
+ "skills": {"dir": str(skills_dir), "results": skills},
294
+ "agents": agents,
295
+ "advice": environment_advice(home),
296
+ "next": ["fusion new ~/buckets/personal --kind personal "
297
+ "--description \"Home base.\"",
298
+ "https://github.com/bluewaves-creations/fusion/blob/"
299
+ "main/docs/GETTING-STARTED.md"],
300
+ }