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.
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/PKG-INFO +7 -4
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/README.md +6 -3
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/pyproject.toml +1 -1
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/src/fusion/cli.py +33 -2
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/src/fusion/setup.py +67 -3
- fusion_cli-1.3.1/src/fusion/update.py +85 -0
- fusion_cli-1.3.1/tests/test_cli_update.py +140 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/tests/test_setup_agents.py +119 -26
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/uv.lock +1 -1
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/.gitignore +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/hatch_build.py +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-analyst/SKILL.md +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-analyst/references/assess.md +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-analyst/references/compare.md +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-analyst/references/export.md +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-analyst/references/fusion-conventions.md +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-analyst/references/report.md +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-analyst/scripts/export.py +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-intake/SKILL.md +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-intake/references/convert.md +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-intake/references/delivery.md +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-intake/references/fusion-conventions.md +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-intake/references/gate.md +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-intake/scripts/convert.py +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-intake/scripts/gate.py +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-intake/tests/conftest.py +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-intake/tests/make_fixtures.py +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-intake/tests/test_convert.py +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-intake/tests/test_gate.py +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-intake/tests/test_integration.py +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-librarian/SKILL.md +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-librarian/references/archive.md +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-librarian/references/create.md +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-librarian/references/cross-reference.md +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-librarian/references/fusion-conventions.md +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-librarian/references/promote.md +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-librarian/references/query.md +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-librarian/references/reflect.md +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-librarian/references/restructure.md +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-librarian/references/tag.md +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-librarian/scripts/link-repair.py +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-librarian/tests/conftest.py +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-librarian/tests/test_link_repair.py +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-planner/SKILL.md +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-planner/references/close.md +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-planner/references/create-activity.md +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-planner/references/fusion-conventions.md +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-planner/references/horizon.md +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/src/fusion/__init__.py +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/src/fusion/bucket.py +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/src/fusion/checker.py +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/src/fusion/document.py +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/src/fusion/hub.py +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/src/fusion/indexer.py +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/src/fusion/ledger.py +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/src/fusion/manifest.py +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/src/fusion/scaffold.py +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/src/fusion/views.py +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/tests/__init__.py +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/tests/conftest.py +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/tests/test_build_payload.py +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/tests/test_checker_errors.py +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/tests/test_checker_warnings.py +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/tests/test_cli.py +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/tests/test_cli_encoding.py +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/tests/test_cli_setup.py +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/tests/test_cli_version.py +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/tests/test_document.py +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/tests/test_hub_bucket.py +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/tests/test_indexer.py +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/tests/test_ledger.py +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/tests/test_line_endings.py +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/tests/test_roundtrip.py +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/tests/test_scaffold.py +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/tests/test_setup_core.py +0 -0
- {fusion_cli-1.2.0 → fusion_cli-1.3.1}/tests/test_skill_family.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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)
|
|
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).
|
|
@@ -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
|
|
408
|
-
"
|
|
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": ".
|
|
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": ".
|
|
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
|
-
|
|
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 / ".
|
|
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
|
-
|
|
107
|
-
assert {r["action"] for r in
|
|
108
|
-
copied = home / ".
|
|
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
|
-
|
|
121
|
-
assert
|
|
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 / ".
|
|
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 / ".
|
|
146
|
-
assert (ours / ".fusion-setup").is_file()
|
|
156
|
+
ours = home / ".claude" / "skills" / "fusion-intake"
|
|
157
|
+
assert (ours / ".fusion-setup").is_file() # our sentinel-marked copy
|
|
147
158
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-analyst/references/fusion-conventions.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-librarian/references/fusion-conventions.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fusion_cli-1.2.0 → fusion_cli-1.3.1}/skills/fusion-planner/references/fusion-conventions.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|