fusion-cli 1.2.0__tar.gz → 1.3.1__tar.gz

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 (77) hide show
  1. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/PKG-INFO +7 -4
  2. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/README.md +6 -3
  3. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/pyproject.toml +1 -1
  4. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/src/fusion/cli.py +33 -2
  5. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/src/fusion/setup.py +67 -3
  6. fusion_cli-1.3.1/src/fusion/update.py +85 -0
  7. fusion_cli-1.3.1/tests/test_cli_update.py +140 -0
  8. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/tests/test_setup_agents.py +119 -26
  9. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/uv.lock +1 -1
  10. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/.gitignore +0 -0
  11. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/hatch_build.py +0 -0
  12. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-analyst/SKILL.md +0 -0
  13. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-analyst/references/assess.md +0 -0
  14. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-analyst/references/compare.md +0 -0
  15. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-analyst/references/export.md +0 -0
  16. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-analyst/references/fusion-conventions.md +0 -0
  17. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-analyst/references/report.md +0 -0
  18. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-analyst/scripts/export.py +0 -0
  19. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-intake/SKILL.md +0 -0
  20. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-intake/references/convert.md +0 -0
  21. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-intake/references/delivery.md +0 -0
  22. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-intake/references/fusion-conventions.md +0 -0
  23. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-intake/references/gate.md +0 -0
  24. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-intake/scripts/convert.py +0 -0
  25. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-intake/scripts/gate.py +0 -0
  26. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-intake/tests/conftest.py +0 -0
  27. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-intake/tests/make_fixtures.py +0 -0
  28. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-intake/tests/test_convert.py +0 -0
  29. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-intake/tests/test_gate.py +0 -0
  30. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-intake/tests/test_integration.py +0 -0
  31. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-librarian/SKILL.md +0 -0
  32. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-librarian/references/archive.md +0 -0
  33. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-librarian/references/create.md +0 -0
  34. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-librarian/references/cross-reference.md +0 -0
  35. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-librarian/references/fusion-conventions.md +0 -0
  36. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-librarian/references/promote.md +0 -0
  37. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-librarian/references/query.md +0 -0
  38. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-librarian/references/reflect.md +0 -0
  39. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-librarian/references/restructure.md +0 -0
  40. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-librarian/references/tag.md +0 -0
  41. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-librarian/scripts/link-repair.py +0 -0
  42. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-librarian/tests/conftest.py +0 -0
  43. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-librarian/tests/test_link_repair.py +0 -0
  44. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-planner/SKILL.md +0 -0
  45. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-planner/references/close.md +0 -0
  46. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-planner/references/create-activity.md +0 -0
  47. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-planner/references/fusion-conventions.md +0 -0
  48. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-planner/references/horizon.md +0 -0
  49. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/src/fusion/__init__.py +0 -0
  50. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/src/fusion/bucket.py +0 -0
  51. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/src/fusion/checker.py +0 -0
  52. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/src/fusion/document.py +0 -0
  53. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/src/fusion/hub.py +0 -0
  54. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/src/fusion/indexer.py +0 -0
  55. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/src/fusion/ledger.py +0 -0
  56. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/src/fusion/manifest.py +0 -0
  57. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/src/fusion/scaffold.py +0 -0
  58. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/src/fusion/views.py +0 -0
  59. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/tests/__init__.py +0 -0
  60. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/tests/conftest.py +0 -0
  61. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/tests/test_build_payload.py +0 -0
  62. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/tests/test_checker_errors.py +0 -0
  63. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/tests/test_checker_warnings.py +0 -0
  64. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/tests/test_cli.py +0 -0
  65. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/tests/test_cli_encoding.py +0 -0
  66. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/tests/test_cli_setup.py +0 -0
  67. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/tests/test_cli_version.py +0 -0
  68. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/tests/test_document.py +0 -0
  69. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/tests/test_hub_bucket.py +0 -0
  70. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/tests/test_indexer.py +0 -0
  71. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/tests/test_ledger.py +0 -0
  72. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/tests/test_line_endings.py +0 -0
  73. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/tests/test_roundtrip.py +0 -0
  74. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/tests/test_scaffold.py +0 -0
  75. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/tests/test_setup_core.py +0 -0
  76. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/tests/test_skill_family.py +0 -0
  77. {fusion_cli-1.2.0 → fusion_cli-1.3.1}/tests/test_views.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fusion-cli
3
- Version: 1.2.0
3
+ Version: 1.3.1
4
4
  Summary: The Fusion reference CLI — the notary of the Fusion Convention.
5
5
  Author: Bluewaves Boutique
6
6
  License-Expression: MIT
@@ -38,7 +38,7 @@ uv tool install ./fusion/cli
38
38
  fusion --version
