fusion-cli 1.2.0__tar.gz → 1.2.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 (75) hide show
  1. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/PKG-INFO +2 -2
  2. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/README.md +1 -1
  3. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/pyproject.toml +1 -1
  4. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/src/fusion/cli.py +4 -2
  5. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/src/fusion/setup.py +67 -3
  6. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/tests/test_setup_agents.py +119 -26
  7. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/uv.lock +1 -1
  8. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/.gitignore +0 -0
  9. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/hatch_build.py +0 -0
  10. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/skills/fusion-analyst/SKILL.md +0 -0
  11. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/skills/fusion-analyst/references/assess.md +0 -0
  12. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/skills/fusion-analyst/references/compare.md +0 -0
  13. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/skills/fusion-analyst/references/export.md +0 -0
  14. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/skills/fusion-analyst/references/fusion-conventions.md +0 -0
  15. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/skills/fusion-analyst/references/report.md +0 -0
  16. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/skills/fusion-analyst/scripts/export.py +0 -0
  17. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/skills/fusion-intake/SKILL.md +0 -0
  18. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/skills/fusion-intake/references/convert.md +0 -0
  19. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/skills/fusion-intake/references/delivery.md +0 -0
  20. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/skills/fusion-intake/references/fusion-conventions.md +0 -0
  21. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/skills/fusion-intake/references/gate.md +0 -0
  22. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/skills/fusion-intake/scripts/convert.py +0 -0
  23. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/skills/fusion-intake/scripts/gate.py +0 -0
  24. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/skills/fusion-intake/tests/conftest.py +0 -0
  25. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/skills/fusion-intake/tests/make_fixtures.py +0 -0
  26. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/skills/fusion-intake/tests/test_convert.py +0 -0
  27. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/skills/fusion-intake/tests/test_gate.py +0 -0
  28. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/skills/fusion-intake/tests/test_integration.py +0 -0
  29. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/skills/fusion-librarian/SKILL.md +0 -0
  30. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/skills/fusion-librarian/references/archive.md +0 -0
  31. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/skills/fusion-librarian/references/create.md +0 -0
  32. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/skills/fusion-librarian/references/cross-reference.md +0 -0
  33. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/skills/fusion-librarian/references/fusion-conventions.md +0 -0
  34. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/skills/fusion-librarian/references/promote.md +0 -0
  35. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/skills/fusion-librarian/references/query.md +0 -0
  36. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/skills/fusion-librarian/references/reflect.md +0 -0
  37. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/skills/fusion-librarian/references/restructure.md +0 -0
  38. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/skills/fusion-librarian/references/tag.md +0 -0
  39. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/skills/fusion-librarian/scripts/link-repair.py +0 -0
  40. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/skills/fusion-librarian/tests/conftest.py +0 -0
  41. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/skills/fusion-librarian/tests/test_link_repair.py +0 -0
  42. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/skills/fusion-planner/SKILL.md +0 -0
  43. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/skills/fusion-planner/references/close.md +0 -0
  44. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/skills/fusion-planner/references/create-activity.md +0 -0
  45. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/skills/fusion-planner/references/fusion-conventions.md +0 -0
  46. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/skills/fusion-planner/references/horizon.md +0 -0
  47. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/src/fusion/__init__.py +0 -0
  48. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/src/fusion/bucket.py +0 -0
  49. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/src/fusion/checker.py +0 -0
  50. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/src/fusion/document.py +0 -0
  51. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/src/fusion/hub.py +0 -0
  52. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/src/fusion/indexer.py +0 -0
  53. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/src/fusion/ledger.py +0 -0
  54. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/src/fusion/manifest.py +0 -0
  55. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/src/fusion/scaffold.py +0 -0
  56. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/src/fusion/views.py +0 -0
  57. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/tests/__init__.py +0 -0
  58. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/tests/conftest.py +0 -0
  59. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/tests/test_build_payload.py +0 -0
  60. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/tests/test_checker_errors.py +0 -0
  61. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/tests/test_checker_warnings.py +0 -0
  62. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/tests/test_cli.py +0 -0
  63. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/tests/test_cli_encoding.py +0 -0
  64. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/tests/test_cli_setup.py +0 -0
  65. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/tests/test_cli_version.py +0 -0
  66. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/tests/test_document.py +0 -0
  67. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/tests/test_hub_bucket.py +0 -0
  68. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/tests/test_indexer.py +0 -0
  69. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/tests/test_ledger.py +0 -0
  70. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/tests/test_line_endings.py +0 -0
  71. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/tests/test_roundtrip.py +0 -0
  72. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/tests/test_scaffold.py +0 -0
  73. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/tests/test_setup_core.py +0 -0
  74. {fusion_cli-1.2.0 → fusion_cli-1.2.1}/tests/test_skill_family.py +0 -0
  75. {fusion_cli-1.2.0 → fusion_cli-1.2.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.2.1
4
4
  Summary: The Fusion reference CLI — the notary of the Fusion Convention.
5
5
  Author: Bluewaves Boutique
6
6
  License-Expression: MIT
@@ -50,7 +50,7 @@ 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
54
 
55
55
  Every command takes `--json` (agents parse, never scrape). `log`, `new`,
56
56
  and `index` take `--as <actor>`; the pen defaults to `FUSION_ACTOR`, then
@@ -40,7 +40,7 @@ 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
44
 
45
45
  Every command takes `--json` (agents parse, never scrape). `log`, `new`,
46
46
  and `index` take `--as <actor>`; the pen defaults to `FUSION_ACTOR`, then
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fusion-cli"
3
- version = "1.2.0"
3
+ version = "1.2.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"
@@ -404,8 +404,10 @@ def build_parser() -> argparse.ArgumentParser:
404
404
 
405
405
  p = sub.add_parser("setup", help="install skills into agents; --remove undoes it",
406
406
  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.")
407
+ "skills directory (~/.agents/skills) and make them available "
408
+ "to every detected agent most read that directory "
409
+ "natively; agents that do not (Claude Code) get symlinks. "
410
+ "--remove undoes exactly what setup can prove it created.")
409
411
  p.add_argument("--force", action="store_true",
410
412
  help="replace foreign entries setup would otherwise leave")
411
413
  p.add_argument("--remove", action="store_true",
@@ -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:
@@ -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.2.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