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