39
39
  ```
40
40
 
41
- ## The nine commands (there is no tenth)
41
+ ## The ten commands (there is no eleventh)
42
42
 
43
43
  | Command | Does |
44
44
  |---|---|
@@ -50,9 +50,12 @@ fusion --version
50
50
  | `fusion status [path]` | One bucket at a glance |
51
51
  | `fusion today` | The composed day, across every bucket in the hub |
52
52
  | `fusion agenda` | The wider horizon — dated and active, across the hub |
53
- | `fusion setup` | Install the skills into every detected agent — the installer's brain. `--remove` undoes it |
53
+ | `fusion setup` | Install the skills to `~/.agents/skills` and serve every detected agent (links only where needed) — the installer's brain. `--remove` undoes it |
54
+ | `fusion update` | Bring the whole system current: upgrade fusion-cli through uv, then re-run setup from the new binary so the skills refresh everywhere. Equivalent to `uv tool upgrade fusion-cli && fusion setup` |
54
55
 
55
- Every command takes `--json` (agents parse, never scrape). `log`, `new`,
56
+ Every command takes `--json` (agents parse, never scrape) except
57
+ `update`, which composes two processes (uv, then setup) that own
58
+ stdout in turn. `log`, `new`,
56
59
  and `index` take `--as <actor>`; the pen defaults to `FUSION_ACTOR`, then
57
60
  the OS username. `status` and `log` take `--since <date|last-reflection>`.
58
61
  `log` also takes `--bucket` (resolves the ledger; default: walk up from the current directory).
@@ -28,7 +28,7 @@ uv tool install ./fusion/cli
28
28
  fusion --version
29
29
  ```
30
30
 
31
- ## The nine commands (there is no tenth)
31
+ ## The ten commands (there is no eleventh)
32
32
 
33
33
  | Command | Does |
34
34
  |---|---|
@@ -40,9 +40,12 @@ fusion --version
40
40
  | `fusion status [path]` | One bucket at a glance |
41
41
  | `fusion today` | The composed day, across every bucket in the hub |
42
42
  | `fusion agenda` | The wider horizon — dated and active, across the hub |
43
- | `fusion setup` | Install the skills into every detected agent — the installer's brain. `--remove` undoes it |
43
+ | `fusion setup` | Install the skills to `~/.agents/skills` and serve every detected agent (links only where needed) — the installer's brain. `--remove` undoes it |
44
+ | `fusion update` | Bring the whole system current: upgrade fusion-cli through uv, then re-run setup from the new binary so the skills refresh everywhere. Equivalent to `uv tool upgrade fusion-cli && fusion setup` |
44
45
 
45
- Every command takes `--json` (agents parse, never scrape). `log`, `new`,
46
+ Every command takes `--json` (agents parse, never scrape) except
47
+ `update`, which composes two processes (uv, then setup) that own
48
+ stdout in turn. `log`, `new`,
46
49
  and `index` take `--as <actor>`; the pen defaults to `FUSION_ACTOR`, then
47
50
  the OS username. `status` and `log` take `--since <date|last-reflection>`.
48
51
  `log` also takes `--bucket` (resolves the ledger; default: walk up from the current directory).
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fusion-cli"
3
- version = "1.2.0"
3
+ version = "1.3.1"
4
4
  description = "The Fusion reference CLI — the notary of the Fusion Convention."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -319,6 +319,21 @@ def _render_setup(report: dict) -> list[str]:
319
319
  return lines
320
320
 
321
321
 
322
+ def cmd_update(args) -> int:
323
+ from fusion import update as update_mod
324
+ setup_args = []
325
+ if args.force:
326
+ setup_args.append("--force")
327
+ if args.skills_dir:
328
+ setup_args += ["--skills-dir", args.skills_dir]
329
+ if args.no_agents:
330
+ setup_args.append("--no-agents")
331
+ try:
332
+ return update_mod.run_update(Path.home(), setup_args)
333
+ except update_mod.UpdateError as exc:
334
+ return _fail(str(exc))
335
+
336
+
322
337
  # ── parser ───────────────────────────────────────────────────────────────
323
338
 
324
339
  def build_parser() -> argparse.ArgumentParser:
@@ -404,8 +419,10 @@ def build_parser() -> argparse.ArgumentParser:
404
419
 
405
420
  p = sub.add_parser("setup", help="install skills into agents; --remove undoes it",
406
421
  description="Install the bundled skills to the canonical "
407
- "skills directory and link them into every detected agent. "
408
- "Idempotent; never destroys what it did not create.")
422
+ "skills directory (~/.agents/skills) and make them available "
423
+ "to every detected agent most read that directory "
424
+ "natively; agents that do not (Claude Code) get symlinks. "
425
+ "--remove undoes exactly what setup can prove it created.")
409
426
  p.add_argument("--force", action="store_true",
410
427
  help="replace foreign entries setup would otherwise leave")
