boost-skill-cli 1.0.1__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.
@@ -0,0 +1,247 @@
1
+ """The canonical store (~/.agents/skills) and agent symlinks.
2
+
3
+ install(): copy skill dir from a tap clone -> store, symlink into every
4
+ enabled agent dir, record in the lock file, log to the journal.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import shutil
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from typing import Dict, List, Optional
12
+
13
+ from ..errors import BoostError
14
+ from . import agents, journal, lockfile, paths, policy, registry, util
15
+
16
+
17
+ @dataclass
18
+ class InstallResult:
19
+ name: str
20
+ dest: Path
21
+ linked: List[str] = field(default_factory=list)
22
+ conflicts: List[str] = field(default_factory=list)
23
+ score: int = 0
24
+ upgraded: bool = False
25
+
26
+
27
+ def skill_store_dir(name: str) -> Path:
28
+ return paths.store_dir() / name
29
+
30
+
31
+ def installed() -> dict:
32
+ return lockfile.installed()
33
+
34
+
35
+ def source_dir_for(entry: dict) -> Path:
36
+ """Absolute path of a catalog entry's skill dir inside its tap clone."""
37
+ tap = registry.get(entry["tap"])
38
+ src = tap.path if entry["rel_dir"] == "." else tap.path / entry["rel_dir"]
39
+ if not (src / "SKILL.md").exists():
40
+ raise BoostError("source for %s vanished from tap %s" % (entry["name"], tap.name),
41
+ hint="run `boost update %s`" % tap.name)
42
+ return src
43
+
44
+
45
+ def link_agents(name: str, only: Optional[List[str]] = None) -> InstallResult:
46
+ """Symlink store/<name> into each enabled agent dir. Returns result with
47
+ .linked (agent names) and .conflicts (paths that were real files/dirs)."""
48
+ res = InstallResult(name=name, dest=skill_store_dir(name))
49
+ target = skill_store_dir(name)
50
+ for agent, adir in agents.enabled_agents().items():
51
+ if only and agent not in only:
52
+ continue
53
+ adir.mkdir(parents=True, exist_ok=True)
54
+ link = adir / name
55
+ if link.is_symlink():
56
+ link.unlink()
57
+ elif link.exists():
58
+ res.conflicts.append(str(link))
59
+ continue
60
+ link.symlink_to(target)
61
+ res.linked.append(agent)
62
+ return res
63
+
64
+
65
+ def unlink_agents(name: str) -> List[str]:
66
+ removed = []
67
+ for agent, adir in agents.enabled_agents().items():
68
+ link = adir / name
69
+ if link.is_symlink():
70
+ link.unlink()
71
+ removed.append(agent)
72
+ return removed
73
+
74
+
75
+ def _copy_skill(src: Path, dest: Path) -> None:
76
+ if dest.exists():
77
+ shutil.rmtree(dest)
78
+ shutil.copytree(src, dest,
79
+ ignore=shutil.ignore_patterns(".git", "__pycache__", ".DS_Store"))
80
+
81
+
82
+ def install(entry: dict, force: bool = False,
83
+ only_agents: Optional[List[str]] = None) -> InstallResult:
84
+ """Install a catalog entry. Raises BoostError on policy block or conflict."""
85
+ name = entry["name"]
86
+ existing = lockfile.get_skill(name)
87
+ if existing and existing.get("pinned") and not force:
88
+ raise BoostError("%s is pinned" % name, hint="`boost unpin %s` first" % name)
89
+ if existing and not force:
90
+ raise BoostError("%s is already installed (v%s)" % (name, existing.get("version")),
91
+ hint="`boost reinstall %s` to force, `boost update` to upgrade" % name)
92
+
93
+ violations = policy.check_install(entry, len(lockfile.installed()))
94
+ if violations:
95
+ raise BoostError("policy blocks installing %s: %s" % (name, "; ".join(violations)),
96
+ hint="inspect with `boost policy list`")
97
+
98
+ src = source_dir_for(entry)
99
+ dest = skill_store_dir(name)
100
+ paths.ensure_dirs()
101
+ _copy_skill(src, dest)
102
+
103
+ res = link_agents(name, only=only_agents)
104
+ res.upgraded = existing is not None
105
+ res.score, _ = util.score_skill(dest)
106
+
107
+ tap = registry.get(entry["tap"])
108
+ from . import gitutil
109
+ now = util.now_iso()
110
+ lockfile.set_skill(name, {
111
+ "version": entry.get("version", "0.0.0"),
112
+ "tap": entry["tap"],
113
+ "source_dir": entry.get("rel_dir", "."),
114
+ "commit": gitutil.head_commit(tap.path),
115
+ "sha256": util.sha256_dir(dest),
116
+ "installed_at": (existing or {}).get("installed_at", now),
117
+ "updated_at": now,
118
+ "pinned": bool((existing or {}).get("pinned")),
119
+ "quarantined": False,
120
+ "agents": res.linked,
121
+ "tags": (existing or {}).get("tags", []),
122
+ })
123
+ journal.log("install", name, tap=entry["tap"], version=entry.get("version"))
124
+ return res
125
+
126
+
127
+ def install_from_path(src_dir: Path, name: Optional[str] = None,
128
+ tap_label: str = "local",
129
+ only_agents: Optional[List[str]] = None) -> InstallResult:
130
+ """Install directly from a local directory (used by `boost import`)."""
131
+ src_dir = Path(src_dir)
132
+ if not (src_dir / "SKILL.md").exists():
133
+ raise BoostError("%s has no SKILL.md" % src_dir)
134
+ from . import frontmatter
135
+ meta, _ = frontmatter.parse((src_dir / "SKILL.md").read_text(
136
+ encoding="utf-8", errors="replace"))
137
+ name = name or str(meta.get("name") or src_dir.name)
138
+ dest = skill_store_dir(name)
139
+ paths.ensure_dirs()
140
+ _copy_skill(src_dir, dest)
141
+ res = link_agents(name, only=only_agents)
142
+ res.score, _ = util.score_skill(dest)
143
+ now = util.now_iso()
144
+ existing = lockfile.get_skill(name)
145
+ lockfile.set_skill(name, {
146
+ "version": str(meta.get("version") or "0.0.0"),
147
+ "tap": tap_label,
148
+ "source_dir": str(src_dir),
149
+ "commit": "",
150
+ "sha256": util.sha256_dir(dest),
151
+ "installed_at": (existing or {}).get("installed_at", now),
152
+ "updated_at": now,
153
+ "pinned": False,
154
+ "quarantined": False,
155
+ "agents": res.linked,
156
+ "tags": (existing or {}).get("tags", []),
157
+ })
158
+ journal.log("import", name, source=str(src_dir))
159
+ return res
160
+
161
+
162
+ def uninstall(name: str) -> dict:
163
+ entry = lockfile.get_skill(name)
164
+ if not entry:
165
+ raise BoostError("%s is not installed" % name,
166
+ hint="see what is with `boost list`")
167
+ removed_links = unlink_agents(name)
168
+ dest = skill_store_dir(name)
169
+ if dest.exists():
170
+ shutil.rmtree(dest)
171
+ lockfile.remove_skill(name)
172
+ journal.log("uninstall", name)
173
+ return {"name": name, "unlinked": removed_links, "entry": entry}
174
+
175
+
176
+ def sync_plan() -> Dict[str, list]:
177
+ """Compare lock file <-> store <-> agent symlinks.
178
+
179
+ Returns {missing_store, missing_links, stale_links, orphaned_store}
180
+ missing_store: lock entries whose store dir is gone
181
+ missing_links: (skill, agent) pairs that should be linked but aren't
182
+ stale_links: paths in agent dirs that are broken/unmanaged symlinks
183
+ orphaned_store: store dirs not present in the lock file
184
+ """
185
+ lock = lockfile.installed()
186
+ plan: dict[str, list] = {"missing_store": [], "missing_links": [],
187
+ "stale_links": [], "orphaned_store": []}
188
+ for name, entry in lock.items():
189
+ sdir = skill_store_dir(name)
190
+ if not sdir.is_dir():
191
+ plan["missing_store"].append(name)
192
+ continue
193
+ if entry.get("quarantined"):
194
+ continue
195
+ for agent, adir in agents.enabled_agents().items():
196
+ link = adir / name
197
+ if not link.is_symlink() or not link.exists():
198
+ plan["missing_links"].append((name, agent))
199
+ store_root = paths.store_dir()
200
+ if store_root.is_dir():
201
+ for child in store_root.iterdir():
202
+ if child.is_dir() and child.name not in lock:
203
+ plan["orphaned_store"].append(child.name)
204
+ for agent, adir in agents.enabled_agents().items():
205
+ if not adir.is_dir():
206
+ continue
207
+ for link in adir.iterdir():
208
+ if link.is_symlink():
209
+ points_into_store = str(paths.store_dir()) in str(
210
+ link.resolve() if link.exists() else link.readlink())
211
+ if not link.exists() or (points_into_store and link.name not in lock):
212
+ plan["stale_links"].append(str(link))
213
+ return plan
214
+
215
+
216
+ def sync_apply(plan: Dict[str, list]) -> List[str]:
217
+ """Fix what sync_plan found. Returns human-readable actions taken."""
218
+ actions = []
219
+ for name, agent in plan["missing_links"]:
220
+ res = link_agents(name, only=[agent])
221
+ if agent in res.linked:
222
+ actions.append("linked %s → %s" % (name, agent))
223
+ for path in plan["stale_links"]:
224
+ p = Path(path)
225
+ if p.is_symlink():
226
+ p.unlink()
227
+ actions.append("removed stale link %s" % path)
228
+ for name in plan["missing_store"]:
229
+ entry = lockfile.get_skill(name) or {}
230
+ tap_name = entry.get("tap")
231
+ restored = False
232
+ if tap_name and tap_name != "local":
233
+ try:
234
+ from . import catalog
235
+ matches = [e for e in catalog.find(name) if e["tap"] == tap_name]
236
+ if matches:
237
+ install(matches[0], force=True)
238
+ actions.append("reinstalled missing %s from %s" % (name, tap_name))
239
+ restored = True
240
+ except BoostError:
241
+ pass
242
+ if not restored:
243
+ lockfile.remove_skill(name)
244
+ actions.append("dropped %s from lock (store dir missing, source gone)" % name)
245
+ if actions:
246
+ journal.log("sync", "%d fixes" % len(actions))
247
+ return actions
boost_cli/core/util.py ADDED
@@ -0,0 +1,139 @@
1
+ """Small shared utilities: time, hashing, versions, skill quality scoring."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import re
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from typing import List, Tuple
9
+
10
+ IGNORED = {".git", "__pycache__", ".DS_Store"}
11
+
12
+
13
+ def now_iso() -> str:
14
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
15
+
16
+
17
+ def rel_time(iso: str) -> str:
18
+ """'2026-07-16T01:00:00Z' -> '3h ago' (best effort)."""
19
+ try:
20
+ then = datetime.strptime(iso, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
21
+ except (ValueError, TypeError):
22
+ return iso or "?"
23
+ secs = (datetime.now(timezone.utc) - then).total_seconds()
24
+ for limit, size, unit in ((60, 1, "s"), (3600, 60, "m"),
25
+ (86400, 3600, "h"), (604800, 86400, "d")):
26
+ if secs < limit:
27
+ return "%d%s ago" % (max(1, secs // size), unit)
28
+ if secs < 604800 * 8:
29
+ return "%dw ago" % (secs // 604800)
30
+ return then.strftime("%Y-%m-%d")
31
+
32
+
33
+ def human_size(n: int) -> str:
34
+ size = float(n)
35
+ for unit in ("B", "KB", "MB", "GB"):
36
+ if size < 1024 or unit == "GB":
37
+ return ("%d%s" if unit == "B" else "%.1f%s") % (size, unit)
38
+ size /= 1024.0
39
+ return str(size)
40
+
41
+
42
+ def slugify(name: str) -> str:
43
+ return re.sub(r"[^a-z0-9-]+", "-", name.strip().lower()).strip("-") or "skill"
44
+
45
+
46
+ def sha256_dir(path: Path) -> str:
47
+ """Deterministic content hash of a directory tree (paths + bytes)."""
48
+ h = hashlib.sha256()
49
+ root = Path(path)
50
+ files = sorted(
51
+ p for p in root.rglob("*")
52
+ if p.is_file() and not any(part in IGNORED for part in p.parts)
53
+ )
54
+ for f in files:
55
+ h.update(str(f.relative_to(root)).encode())
56
+ h.update(b"\0")
57
+ h.update(f.read_bytes())
58
+ h.update(b"\0")
59
+ return h.hexdigest()
60
+
61
+
62
+ def dir_size(path: Path) -> int:
63
+ return sum(p.stat().st_size for p in Path(path).rglob("*") if p.is_file())
64
+
65
+
66
+ def semver_tuple(v: str):
67
+ parts = re.findall(r"\d+", str(v or "0"))[:3]
68
+ return tuple(int(p) for p in parts) + (0,) * (3 - len(parts))
69
+
70
+
71
+ def semver_gt(a: str, b: str) -> bool:
72
+ return semver_tuple(a) > semver_tuple(b)
73
+
74
+
75
+ def score_skill(skill_dir: Path) -> Tuple[int, List[str]]:
76
+ """Heuristic quality score 0-100 for a skill directory, with notes.
77
+
78
+ Shared by `boost install` (display), `boost lint`, and `boost test`.
79
+ """
80
+ from . import frontmatter
81
+
82
+ skill_md = Path(skill_dir) / "SKILL.md"
83
+ notes: List[str] = []
84
+ if not skill_md.exists():
85
+ return 0, ["missing SKILL.md"]
86
+ try:
87
+ text = skill_md.read_text(encoding="utf-8", errors="replace")
88
+ except OSError as e:
89
+ return 0, ["unreadable SKILL.md: %s" % e]
90
+ meta, body = frontmatter.parse(text)
91
+ score = 20 # exists and parses
92
+
93
+ if meta.get("name"):
94
+ score += 10
95
+ else:
96
+ notes.append("frontmatter missing `name`")
97
+ desc = str(meta.get("description") or "")
98
+ if desc:
99
+ score += 10
100
+ if len(desc) >= 40:
101
+ score += 5
102
+ else:
103
+ notes.append("description is thin (<40 chars)")
104
+ else:
105
+ notes.append("frontmatter missing `description`")
106
+ if meta.get("version"):
107
+ score += 10
108
+ if not re.match(r"^\d+\.\d+(\.\d+)?", str(meta["version"])):
109
+ score -= 5
110
+ notes.append("version is not semver-ish")
111
+ else:
112
+ notes.append("frontmatter missing `version`")
113
+
114
+ if len(body.strip()) >= 200:
115
+ score += 15
116
+ else:
117
+ notes.append("body is short (<200 chars)")
118
+ if re.search(r"^#{1,3} ", body, re.M):
119
+ score += 10
120
+ else:
121
+ notes.append("no markdown headings in body")
122
+ if "```" in body or re.search(r"^\d+\. ", body, re.M) or re.search(r"^- ", body, re.M):
123
+ score += 10 # concrete steps or examples
124
+ else:
125
+ notes.append("no examples, steps, or code blocks")
126
+ if not re.search(r"\bTODO\b|\bFIXME\b", body):
127
+ score += 5
128
+ else:
129
+ notes.append("contains TODO/FIXME")
130
+ if meta.get("license") or (Path(skill_dir) / "LICENSE").exists():
131
+ score += 5
132
+ extras = [p for p in Path(skill_dir).iterdir()
133
+ if p.name not in IGNORED and p.name != "SKILL.md"]
134
+ if extras:
135
+ score += 5 # ships supporting references/scripts
136
+ if len(text) > 48_000:
137
+ score -= 10
138
+ notes.append("very large SKILL.md (>48KB) — consider splitting")
139
+ return max(0, min(100, score)), notes
boost_cli/errors.py ADDED
@@ -0,0 +1,11 @@
1
+ """User-facing errors."""
2
+ from __future__ import annotations
3
+
4
+
5
+ class BoostError(Exception):
6
+ """An expected, user-facing failure. Printed without a traceback."""
7
+
8
+ def __init__(self, message: str, hint: str | None = None):
9
+ super().__init__(message)
10
+ self.message = message
11
+ self.hint = hint