411
428
  p.add_argument("--remove", action="store_true",
@@ -416,6 +433,20 @@ def build_parser() -> argparse.ArgumentParser:
416
433
  flag_json(p)
417
434
  p.set_defaults(func=cmd_setup)
418
435
 
436
+ p = sub.add_parser("update", help="upgrade fusion-cli and refresh the skills",
437
+ description="Bring the whole system current: upgrade "
438
+ "fusion-cli through uv, then re-run `fusion setup` "
439
+ "from the new binary so the bundled skills refresh "
440
+ "everywhere setup put them. Equivalent to: "
441
+ "uv tool upgrade fusion-cli && fusion setup.")
442
+ p.add_argument("--force", action="store_true",
443
+ help="forwarded to setup: replace foreign entries")
444
+ p.add_argument("--skills-dir",
445
+ help="forwarded to setup: canonical destination")
446
+ p.add_argument("--no-agents", action="store_true",
447
+ help="forwarded to setup: skip agent fan-out")
448
+ p.set_defaults(func=cmd_update)
449
+
419
450
  return parser
420
451
 
421
452
 
@@ -99,15 +99,20 @@ def install_canonical(payload: Path, skills_dir: Path,
99
99
  # ~/.agents/skills dir, so each fusion-* skill gets a symlink (or copy)
100
100
  # in its own skills dir. mode "standard": the agent reads the standard
101
101
  # dir — creating links there too would load every skill twice.
102
+ # legacy_subdir: a dir an earlier fusion-cli release linked into before
103
+ # the agent's native standard-dir support was verified; setup sweeps
104
+ # OUR leftovers out of it (attribution-checked, like everything else).
102
105
  AGENTS = [
103
106
  {"name": "Claude Code", "marker": ".claude",
104
107
  "skills_subdir": ".claude/skills", "mode": "link",
105
108
  "docs_url": "https://code.claude.com/docs/en/skills"},
106
109
  {"name": "Codex", "marker": ".codex",
107
- "skills_subdir": ".codex/skills", "mode": "link",
110
+ "skills_subdir": ".agents/skills", "mode": "standard",
111
+ "legacy_subdir": ".codex/skills",
108
112
  "docs_url": "https://developers.openai.com/codex/skills"},
109
113
  {"name": "Pi", "marker": ".pi",
110
- "skills_subdir": ".pi/agent/skills", "mode": "link",
114
+ "skills_subdir": ".agents/skills", "mode": "standard",
115
+ "legacy_subdir": ".pi/agent/skills",
111
116
  "docs_url": "https://pi.dev/docs/latest/skills"},
112
117
  {"name": "Cursor", "marker": ".cursor",
113
118
  "skills_subdir": ".agents/skills", "mode": "standard",
@@ -128,7 +133,10 @@ def detect_agents(home: Path) -> list[dict]:
128
133
  found = []
129
134
  for row in AGENTS:
130
135
  if (home / row["marker"]).is_dir():
131
- found.append({**row, "skills_dir": home / row["skills_subdir"]})
136
+ entry = {**row, "skills_dir": home / row["skills_subdir"]}
137
+ if "legacy_subdir" in row:
138
+ entry["legacy_dir"] = home / row["legacy_subdir"]
139
+ found.append(entry)
132
140
  return found
133
141
 
134
142
 
@@ -139,11 +147,51 @@ def _points_into(link: Path, canonical: Path) -> bool:
139
147
  return False
140
148
 
141
149
 
150
+ def _sweep_legacy(agent: dict, canonical: Path) -> list[dict]:
151
+ """Remove OUR leftovers from a dir an earlier release linked into.
152
+
153
+ fusion-cli <= 1.2.0 symlinked (or copy-fell-back) into Pi's and
154
+ Codex's own dirs; both agents read the canonical dir natively, so
155
+ those entries double-loaded every skill. Attribution-checked:
156
+ symlinks into the canonical and sentinel-marked copies are ours;
157
+ anything else is left with a report row. If the legacy dir is
158
+ itself aliased at the canonical, there is nothing to sweep.
159
+ """
160
+ legacy = agent.get("legacy_dir")
161
+ if legacy is None or not legacy.is_dir():
162
+ return []
163
+ if _points_into(legacy, canonical):
164
+ return [] # the legacy dir IS the canonical through an alias —
165
+ # sweeping it would reach canonical entries
166
+ results = []
167
+ for entry in sorted(legacy.glob("fusion-*")):
168
+ row = {"agent": agent["name"], "skill": entry.name}
169
+ if entry.is_symlink() and _points_into(entry, canonical):
170
+ entry.unlink()
171
+ results.append({**row, "action": "unlinked",
172
+ "detail": f"{entry} — this agent reads the "
173
+ f"standard dir; the extra link "
174
+ f"double-loaded every skill"})
175
+ elif (entry.is_dir() and not entry.is_symlink()
176
+ and (entry / ".fusion-setup").is_file()):
177
+ shutil.rmtree(entry)
178
+ results.append({**row, "action": "unlinked",
179
+ "detail": f"{entry} — copy from an earlier "
180
+ f"fusion-cli; this agent reads the "
181
+ f"standard dir"})
182
+ else:
183
+ results.append({**row, "action": "left",
184
+ "detail": f"{entry} is not attributable to "
185
+ f"setup — left in place"})
186
+ return results
187
+
188
+
142
189
  def fan_out(canonical: Path, agents: list[dict], force: bool) -> list[dict]:
143
190
  results = []
144
191
  skills = sorted(canonical.glob("fusion-*"))
145
192
  for agent in agents:
146
193
  if agent["mode"] == "standard":
194
+ results.extend(_sweep_legacy(agent, canonical))
147
195
  if canonical.resolve() != agent["skills_dir"].resolve():
148
196
  detail = (f"reads ~/.agents/skills — but your skills are "
149
197
  f"at {canonical}, which this agent does not read")
@@ -152,6 +200,13 @@ def fan_out(canonical: Path, agents: list[dict], force: bool) -> list[dict]:
152
200
  results.append({"agent": agent["name"], "skill": "*",
153
201
  "action": "standard", "detail": detail})
154
202
  continue
203
+ if _points_into(agent["skills_dir"], canonical):
204
+ results.append({
205
+ "agent": agent["name"], "skill": "*", "action": "served",
206
+ "detail": f"{agent['skills_dir']} resolves into "
207
+ f"{canonical} — the canonical install already "
208
+ f"serves this agent; nothing to link"})
209
+ continue
155
210
  agent["skills_dir"].mkdir(parents=True, exist_ok=True)
156
211
  for skill in skills:
157
212
  target = agent["skills_dir"] / skill.name
@@ -170,6 +225,11 @@ def fan_out(canonical: Path, agents: list[dict], force: bool) -> list[dict]:
170
225
  target.unlink()
171
226
  replaced = True
172
227
  elif target.is_dir():
228
+ if target.resolve() == skill.resolve():
229
+ results.append({**row, "action": "served",
230
+ "detail": f"{target} already resolves "
231
+ f"to the canonical skill"})
232
+ continue
173
233
  if tree_digest(target) == tree_digest(skill):
174
234
  pass # our current copy, refresh below keeps it current
175
235
  elif (target / ".fusion-setup").is_file():
@@ -211,7 +271,11 @@ def remove_all(canonical: Path, home: Path) -> list[dict]:
211
271
  results = []
212
272
  for agent in detect_agents(home):
213
273
  if agent["mode"] == "standard":
274
+ results.extend(_sweep_legacy(agent, canonical))
214
275
  continue
276
+ if _points_into(agent["skills_dir"], canonical):
277
+ continue # entries here ARE the canonical skills; the
278
+ # canonical phase below removes them exactly once
215
279
  entries = (sorted(agent["skills_dir"].glob("fusion-*"))
216
280
  if agent["skills_dir"].is_dir() else [])
217
281
  for entry in entries:
@@ -0,0 +1,85 @@
1
+ """fusion update — one verb to bring the whole system current.
2
+
3
+ Upgrades fusion-cli through uv, then re-runs `fusion setup` from the
4
+ NEW binary: this process keeps the old skills payload in memory, so
5
+ setup must run in a child for the refreshed payload to land. Every
6
+ decision is testable without a real subprocess — the runner is a seam.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ from .setup import payload_version
17
+
18
+
19
+ class UpdateError(Exception):
20
+ """An update step that cannot proceed."""
21
+
22
+
23
+ def _runner():
24
+ return subprocess.run
25
+
26
+
27
+ def find_uv(home: Path) -> Path | None:
28
+ found = shutil.which("uv")
29
+ if found:
30
+ return Path(found)
31
+ # install.sh's landing spot, for shells whose PATH never learned it
32
+ for name in ("uv.exe", "uv") if os.name == "nt" else ("uv",):
33
+ candidate = home / ".local" / "bin" / name
34
+ if candidate.is_file() and os.access(candidate, os.X_OK):
35
+ return candidate
36
+ return None
37
+
38
+
39
+ def uv_owns_install(uv: Path, run) -> bool:
40
+ proc = run([str(uv), "tool", "list"], capture_output=True, text=True)
41
+ if proc.returncode != 0:
42
+ return False
43
+ return any(line.split()[:1] == ["fusion-cli"]
44
+ for line in proc.stdout.splitlines())
45
+
46
+
47
+ def fusion_binary(uv: Path, run) -> Path:
48
+ proc = run([str(uv), "tool", "dir", "--bin"],
49
+ capture_output=True, text=True)
50
+ if proc.returncode != 0 or not proc.stdout.strip():
51
+ raise UpdateError(
52
+ "could not resolve uv's tool bin dir — finish by hand: "
53
+ "uv tool dir --bin, then <that dir>/fusion setup")
54
+ bin_dir = Path(proc.stdout.strip())
55
+ for name in ("fusion", "fusion.exe"):
56
+ candidate = bin_dir / name
57
+ if candidate.is_file():
58
+ return candidate
59
+ raise UpdateError(
60
+ f"fusion not found in {bin_dir} after the upgrade — "
61
+ f"check: uv tool list")
62
+
63
+
64
+ def run_update(home: Path, setup_args: list[str]) -> int:
65
+ run = _runner()
66
+ uv = find_uv(home)
67
+ if uv is None:
68
+ raise UpdateError(
69
+ "uv not found — fusion updates through uv "
70
+ "(https://docs.astral.sh/uv/). Install it, or upgrade "
71
+ "fusion-cli with whatever installed it, then run: fusion setup")
72
+ if not uv_owns_install(uv, run):
73
+ raise UpdateError(
74
+ "this fusion-cli is not managed by uv — upgrade it with "
75
+ "whatever installed it (pip, pipx, a checkout), then run: "
76
+ "fusion setup")
77
+ print(f"fusion-cli {payload_version()} — asking uv for newer…")
78
+ sys.stdout.flush() # keep our line ahead of uv's inherited stdio
79
+ if run([str(uv), "tool", "upgrade", "fusion-cli"]).returncode != 0:
80
+ raise UpdateError(
81
+ "uv tool upgrade failed — on Windows the running fusion.exe "
82
+ "can hold the lock. Exit this process, then run: "
83
+ "uv tool upgrade fusion-cli && fusion setup")
84
+ child = run([str(fusion_binary(uv, run)), "setup", *setup_args])
85
+ return child.returncode
@@ -0,0 +1,140 @@
1
+ """fusion update end to end through the CLI surface.
2
+
3
+ Same isolation contract as test_cli_setup.py: no real subprocess is
4
+ ever reached; a fake runner records the exact command sequence.
5
+ """
6
+ import subprocess
7
+ from pathlib import Path
8
+ from types import SimpleNamespace
9
+
10
+ import pytest
11
+
12
+ from fusion import update
13
+ from fusion.cli import build_parser, main
14
+
15
+ # find_uv wraps which()'s answer in Path, so the argv the runner sees is
16
+ # the platform's rendering of this stub ("\\stub\\uv" on Windows).
17
+ STUB_UV = str(Path("/stub/uv"))
18
+
19
+
20
+ class FakeRunner:
21
+ """Answers uv/fusion invocations from a script of canned results."""
22
+
23
+ def __init__(self, bin_dir: Path, *, list_stdout="fusion-cli v1.2.1\n- fusion\n",
24
+ list_rc=0, upgrade_rc=0, setup_rc=0):
25
+ self.bin_dir = bin_dir
26
+ self.list_stdout = list_stdout
27
+ self.list_rc = list_rc
28
+ self.upgrade_rc = upgrade_rc
29
+ self.setup_rc = setup_rc
30
+ self.calls = []
31
+
32
+ def __call__(self, argv, **kwargs):
33
+ self.calls.append([str(a) for a in argv])
34
+ prog, rest = Path(argv[0]).name, [str(a) for a in argv[1:]]
35
+ if prog.startswith("uv") and rest == ["tool", "list"]:
36
+ return SimpleNamespace(returncode=self.list_rc,
37
+ stdout=self.list_stdout, stderr="")
38
+ if prog.startswith("uv") and rest == ["tool", "upgrade", "fusion-cli"]:
39
+ return SimpleNamespace(returncode=self.upgrade_rc, stdout="", stderr="")
40
+ if prog.startswith("uv") and rest == ["tool", "dir", "--bin"]:
41
+ return SimpleNamespace(returncode=0, stdout=f"{self.bin_dir}\n", stderr="")
42
+ if prog.startswith("fusion") and rest[:1] == ["setup"]:
43
+ return SimpleNamespace(returncode=self.setup_rc, stdout="", stderr="")
44
+ raise AssertionError(f"unexpected command: {argv}")
45
+
46
+
47
+ @pytest.fixture
48
+ def sandbox(tmp_path, monkeypatch):
49
+ home = tmp_path / "home"
50
+ home.mkdir()
51
+ monkeypatch.setenv("HOME", str(home))
52
+ monkeypatch.setenv("USERPROFILE", str(home)) # windows
53
+ monkeypatch.setattr(Path, "home", classmethod(lambda cls: home))
54
+ monkeypatch.setattr(update.shutil, "which",
55
+ lambda name, *a, **kw: "/stub/uv" if name == "uv" else None)
56
+ monkeypatch.setattr(subprocess, "run",
57
+ lambda *a, **kw: pytest.fail("real subprocess reached"))
58
+ bin_dir = tmp_path / "toolbin"
59
+ bin_dir.mkdir()
60
+ (bin_dir / "fusion").write_text("")
61
+ return SimpleNamespace(home=home, bin_dir=bin_dir)
62
+
63
+
64
+ def _install_runner(monkeypatch, runner):
65
+ monkeypatch.setattr(update, "_runner", lambda: runner)
66
+
67
+
68
+ def test_update_happy_path_runs_upgrade_then_setup(sandbox, monkeypatch, capsys):
69
+ runner = FakeRunner(sandbox.bin_dir)
70
+ _install_runner(monkeypatch, runner)
71
+ assert main(["update"]) == 0
72
+ assert runner.calls == [
73
+ [STUB_UV, "tool", "list"],
74
+ [STUB_UV, "tool", "upgrade", "fusion-cli"],
75
+ [STUB_UV, "tool", "dir", "--bin"],
76
+ [str(sandbox.bin_dir / "fusion"), "setup"],
77
+ ]
78
+
79
+
80
+ def test_update_forwards_setup_flags(sandbox, monkeypatch):
81
+ runner = FakeRunner(sandbox.bin_dir)
82
+ _install_runner(monkeypatch, runner)
83
+ custom = str(sandbox.home / "custom-skills")
84
+ assert main(["update", "--force", "--skills-dir", custom,
85
+ "--no-agents"]) == 0
86
+ assert runner.calls[-1] == [str(sandbox.bin_dir / "fusion"), "setup",
87
+ "--force", "--skills-dir", custom,
88
+ "--no-agents"]
89
+
90
+
91
+ def test_update_propagates_setup_exit_code(sandbox, monkeypatch):
92
+ runner = FakeRunner(sandbox.bin_dir, setup_rc=3)
93
+ _install_runner(monkeypatch, runner)
94
+ assert main(["update"]) == 3
95
+
96
+
97
+ def test_update_without_uv_fails_with_manual_path(sandbox, monkeypatch, capsys):
98
+ monkeypatch.setattr(update.shutil, "which", lambda name, *a, **kw: None)
99
+ _install_runner(monkeypatch, FakeRunner(sandbox.bin_dir))
100
+ assert main(["update"]) == 1
101
+ err = capsys.readouterr().err
102
+ assert "uv" in err and "fusion setup" in err
103
+
104
+
105
+ def test_update_finds_uv_at_install_sh_fallback(sandbox, monkeypatch):
106
+ monkeypatch.setattr(update.shutil, "which", lambda name, *a, **kw: None)
107
+ fallback = sandbox.home / ".local" / "bin" / "uv"
108
+ fallback.parent.mkdir(parents=True)
109
+ fallback.write_text("")
110
+ fallback.chmod(0o755)
111
+ runner = FakeRunner(sandbox.bin_dir)
112
+ _install_runner(monkeypatch, runner)
113
+ assert main(["update"]) == 0
114
+ assert runner.calls[0][0] == str(fallback)
115
+
116
+
117
+ def test_update_refuses_non_uv_install(sandbox, monkeypatch, capsys):
118
+ runner = FakeRunner(sandbox.bin_dir, list_stdout="black v24.0.0\n- black\n")
119
+ _install_runner(monkeypatch, runner)
120
+ assert main(["update"]) == 1
121
+ err = capsys.readouterr().err
122
+ assert "not managed by uv" in err and "fusion setup" in err
123
+ # never got as far as upgrading
124
+ assert [STUB_UV, "tool", "upgrade", "fusion-cli"] not in runner.calls
125
+ assert runner.calls == [[STUB_UV, "tool", "list"]]
126
+
127
+
128
+ def test_update_reports_failed_upgrade_with_retry_ritual(sandbox, monkeypatch,
129
+ capsys):
130
+ runner = FakeRunner(sandbox.bin_dir, upgrade_rc=1)
131
+ _install_runner(monkeypatch, runner)
132
+ assert main(["update"]) == 1
133
+ err = capsys.readouterr().err
134
+ assert "uv tool upgrade fusion-cli && fusion setup" in err
135
+ # setup never ran on a failed upgrade
136
+ assert all(c[1:2] != ["setup"] for c in runner.calls)
137
+
138
+
139
+ def test_update_appears_in_help(sandbox):
140
+ assert "update" in build_parser().format_help()
@@ -26,7 +26,12 @@ def test_registry_shape():
26
26
  "opencode", "Goose"} == names
27
27
  modes = {a["name"]: a["mode"] for a in setup.AGENTS}
28
28
  assert modes["Claude Code"] == "link"
29
+ assert modes["Pi"] == "standard"
30
+ assert modes["Codex"] == "standard"
29
31
  assert modes["Cursor"] == "standard"
32
+ legacy = {a["name"]: a.get("legacy_subdir") for a in setup.AGENTS}
33
+ assert legacy["Pi"] == ".pi/agent/skills"
34
+ assert legacy["Codex"] == ".codex/skills"
30
35
 
31
36
 
32
37
  def test_detect_agents_only_present(home):
@@ -97,15 +102,15 @@ def test_fan_out_leaves_plain_file_unless_forced(home, canonical):
97
102
 
98
103
 
99
104
  def test_fan_out_copies_when_symlink_unavailable(home, canonical, monkeypatch):
100
- (home / ".pi").mkdir()
105
+ (home / ".claude").mkdir()
101
106
 
102
107
  def no_symlink(*a, **k):
103
108
  raise OSError("symlinks disabled")
104
109
  monkeypatch.setattr(setup.os, "symlink", no_symlink)
105
110
  results = setup.fan_out(canonical, setup.detect_agents(home), force=False)
106
- pi = [r for r in results if r["agent"] == "Pi"]
107
- assert {r["action"] for r in pi} == {"copied"}
108
- copied = home / ".pi" / "agent" / "skills" / "fusion-intake"
111
+ claude = [r for r in results if r["agent"] == "Claude Code"]
112
+ assert {r["action"] for r in claude} == {"copied"}
113
+ copied = home / ".claude" / "skills" / "fusion-intake"
109
114
  assert copied.is_dir() and not copied.is_symlink()
110
115
  # the copy carries a provenance sentinel — that's how we know it's ours
111
116
  sentinel = copied / ".fusion-setup"
@@ -117,8 +122,8 @@ def test_fan_out_copies_when_symlink_unavailable(home, canonical, monkeypatch):
117
122
  # itself is an extra file — so the sentinel is what proves provenance)
118
123
  (copied / "SKILL.md").write_text("stale")
119
124
  again = setup.fan_out(canonical, setup.detect_agents(home), force=False)
120
- pi_intake = [r for r in again if r["skill"] == "fusion-intake"][0]
121
- assert pi_intake["action"] == "copied"
125
+ claude_intake = [r for r in again if r["skill"] == "fusion-intake"][0]
126
+ assert claude_intake["action"] == "copied"
122
127
  assert (copied / ".fusion-setup").is_file()
123
128
 
124
129
 
@@ -136,34 +141,122 @@ def test_remove_all_is_attribution_checked(home, canonical):
136
141
 
137
142
  def test_remove_all_removes_sentinel_copies_and_leaves_foreign(
138
143
  home, canonical, monkeypatch):
139
- (home / ".pi").mkdir()
144
+ (home / ".claude").mkdir()
145
+ # a foreign dir sharing a canonical skill's name — same shape, no
146
+ # sentinel, different content — must survive removal untouched
147
+ foreign = home / ".claude" / "skills" / "fusion-planner"
148
+ foreign.mkdir(parents=True)
149
+ (foreign / "SKILL.md").write_text("---\nname: my-own-tool\n---\n")
150
+ (foreign / "data.txt").write_text("do not touch\n")
140
151
 
141
152
  def no_symlink(*a, **k):
142
153
  raise OSError("symlinks disabled")
143
154
  monkeypatch.setattr(setup.os, "symlink", no_symlink)
144
155
  setup.fan_out(canonical, setup.detect_agents(home), force=False)
145
- ours = home / ".pi" / "agent" / "skills" / "fusion-intake"
146
- assert (ours / ".fusion-setup").is_file() # our sentinel-marked copy
156
+ ours = home / ".claude" / "skills" / "fusion-intake"
157
+ assert (ours / ".fusion-setup").is_file() # our sentinel-marked copy
147
158
 
148
- # a foreign dir sharing a canonical skill's name — same shape, no
149
- # sentinel, different content — must survive removal untouched, even
150
- # discovered fresh (this agent never went through fan_out)
159
+ results = setup.remove_all(canonical, home)
160
+
161
+ assert not ours.exists() # our copy: removed
162
+ intake = [r for r in results if r["agent"] == "Claude Code"
163
+ and r["skill"] == "fusion-intake"][0]
164
+ assert intake["action"] == "removed"
165
+ assert foreign.is_dir() # foreign: left in place
166
+ assert (foreign / "data.txt").read_text() == "do not touch\n"
167
+ planner = [r for r in results if r["agent"] == "Claude Code"
168
+ and r["skill"] == "fusion-planner"][0]
169
+ assert planner["action"] == "left"
170
+
171
+
172
+ def test_fan_out_survives_skills_dir_symlinked_to_canonical(home, canonical):
173
+ # the topology that destroyed skills in v1.1.0-v1.2.0: the agent's
174
+ # whole skills dir is a symlink AT the canonical directory
151
175
  (home / ".claude").mkdir()
152
- foreign = home / ".claude" / "skills" / "fusion-intake"
153
- foreign.mkdir(parents=True)
154
- (foreign / "SKILL.md").write_text("---\nname: my-own-tool\n---\n")
155
- (foreign / "data.txt").write_text("do not touch\n")
176
+ (home / ".claude" / "skills").symlink_to(
177
+ canonical, target_is_directory=True)
178
+ results = setup.fan_out(canonical, setup.detect_agents(home),
179
+ force=False)
180
+ claude = [r for r in results if r["agent"] == "Claude Code"]
181
+ assert len(claude) == 1 and claude[0]["action"] == "served"
182
+ # the canonical skills are alive, real, and readable
183
+ assert (canonical / "fusion-intake" / "SKILL.md").is_file()
184
+ assert not (canonical / "fusion-intake").is_symlink()
185
+
156
186
 
187
+ def test_remove_all_survives_skills_dir_symlinked_to_canonical(
188
+ home, canonical):
189
+ (home / ".claude").mkdir()
190
+ (home / ".claude" / "skills").symlink_to(
191
+ canonical, target_is_directory=True)
157
192
  results = setup.remove_all(canonical, home)
193
+ # the guard means the agent sweep never touches the alias — every
194
+ # removal row must come from the canonical phase
195
+ assert not any(r["agent"] == "Claude Code" for r in results)
196
+ # the canonical phase removed the skills exactly once; the user's
197
+ # own dir-level symlink is not ours and stays
198
+ assert not (canonical / "fusion-intake").exists()
199
+ assert (home / ".claude" / "skills").is_symlink()
158
200
 
159
- assert not ours.exists() # our copy: removed
160
- pi_result = [r for r in results
161
- if r["agent"] == "Pi" and r["skill"] == "fusion-intake"][0]
162
- assert pi_result["action"] == "removed"
163
201
 
164
- assert foreign.is_dir() # foreign: left in place
165
- assert (foreign / "data.txt").read_text() == "do not touch\n"
166
- claude_result = [r for r in results
167
- if r["agent"] == "Claude Code"
168
- and r["skill"] == "fusion-intake"][0]
169
- assert claude_result["action"] == "left"
202
+ def test_fan_out_standard_sweeps_our_legacy_links(home, canonical):
203
+ (home / ".pi").mkdir()
204
+ legacy = home / ".pi" / "agent" / "skills"
205
+ legacy.mkdir(parents=True)
206
+ (legacy / "fusion-intake").symlink_to(
207
+ canonical / "fusion-intake", target_is_directory=True)
208
+ stale_copy = legacy / "fusion-planner" # 1.2.0 copy-fallback relic
209
+ stale_copy.mkdir()
210
+ (stale_copy / "SKILL.md").write_text("old payload")
211
+ (stale_copy / ".fusion-setup").write_text("1.0\nabc\n")
212
+ mine = legacy / "fusion-mine" # the user's own, not ours
213
+ mine.mkdir()
214
+ results = setup.fan_out(canonical, setup.detect_agents(home),
215
+ force=False)
216
+ pi = {r["skill"]: r for r in results if r["agent"] == "Pi"}
217
+ assert pi["fusion-intake"]["action"] == "unlinked"
218
+ assert not (legacy / "fusion-intake").exists()
219
+ assert pi["fusion-planner"]["action"] == "unlinked"
220
+ assert not stale_copy.exists()
221
+ assert pi["fusion-mine"]["action"] == "left"
222
+ assert mine.is_dir()
223
+ assert pi["*"]["action"] == "standard" # and Pi reads the std dir
224
+ # no new links were created in the legacy dir
225
+ assert not (legacy / "fusion-analyst").exists()
226
+
227
+
228
+ def test_remove_all_sweeps_legacy_dirs(home, canonical):
229
+ (home / ".codex").mkdir()
230
+ legacy = home / ".codex" / "skills"
231
+ legacy.mkdir(parents=True)
232
+ (legacy / "fusion-intake").symlink_to(
233
+ canonical / "fusion-intake", target_is_directory=True)
234
+ results = setup.remove_all(canonical, home)
235
+ assert not (legacy / "fusion-intake").exists()
236
+ assert any(r["agent"] == "Codex" and r["action"] == "unlinked"
237
+ for r in results)
238
+ assert not (canonical / "fusion-intake").exists()
239
+
240
+
241
+ def test_sweep_legacy_skips_dir_aliased_at_canonical(home, canonical):
242
+ # topology B from the v1.2.1 final review: legacy dir symlinked AT
243
+ # the canonical, plus a canonical entry that is itself a symlink
244
+ # resolving into the canonical — the sweep must not reach through
245
+ # the alias and unlink it
246
+ (home / ".pi").mkdir()
247
+ (home / ".pi" / "agent").mkdir()
248
+ (home / ".pi" / "agent" / "skills").symlink_to(
249
+ canonical, target_is_directory=True)
250
+ store = canonical / "_store" / "fusion-extra"
251
+ store.mkdir(parents=True)
252
+ (store / "SKILL.md").write_text("---\nname: fusion-extra\n---\n")
253
+ user_link = canonical / "fusion-extra"
254
+ user_link.symlink_to(store, target_is_directory=True)
255
+ results = setup.fan_out(canonical, setup.detect_agents(home),
256
+ force=False)
257
+ assert user_link.is_symlink() # the user's link survives
258
+ pi = [r for r in results if r["agent"] == "Pi"]
259
+ assert [r["action"] for r in pi] == ["standard"] # sweep stayed out
260
+ # remove is equally alias-safe
261
+ setup.remove_all(canonical, home)
262
+ assert user_link.is_symlink()
@@ -13,7 +13,7 @@ wheels = [
13
13
 
14
14
  [[package]]
15
15
  name = "fusion-cli"
16
- version = "1.2.0"
16
+ version = "1.3.1"
17
17
  source = { editable = "." }
18
18
  dependencies = [
19
19
  { name = "pyyaml" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes