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.
- boost_cli/__init__.py +5 -0
- boost_cli/__main__.py +8 -0
- boost_cli/cli.py +213 -0
- boost_cli/commands/__init__.py +0 -0
- boost_cli/commands/configuration.py +1087 -0
- boost_cli/commands/discovery.py +650 -0
- boost_cli/commands/info.py +595 -0
- boost_cli/commands/intelligence.py +1048 -0
- boost_cli/commands/pkg.py +828 -0
- boost_cli/commands/quality.py +1041 -0
- boost_cli/commands/taps.py +203 -0
- boost_cli/commands/team.py +679 -0
- boost_cli/core/__init__.py +0 -0
- boost_cli/core/agents.py +33 -0
- boost_cli/core/ai.py +115 -0
- boost_cli/core/catalog.py +144 -0
- boost_cli/core/config.py +112 -0
- boost_cli/core/frontmatter.py +160 -0
- boost_cli/core/gitutil.py +69 -0
- boost_cli/core/journal.py +70 -0
- boost_cli/core/lockfile.py +116 -0
- boost_cli/core/output.py +99 -0
- boost_cli/core/paths.py +111 -0
- boost_cli/core/policy.py +63 -0
- boost_cli/core/registry.py +112 -0
- boost_cli/core/store.py +247 -0
- boost_cli/core/util.py +139 -0
- boost_cli/errors.py +11 -0
- boost_skill_cli-1.0.1.dist-info/METADATA +831 -0
- boost_skill_cli-1.0.1.dist-info/RECORD +34 -0
- boost_skill_cli-1.0.1.dist-info/WHEEL +5 -0
- boost_skill_cli-1.0.1.dist-info/entry_points.txt +2 -0
- boost_skill_cli-1.0.1.dist-info/licenses/LICENSE +675 -0
- boost_skill_cli-1.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,828 @@
|
|
|
1
|
+
"""Package Management commands (12): install, uninstall, sync, update,
|
|
2
|
+
reinstall, bundle, import, migrate, pin, unpin, snapshot, export."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import sys
|
|
11
|
+
import tarfile
|
|
12
|
+
import tempfile
|
|
13
|
+
import zipfile
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Dict, List, Optional
|
|
17
|
+
|
|
18
|
+
from ..core import (agents, catalog, gitutil, journal, lockfile, paths,
|
|
19
|
+
registry, store, util)
|
|
20
|
+
from ..core import output as out
|
|
21
|
+
from ..errors import BoostError
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _tilde(p) -> str:
|
|
25
|
+
"""Contract $HOME to ~ for display (also matches the resolved home,
|
|
26
|
+
so /private/var vs /var symlinks on macOS still contract)."""
|
|
27
|
+
s = str(p)
|
|
28
|
+
for h in (str(paths.home()), str(paths.home().resolve())):
|
|
29
|
+
if s == h or s.startswith(h + os.sep):
|
|
30
|
+
return "~" + s[len(h):]
|
|
31
|
+
return s
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _plural(n: int, word: str) -> str:
|
|
35
|
+
return "%d %s%s" % (n, word, "" if n == 1 else "s")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _check_agents(names: Optional[List[str]]) -> Optional[List[str]]:
|
|
39
|
+
"""Validate --agent values against configured agents."""
|
|
40
|
+
if not names:
|
|
41
|
+
return None
|
|
42
|
+
known = agents.known_agents()
|
|
43
|
+
bad = [n for n in names if n not in known]
|
|
44
|
+
if bad:
|
|
45
|
+
raise BoostError("unknown agent%s: %s" % ("s" if len(bad) > 1 else "",
|
|
46
|
+
", ".join(bad)),
|
|
47
|
+
hint="known agents: %s" % ", ".join(known))
|
|
48
|
+
return names
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _report_result(res: store.InstallResult) -> None:
|
|
52
|
+
"""The canonical three-line install report."""
|
|
53
|
+
out.ok("copied to %s" % _tilde(res.dest))
|
|
54
|
+
if res.linked:
|
|
55
|
+
out.ok("linked → %s" % " · ".join(res.linked))
|
|
56
|
+
else:
|
|
57
|
+
out.warn("no agent links created (no enabled agents?)")
|
|
58
|
+
for path in res.conflicts:
|
|
59
|
+
out.warn("not linked: %s exists and is not managed by boost" % _tilde(path))
|
|
60
|
+
out.ok("lock updated (.skill-lock.json)")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _boostfile_text(skills: Dict[str, dict], via: str = "boost bundle dump") -> str:
|
|
64
|
+
"""Render lock entries as a Boostfile (taps first, then sorted skills)."""
|
|
65
|
+
lines = ["# Boostfile — generated by %s" % via]
|
|
66
|
+
taps = sorted({e.get("tap") or "" for e in skills.values()} - {"", "local"})
|
|
67
|
+
for tname in taps:
|
|
68
|
+
try:
|
|
69
|
+
url = registry.get(tname).url
|
|
70
|
+
except BoostError:
|
|
71
|
+
url = ""
|
|
72
|
+
lines.append(("tap %s %s" % (tname, url)).rstrip())
|
|
73
|
+
for name in sorted(skills):
|
|
74
|
+
e = skills[name]
|
|
75
|
+
if (e.get("tap") or "local") == "local":
|
|
76
|
+
lines.append("# local skill (no tap source): %s" % name)
|
|
77
|
+
else:
|
|
78
|
+
lines.append("skill %s:%s@%s" % (e["tap"], name,
|
|
79
|
+
e.get("version", "0.0.0")))
|
|
80
|
+
return "\n".join(lines) + "\n"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ── install ──────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
def cmd_install(argv: List[str]) -> int:
|
|
86
|
+
ap = argparse.ArgumentParser(prog="boost install",
|
|
87
|
+
description="Install a skill from a tap registry")
|
|
88
|
+
ap.add_argument("names", nargs="+", metavar="NAME",
|
|
89
|
+
help="skill name, optionally qualified as tap:skill")
|
|
90
|
+
ap.add_argument("--force", action="store_true",
|
|
91
|
+
help="reinstall even if already installed or pinned")
|
|
92
|
+
ap.add_argument("--agent", action="append", metavar="A",
|
|
93
|
+
help="link only into this agent (repeatable)")
|
|
94
|
+
ap.add_argument("--dry-run", action="store_true",
|
|
95
|
+
help="show what would happen without changing anything")
|
|
96
|
+
args = ap.parse_args(argv)
|
|
97
|
+
only = _check_agents(args.agent)
|
|
98
|
+
multi = len(args.names) > 1
|
|
99
|
+
entries, failed = [], 0
|
|
100
|
+
for n in args.names:
|
|
101
|
+
try:
|
|
102
|
+
entries.append(catalog.resolve_one(n))
|
|
103
|
+
except BoostError as err:
|
|
104
|
+
if not multi:
|
|
105
|
+
raise
|
|
106
|
+
out.warn("%s: %s" % (n, err.message))
|
|
107
|
+
failed += 1
|
|
108
|
+
|
|
109
|
+
if args.dry_run:
|
|
110
|
+
targets = [a for a in agents.enabled_agents() if not only or a in only]
|
|
111
|
+
for e in entries:
|
|
112
|
+
verb = "upgrade" if lockfile.get_skill(e["name"]) else "install"
|
|
113
|
+
out.info("would %s %s v%s from %s" % (verb, e["name"],
|
|
114
|
+
e["version"], e["tap"]))
|
|
115
|
+
out.info(" copy %s → %s" % (_tilde(store.source_dir_for(e)),
|
|
116
|
+
_tilde(store.skill_store_dir(e["name"]))))
|
|
117
|
+
out.info(" link → %s" % (" · ".join(targets) or "(no enabled agents)"))
|
|
118
|
+
out.info("dry run — nothing was changed")
|
|
119
|
+
return 1 if failed else 0
|
|
120
|
+
|
|
121
|
+
results = []
|
|
122
|
+
for e in entries:
|
|
123
|
+
if multi:
|
|
124
|
+
out.heading("%s v%s (%s)" % (e["name"], e["version"], e["tap"]))
|
|
125
|
+
try:
|
|
126
|
+
res = store.install(e, force=args.force, only_agents=only)
|
|
127
|
+
except BoostError as err:
|
|
128
|
+
if not multi:
|
|
129
|
+
raise
|
|
130
|
+
out.warn("%s: %s" % (e["name"], err.message))
|
|
131
|
+
failed += 1
|
|
132
|
+
continue
|
|
133
|
+
_report_result(res)
|
|
134
|
+
results.append(res)
|
|
135
|
+
if results:
|
|
136
|
+
new = sum(1 for r in results if not r.upgraded)
|
|
137
|
+
parts = []
|
|
138
|
+
if new:
|
|
139
|
+
parts.append("Installed %s" % _plural(new, "new skill"))
|
|
140
|
+
if len(results) - new:
|
|
141
|
+
parts.append("Upgraded %s" % _plural(len(results) - new, "skill"))
|
|
142
|
+
avg = round(sum(r.score for r in results) / len(results))
|
|
143
|
+
out.info("%s; quality score %d/100" % ("; ".join(parts), avg))
|
|
144
|
+
return 1 if failed else 0
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ── uninstall ────────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
def cmd_uninstall(argv: List[str]) -> int:
|
|
150
|
+
ap = argparse.ArgumentParser(
|
|
151
|
+
prog="boost uninstall",
|
|
152
|
+
description="Remove an installed skill, rule, workflow, or config")
|
|
153
|
+
ap.add_argument("names", nargs="+", metavar="NAME")
|
|
154
|
+
args = ap.parse_args(argv)
|
|
155
|
+
removed, failed = 0, 0
|
|
156
|
+
for name in args.names:
|
|
157
|
+
dest = store.skill_store_dir(name)
|
|
158
|
+
try:
|
|
159
|
+
info = store.uninstall(name)
|
|
160
|
+
except BoostError as err:
|
|
161
|
+
if len(args.names) == 1:
|
|
162
|
+
raise
|
|
163
|
+
out.warn("%s: %s" % (name, err.message))
|
|
164
|
+
failed += 1
|
|
165
|
+
continue
|
|
166
|
+
if info["unlinked"]:
|
|
167
|
+
out.ok("unlinked ← %s" % " · ".join(info["unlinked"]))
|
|
168
|
+
out.ok("removed %s" % _tilde(dest))
|
|
169
|
+
removed += 1
|
|
170
|
+
if removed:
|
|
171
|
+
out.info("Uninstalled %s" % _plural(removed, "skill"))
|
|
172
|
+
return 1 if failed else 0
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ── sync ─────────────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
_PLAN_LABELS = [
|
|
178
|
+
("missing_store", "missing from store"),
|
|
179
|
+
("missing_links", "missing agent links"),
|
|
180
|
+
("stale_links", "stale links"),
|
|
181
|
+
("orphaned_store", "orphaned store dirs"),
|
|
182
|
+
]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def cmd_sync(argv: List[str]) -> int:
|
|
186
|
+
ap = argparse.ArgumentParser(
|
|
187
|
+
prog="boost sync",
|
|
188
|
+
description="Reconcile installed skills & symlinks against the lock file")
|
|
189
|
+
ap.add_argument("--diff", action="store_true",
|
|
190
|
+
help="show the plan without applying it")
|
|
191
|
+
ap.add_argument("--prune", action="store_true",
|
|
192
|
+
help="also delete orphaned store dirs")
|
|
193
|
+
ap.add_argument("--json", action="store_true", help="machine-readable output")
|
|
194
|
+
args = ap.parse_args(argv)
|
|
195
|
+
plan = store.sync_plan()
|
|
196
|
+
|
|
197
|
+
if args.diff:
|
|
198
|
+
if args.json:
|
|
199
|
+
print(json.dumps(plan, indent=2))
|
|
200
|
+
return 0
|
|
201
|
+
if not any(plan.values()):
|
|
202
|
+
out.ok("everything in sync")
|
|
203
|
+
return 0
|
|
204
|
+
for key, label in _PLAN_LABELS:
|
|
205
|
+
if not plan[key]:
|
|
206
|
+
continue
|
|
207
|
+
out.heading("%s (%d)" % (label, len(plan[key])))
|
|
208
|
+
for item in plan[key]:
|
|
209
|
+
if key == "missing_links":
|
|
210
|
+
out.info("%s → %s" % (item[0], item[1]))
|
|
211
|
+
else:
|
|
212
|
+
out.info(_tilde(item))
|
|
213
|
+
return 0
|
|
214
|
+
|
|
215
|
+
actions = store.sync_apply(plan)
|
|
216
|
+
orphans = plan["orphaned_store"]
|
|
217
|
+
pruned = []
|
|
218
|
+
if args.prune and orphans:
|
|
219
|
+
go = (bool(os.environ.get("BOOST_ASSUME_YES")) if args.json else
|
|
220
|
+
out.confirm("Delete %s: %s?" % (_plural(len(orphans), "orphaned store dir"),
|
|
221
|
+
", ".join(orphans))))
|
|
222
|
+
if go:
|
|
223
|
+
for name in orphans:
|
|
224
|
+
target = store.skill_store_dir(name)
|
|
225
|
+
if target.is_dir():
|
|
226
|
+
shutil.rmtree(target)
|
|
227
|
+
journal.log("prune", name)
|
|
228
|
+
pruned.append(name)
|
|
229
|
+
left = [n for n in orphans if n not in pruned]
|
|
230
|
+
if args.json:
|
|
231
|
+
print(json.dumps({"actions": actions, "pruned": pruned,
|
|
232
|
+
"orphaned_store": left}))
|
|
233
|
+
return 0
|
|
234
|
+
for a in actions:
|
|
235
|
+
out.ok(a)
|
|
236
|
+
for name in pruned:
|
|
237
|
+
out.ok("pruned %s" % _tilde(store.skill_store_dir(name)))
|
|
238
|
+
if left:
|
|
239
|
+
out.warn("%s left in place: %s — remove with `boost sync --prune`"
|
|
240
|
+
% (_plural(len(left), "orphaned store dir"), ", ".join(left)))
|
|
241
|
+
if not actions and not pruned and not left:
|
|
242
|
+
out.ok("everything in sync")
|
|
243
|
+
return 0
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# ── update ───────────────────────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
def cmd_update(argv: List[str]) -> int:
|
|
249
|
+
ap = argparse.ArgumentParser(prog="boost update",
|
|
250
|
+
description="Sync taps or update installed skills")
|
|
251
|
+
ap.add_argument("tap", nargs="?", metavar="TAP", help="refresh only this tap")
|
|
252
|
+
ap.add_argument("--taps-only", action="store_true",
|
|
253
|
+
help="refresh tap clones & catalogs without touching skills")
|
|
254
|
+
args = ap.parse_args(argv)
|
|
255
|
+
results = registry.update(args.tap or None)
|
|
256
|
+
if not results:
|
|
257
|
+
out.info("no taps configured — start with `boost tap --defaults`")
|
|
258
|
+
return 0
|
|
259
|
+
for tname, summary in results.items():
|
|
260
|
+
catalog.rebuild_tap(registry.get(tname))
|
|
261
|
+
out.ok("%s: %s" % (tname, summary))
|
|
262
|
+
journal.log("update", args.tap or "all")
|
|
263
|
+
if args.taps_only:
|
|
264
|
+
return 0
|
|
265
|
+
|
|
266
|
+
upgraded = 0
|
|
267
|
+
for name, lk in sorted(lockfile.installed().items()):
|
|
268
|
+
tapname = lk.get("tap")
|
|
269
|
+
if (lk.get("pinned") or lk.get("quarantined")
|
|
270
|
+
or tapname == "local" or tapname not in results):
|
|
271
|
+
continue
|
|
272
|
+
matches = [e for e in catalog.find(name) if e["tap"] == tapname]
|
|
273
|
+
if not matches:
|
|
274
|
+
out.warn("%s is no longer in tap %s — leaving as-is" % (name, tapname))
|
|
275
|
+
continue
|
|
276
|
+
entry = matches[0]
|
|
277
|
+
old_v = str(lk.get("version", "0.0.0"))
|
|
278
|
+
new_v = str(entry.get("version", "0.0.0"))
|
|
279
|
+
reason = "version" if util.semver_gt(new_v, old_v) else None
|
|
280
|
+
if reason is None:
|
|
281
|
+
head = gitutil.head_commit(registry.get(tapname).path)
|
|
282
|
+
if head and head != lk.get("commit"):
|
|
283
|
+
try:
|
|
284
|
+
src = store.source_dir_for(entry)
|
|
285
|
+
except BoostError:
|
|
286
|
+
continue
|
|
287
|
+
if util.sha256_dir(src) != lk.get("sha256"):
|
|
288
|
+
reason = "content"
|
|
289
|
+
if reason is None:
|
|
290
|
+
continue
|
|
291
|
+
try:
|
|
292
|
+
store.install(entry, force=True)
|
|
293
|
+
except BoostError as err:
|
|
294
|
+
out.warn("%s: %s" % (name, err.message))
|
|
295
|
+
continue
|
|
296
|
+
if reason == "version":
|
|
297
|
+
out.ok("upgraded %s v%s → v%s" % (name, old_v, new_v))
|
|
298
|
+
else:
|
|
299
|
+
out.ok("refreshed %s v%s (source changed)" % (name, new_v))
|
|
300
|
+
upgraded += 1
|
|
301
|
+
if not upgraded:
|
|
302
|
+
out.ok("everything up to date")
|
|
303
|
+
return 0
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
# ── reinstall ────────────────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
def cmd_reinstall(argv: List[str]) -> int:
|
|
309
|
+
ap = argparse.ArgumentParser(prog="boost reinstall",
|
|
310
|
+
description="Reinstall a skill or all skills (force)")
|
|
311
|
+
ap.add_argument("names", nargs="*", metavar="NAME")
|
|
312
|
+
ap.add_argument("--all", action="store_true",
|
|
313
|
+
help="reinstall every installed skill")
|
|
314
|
+
args = ap.parse_args(argv)
|
|
315
|
+
names = sorted(lockfile.installed()) if args.all else args.names
|
|
316
|
+
if not names:
|
|
317
|
+
raise BoostError("nothing to reinstall",
|
|
318
|
+
hint="no skills installed yet — see `boost list`"
|
|
319
|
+
if args.all else "name a skill or pass --all")
|
|
320
|
+
done, failed = 0, 0
|
|
321
|
+
for name in names:
|
|
322
|
+
lk = lockfile.get_skill(name)
|
|
323
|
+
if not lk:
|
|
324
|
+
if len(names) == 1:
|
|
325
|
+
raise BoostError("%s is not installed" % name,
|
|
326
|
+
hint="see what is with `boost list`")
|
|
327
|
+
out.warn("%s is not installed — skipped" % name)
|
|
328
|
+
failed += 1
|
|
329
|
+
continue
|
|
330
|
+
if lk.get("tap") == "local":
|
|
331
|
+
src = Path(str(lk.get("source_dir") or ""))
|
|
332
|
+
if src.is_dir() and (src / "SKILL.md").exists():
|
|
333
|
+
store.install_from_path(src, name=name)
|
|
334
|
+
out.ok("reinstalled %s (local, from %s)" % (name, _tilde(src)))
|
|
335
|
+
done += 1
|
|
336
|
+
else:
|
|
337
|
+
out.warn("%s: local source %s is gone — skipped" % (name, _tilde(src)))
|
|
338
|
+
failed += 1
|
|
339
|
+
continue
|
|
340
|
+
matches = [e for e in catalog.find(name) if e["tap"] == lk.get("tap")]
|
|
341
|
+
if not matches:
|
|
342
|
+
out.warn("%s not found in tap %s — skipped (try `boost update`)"
|
|
343
|
+
% (name, lk.get("tap")))
|
|
344
|
+
failed += 1
|
|
345
|
+
continue
|
|
346
|
+
try:
|
|
347
|
+
store.install(matches[0], force=True)
|
|
348
|
+
except BoostError as err:
|
|
349
|
+
out.warn("%s: %s" % (name, err.message))
|
|
350
|
+
failed += 1
|
|
351
|
+
continue
|
|
352
|
+
out.ok("reinstalled %s v%s" % (name, matches[0].get("version", "0.0.0")))
|
|
353
|
+
done += 1
|
|
354
|
+
out.info("Reinstalled %s" % _plural(done, "skill"))
|
|
355
|
+
return 1 if failed else 0
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
# ── bundle ───────────────────────────────────────────────────────────────
|
|
359
|
+
|
|
360
|
+
def cmd_bundle(argv: List[str]) -> int:
|
|
361
|
+
ap = argparse.ArgumentParser(prog="boost bundle",
|
|
362
|
+
description="Export/install skill sets via a Boostfile")
|
|
363
|
+
ap.add_argument("action", choices=("dump", "install"))
|
|
364
|
+
ap.add_argument("file", nargs="?", metavar="FILE",
|
|
365
|
+
help="Boostfile (dump: default stdout; "
|
|
366
|
+
"install: default ./Boostfile, '-' = stdin)")
|
|
367
|
+
args = ap.parse_args(argv)
|
|
368
|
+
if args.action == "dump":
|
|
369
|
+
return _bundle_dump(args.file)
|
|
370
|
+
return _bundle_install(args.file)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _bundle_dump(file: Optional[str]) -> int:
|
|
374
|
+
text = _boostfile_text(lockfile.installed())
|
|
375
|
+
if not file or file == "-":
|
|
376
|
+
print(text, end="")
|
|
377
|
+
return 0
|
|
378
|
+
dest = paths.expand(file)
|
|
379
|
+
try:
|
|
380
|
+
dest.write_text(text)
|
|
381
|
+
except OSError as e:
|
|
382
|
+
raise BoostError("cannot write %s: %s" % (_tilde(dest), e.strerror or e),
|
|
383
|
+
hint="check the path exists and is writable")
|
|
384
|
+
n_taps = sum(1 for ln in text.splitlines() if ln.startswith("tap "))
|
|
385
|
+
n_skills = sum(1 for ln in text.splitlines() if ln.startswith("skill "))
|
|
386
|
+
out.ok("wrote %s (%s, %s)" % (_tilde(dest), _plural(n_taps, "tap"),
|
|
387
|
+
_plural(n_skills, "skill")))
|
|
388
|
+
return 0
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _bundle_install(file: Optional[str]) -> int:
|
|
392
|
+
if file == "-":
|
|
393
|
+
text, label = sys.stdin.read(), "<stdin>"
|
|
394
|
+
else:
|
|
395
|
+
path = paths.expand(file or "./Boostfile")
|
|
396
|
+
if not path.exists():
|
|
397
|
+
raise BoostError("no Boostfile at %s" % _tilde(path),
|
|
398
|
+
hint="create one with `boost bundle dump Boostfile`")
|
|
399
|
+
if path.is_dir():
|
|
400
|
+
raise BoostError("%s is a directory, not a Boostfile" % _tilde(path),
|
|
401
|
+
hint="point at the Boostfile itself, "
|
|
402
|
+
"or use `boost import` for skill directories")
|
|
403
|
+
try:
|
|
404
|
+
text, label = path.read_text(), str(path)
|
|
405
|
+
except OSError as e:
|
|
406
|
+
raise BoostError("cannot read %s: %s" % (_tilde(path), e.strerror or e))
|
|
407
|
+
taps_added = installed_n = present = failed = 0
|
|
408
|
+
have_taps = {t.name for t in registry.list_taps()}
|
|
409
|
+
have_skills = set(lockfile.installed())
|
|
410
|
+
for lineno, raw in enumerate(text.splitlines(), 1):
|
|
411
|
+
line = raw.strip()
|
|
412
|
+
if not line or line.startswith("#"):
|
|
413
|
+
continue
|
|
414
|
+
parts = line.split(None, 2)
|
|
415
|
+
if parts[0] == "tap" and len(parts) >= 2:
|
|
416
|
+
tname, turl = parts[1], parts[2] if len(parts) > 2 else ""
|
|
417
|
+
if tname in have_taps:
|
|
418
|
+
continue
|
|
419
|
+
try:
|
|
420
|
+
tap = registry.add(turl or tname)
|
|
421
|
+
catalog.rebuild_tap(tap)
|
|
422
|
+
except BoostError as err:
|
|
423
|
+
out.warn("tap %s failed: %s" % (tname, err.message))
|
|
424
|
+
failed += 1
|
|
425
|
+
continue
|
|
426
|
+
have_taps.add(tap.name)
|
|
427
|
+
taps_added += 1
|
|
428
|
+
out.ok("tapped %s" % tap.name)
|
|
429
|
+
elif parts[0] == "skill" and len(parts) >= 2:
|
|
430
|
+
tapq, _, rest = parts[1].rpartition(":")
|
|
431
|
+
sname, _, sver = rest.partition("@")
|
|
432
|
+
if sname in have_skills:
|
|
433
|
+
present += 1
|
|
434
|
+
continue
|
|
435
|
+
matches = catalog.find(sname, tap=tapq or None)
|
|
436
|
+
if not matches:
|
|
437
|
+
out.warn("%s not found%s — skipped"
|
|
438
|
+
% (sname, (" in tap %s" % tapq) if tapq else ""))
|
|
439
|
+
failed += 1
|
|
440
|
+
continue
|
|
441
|
+
entry = matches[0]
|
|
442
|
+
if sver and str(entry.get("version")) != sver:
|
|
443
|
+
out.warn("%s: Boostfile wants @%s, tap has %s — installing that"
|
|
444
|
+
% (sname, sver, entry.get("version")))
|
|
445
|
+
try:
|
|
446
|
+
store.install(entry)
|
|
447
|
+
except BoostError as err:
|
|
448
|
+
out.warn("%s: %s" % (sname, err.message))
|
|
449
|
+
failed += 1
|
|
450
|
+
continue
|
|
451
|
+
out.ok("installed %s v%s (%s)" % (sname, entry.get("version"),
|
|
452
|
+
entry["tap"]))
|
|
453
|
+
have_skills.add(sname)
|
|
454
|
+
installed_n += 1
|
|
455
|
+
else:
|
|
456
|
+
out.warn("line %d: unrecognised: %s" % (lineno, line))
|
|
457
|
+
failed += 1
|
|
458
|
+
journal.log("bundle-install", label, taps=taps_added, skills=installed_n)
|
|
459
|
+
summary = "Installed %s" % _plural(installed_n, "skill")
|
|
460
|
+
if taps_added:
|
|
461
|
+
summary += ", added %s" % _plural(taps_added, "tap")
|
|
462
|
+
if present:
|
|
463
|
+
summary += ", %d already present" % present
|
|
464
|
+
if failed:
|
|
465
|
+
summary += ", %d failed" % failed
|
|
466
|
+
out.info(summary)
|
|
467
|
+
return 1 if failed else 0
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
# ── import ───────────────────────────────────────────────────────────────
|
|
471
|
+
|
|
472
|
+
def cmd_import(argv: List[str]) -> int:
|
|
473
|
+
ap = argparse.ArgumentParser(
|
|
474
|
+
prog="boost import",
|
|
475
|
+
description="Import skills from a GitHub URL or local path")
|
|
476
|
+
ap.add_argument("source", metavar="URL_OR_PATH")
|
|
477
|
+
ap.add_argument("--name", metavar="N",
|
|
478
|
+
help="skill to pick when several are found (or a rename)")
|
|
479
|
+
ap.add_argument("--all", action="store_true", help="import every skill found")
|
|
480
|
+
ap.add_argument("--agent", action="append", metavar="A",
|
|
481
|
+
help="link only into this agent (repeatable)")
|
|
482
|
+
args = ap.parse_args(argv)
|
|
483
|
+
only = _check_agents(args.agent)
|
|
484
|
+
tmp = None
|
|
485
|
+
try:
|
|
486
|
+
if args.source.startswith(("http://", "https://", "git@", "ssh://")):
|
|
487
|
+
tmp = Path(tempfile.mkdtemp(prefix="boost-import-"))
|
|
488
|
+
root = tmp / "repo"
|
|
489
|
+
out.info("cloning %s …" % args.source)
|
|
490
|
+
gitutil.clone_shallow(args.source, root)
|
|
491
|
+
else:
|
|
492
|
+
root = paths.expand(args.source)
|
|
493
|
+
if not root.is_dir():
|
|
494
|
+
raise BoostError("no such directory: %s" % args.source,
|
|
495
|
+
hint="pass a local path or a git URL")
|
|
496
|
+
return _import_root(root, args.name, args.all, only, args.source)
|
|
497
|
+
finally:
|
|
498
|
+
if tmp:
|
|
499
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _import_root(root: Path, name: Optional[str], do_all: bool,
|
|
503
|
+
only: Optional[List[str]], display: str) -> int:
|
|
504
|
+
def one(skill_dir: Path, rename: Optional[str] = None) -> int:
|
|
505
|
+
res = store.install_from_path(skill_dir, name=rename, only_agents=only)
|
|
506
|
+
_report_result(res)
|
|
507
|
+
out.info("Imported %s; quality score %d/100" % (res.name, res.score))
|
|
508
|
+
return 0
|
|
509
|
+
|
|
510
|
+
if (root / "SKILL.md").exists():
|
|
511
|
+
return one(root, rename=name)
|
|
512
|
+
entries = catalog.scan_dir(root)
|
|
513
|
+
if not entries:
|
|
514
|
+
raise BoostError("no SKILL.md found under %s" % display)
|
|
515
|
+
|
|
516
|
+
def dir_of(e: dict) -> Path:
|
|
517
|
+
return root if e["rel_dir"] == "." else root / e["rel_dir"]
|
|
518
|
+
|
|
519
|
+
if len(entries) == 1:
|
|
520
|
+
return one(dir_of(entries[0]), rename=name)
|
|
521
|
+
if name and not do_all:
|
|
522
|
+
picks = [e for e in entries if e["name"] == name]
|
|
523
|
+
if not picks:
|
|
524
|
+
raise BoostError("no skill named %r in %s" % (name, display),
|
|
525
|
+
hint="available: %s" % ", ".join(e["name"] for e in entries))
|
|
526
|
+
return one(dir_of(picks[0]))
|
|
527
|
+
if do_all:
|
|
528
|
+
for e in entries:
|
|
529
|
+
res = store.install_from_path(dir_of(e), only_agents=only)
|
|
530
|
+
out.ok("imported %s v%s (score %d/100)" % (res.name, e["version"],
|
|
531
|
+
res.score))
|
|
532
|
+
out.info("Imported %s" % _plural(len(entries), "skill"))
|
|
533
|
+
return 0
|
|
534
|
+
out.heading("%d skills in %s" % (len(entries), display))
|
|
535
|
+
out.table([(e["name"], "v" + e["version"], (e["description"] or "")[:60])
|
|
536
|
+
for e in entries])
|
|
537
|
+
raise BoostError("multiple skills found — pick one or import all",
|
|
538
|
+
hint="add `--name NAME` or `--all`")
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
# ── migrate ──────────────────────────────────────────────────────────────
|
|
542
|
+
|
|
543
|
+
def cmd_migrate(argv: List[str]) -> int:
|
|
544
|
+
ap = argparse.ArgumentParser(
|
|
545
|
+
prog="boost migrate",
|
|
546
|
+
description="Migrate skills between agents or from Skills CLI")
|
|
547
|
+
ap.add_argument("--from", dest="src", metavar="AGENT",
|
|
548
|
+
help="agent migrating away from")
|
|
549
|
+
ap.add_argument("--to", dest="dst", metavar="AGENT",
|
|
550
|
+
help="agent to link every installed skill into")
|
|
551
|
+
ap.add_argument("--from-skills-cli", action="store_true",
|
|
552
|
+
help="import skills installed by the Skills CLI")
|
|
553
|
+
ap.add_argument("--path", metavar="DIR",
|
|
554
|
+
help="Skills CLI directory (default ~/.skills)")
|
|
555
|
+
args = ap.parse_args(argv)
|
|
556
|
+
|
|
557
|
+
if args.from_skills_cli:
|
|
558
|
+
root = paths.expand(args.path) if args.path else paths.home() / ".skills"
|
|
559
|
+
if not root.is_dir():
|
|
560
|
+
out.info("nothing to migrate — %s does not exist" % _tilde(root))
|
|
561
|
+
return 0
|
|
562
|
+
entries = catalog.scan_dir(root)
|
|
563
|
+
if not entries:
|
|
564
|
+
out.info("no skills found under %s" % _tilde(root))
|
|
565
|
+
return 0
|
|
566
|
+
for e in entries:
|
|
567
|
+
d = root if e["rel_dir"] == "." else root / e["rel_dir"]
|
|
568
|
+
res = store.install_from_path(d)
|
|
569
|
+
out.ok("imported %s v%s" % (res.name, e["version"]))
|
|
570
|
+
out.info("Migrated %s from %s" % (_plural(len(entries), "skill"),
|
|
571
|
+
_tilde(root)))
|
|
572
|
+
return 0
|
|
573
|
+
|
|
574
|
+
if not (args.src and args.dst):
|
|
575
|
+
raise BoostError("nothing to do",
|
|
576
|
+
hint="use `--from AGENT --to AGENT` or `--from-skills-cli`")
|
|
577
|
+
known = agents.known_agents()
|
|
578
|
+
for a in (args.src, args.dst):
|
|
579
|
+
if a not in known:
|
|
580
|
+
raise BoostError("unknown agent: %s" % a,
|
|
581
|
+
hint="known agents: %s" % ", ".join(known))
|
|
582
|
+
if args.src == args.dst:
|
|
583
|
+
raise BoostError("--from and --to are the same agent")
|
|
584
|
+
if args.dst not in agents.enabled_agents():
|
|
585
|
+
raise BoostError("agent %s is disabled in config" % args.dst,
|
|
586
|
+
hint="`boost config set agents.%s.enabled true`" % args.dst)
|
|
587
|
+
skills = sorted(lockfile.installed())
|
|
588
|
+
if not skills:
|
|
589
|
+
out.info("no skills installed — nothing to migrate")
|
|
590
|
+
return 0
|
|
591
|
+
linked = []
|
|
592
|
+
for name in skills:
|
|
593
|
+
res = store.link_agents(name, only=[args.dst])
|
|
594
|
+
if args.dst in res.linked:
|
|
595
|
+
out.ok("linked %s → %s" % (name, args.dst))
|
|
596
|
+
linked.append(name)
|
|
597
|
+
for pth in res.conflicts:
|
|
598
|
+
out.warn("%s: %s exists and is not managed by boost" % (name, _tilde(pth)))
|
|
599
|
+
lock = lockfile.read()
|
|
600
|
+
changed = False
|
|
601
|
+
for name in linked:
|
|
602
|
+
ent = lock["skills"].get(name)
|
|
603
|
+
if ent is not None and args.dst not in ent.get("agents", []):
|
|
604
|
+
ent["agents"] = sorted(set(ent.get("agents", [])) | {args.dst})
|
|
605
|
+
changed = True
|
|
606
|
+
if changed:
|
|
607
|
+
lockfile.write(lock)
|
|
608
|
+
journal.log("migrate", "%s→%s" % (args.src, args.dst), skills=len(linked))
|
|
609
|
+
out.info("Migrated %s to %s" % (_plural(len(linked), "skill"),
|
|
610
|
+
agents.display_name(args.dst)))
|
|
611
|
+
return 0
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
# ── pin / unpin ──────────────────────────────────────────────────────────
|
|
615
|
+
|
|
616
|
+
def cmd_pin(argv: List[str]) -> int:
|
|
617
|
+
ap = argparse.ArgumentParser(prog="boost pin",
|
|
618
|
+
description="Pin a skill to its current version")
|
|
619
|
+
ap.add_argument("name", metavar="NAME")
|
|
620
|
+
args = ap.parse_args(argv)
|
|
621
|
+
return _set_pin(args.name, True)
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def cmd_unpin(argv: List[str]) -> int:
|
|
625
|
+
ap = argparse.ArgumentParser(prog="boost unpin",
|
|
626
|
+
description="Allow a pinned skill to update again")
|
|
627
|
+
ap.add_argument("name", metavar="NAME")
|
|
628
|
+
args = ap.parse_args(argv)
|
|
629
|
+
return _set_pin(args.name, False)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _set_pin(name: str, pinned: bool) -> int:
|
|
633
|
+
entry = lockfile.get_skill(name)
|
|
634
|
+
if not entry:
|
|
635
|
+
raise BoostError("%s is not installed" % name,
|
|
636
|
+
hint="see what is with `boost list`")
|
|
637
|
+
version = entry.get("version", "0.0.0")
|
|
638
|
+
if bool(entry.get("pinned")) == pinned:
|
|
639
|
+
out.info("%s is already %s" % (name, "pinned at v%s" % version
|
|
640
|
+
if pinned else "unpinned"))
|
|
641
|
+
return 0
|
|
642
|
+
entry["pinned"] = pinned
|
|
643
|
+
lockfile.set_skill(name, entry)
|
|
644
|
+
journal.log("pin" if pinned else "unpin", name)
|
|
645
|
+
if pinned:
|
|
646
|
+
out.ok("pinned %s at v%s — `boost update` will skip it" % (name, version))
|
|
647
|
+
else:
|
|
648
|
+
out.ok("unpinned %s (v%s) — updates apply again" % (name, version))
|
|
649
|
+
return 0
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
# ── snapshot ─────────────────────────────────────────────────────────────
|
|
653
|
+
|
|
654
|
+
def cmd_snapshot(argv: List[str]) -> int:
|
|
655
|
+
ap = argparse.ArgumentParser(prog="boost snapshot",
|
|
656
|
+
description="Save & restore whole skill environments")
|
|
657
|
+
ap.add_argument("action", choices=("save", "list", "restore"))
|
|
658
|
+
ap.add_argument("arg", nargs="?", metavar="LABEL|ID",
|
|
659
|
+
help="label for save, snapshot id for restore")
|
|
660
|
+
ap.add_argument("--json", action="store_true",
|
|
661
|
+
help="machine-readable output (list)")
|
|
662
|
+
args = ap.parse_args(argv)
|
|
663
|
+
if args.action == "save":
|
|
664
|
+
return _snapshot_save(args.arg)
|
|
665
|
+
if args.action == "list":
|
|
666
|
+
return _snapshot_list(args.json)
|
|
667
|
+
if not args.arg:
|
|
668
|
+
raise BoostError("restore needs a snapshot id",
|
|
669
|
+
hint="see `boost snapshot list`")
|
|
670
|
+
return _snapshot_restore(args.arg)
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _snapshot_save(label: Optional[str]) -> int:
|
|
674
|
+
paths.ensure_dirs()
|
|
675
|
+
base = "snap-" + datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
|
676
|
+
snap_id, serial = base, 1
|
|
677
|
+
while (paths.snapshots_dir() / (snap_id + ".tar.gz")).exists():
|
|
678
|
+
serial += 1
|
|
679
|
+
snap_id = "%s-%d" % (base, serial)
|
|
680
|
+
tar_path = paths.snapshots_dir() / (snap_id + ".tar.gz")
|
|
681
|
+
with tarfile.open(str(tar_path), "w:gz") as tf:
|
|
682
|
+
for child in sorted(paths.store_dir().iterdir()):
|
|
683
|
+
tf.add(str(child), arcname=child.name)
|
|
684
|
+
skill_count = len(lockfile.installed())
|
|
685
|
+
manifest = {"id": snap_id, "label": label or "", "created": util.now_iso(),
|
|
686
|
+
"skills": skill_count}
|
|
687
|
+
(paths.snapshots_dir() / (snap_id + ".json")).write_text(
|
|
688
|
+
json.dumps(manifest, indent=2) + "\n")
|
|
689
|
+
journal.log("snapshot", snap_id, label=label)
|
|
690
|
+
out.ok("saved %s (%s, %s)" % (snap_id, _plural(skill_count, "skill"),
|
|
691
|
+
util.human_size(tar_path.stat().st_size)))
|
|
692
|
+
out.info("restore with `boost snapshot restore %s`" % snap_id)
|
|
693
|
+
return 0
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _snapshot_list(as_json: bool) -> int:
|
|
697
|
+
paths.ensure_dirs()
|
|
698
|
+
snaps = []
|
|
699
|
+
# newest first; file mtime keeps same-second saves (snap-...-2) in order
|
|
700
|
+
for tar_path in sorted(paths.snapshots_dir().glob("snap-*.tar.gz"),
|
|
701
|
+
key=lambda p: (p.stat().st_mtime, p.name),
|
|
702
|
+
reverse=True):
|
|
703
|
+
snap_id = tar_path.name[:-len(".tar.gz")]
|
|
704
|
+
side = tar_path.parent / (snap_id + ".json")
|
|
705
|
+
meta = {}
|
|
706
|
+
if side.exists():
|
|
707
|
+
try:
|
|
708
|
+
meta = json.loads(side.read_text())
|
|
709
|
+
except (json.JSONDecodeError, OSError):
|
|
710
|
+
meta = {}
|
|
711
|
+
snaps.append({"id": snap_id, "created": meta.get("created", ""),
|
|
712
|
+
"label": meta.get("label", ""),
|
|
713
|
+
"skills": meta.get("skills", "?"),
|
|
714
|
+
"size": tar_path.stat().st_size})
|
|
715
|
+
if as_json:
|
|
716
|
+
print(json.dumps(snaps, indent=2))
|
|
717
|
+
return 0
|
|
718
|
+
if not snaps:
|
|
719
|
+
out.info("no snapshots yet — create one with `boost snapshot save`")
|
|
720
|
+
return 0
|
|
721
|
+
out.table([(s["id"], util.rel_time(s["created"]) if s["created"] else "?",
|
|
722
|
+
s["label"] or "—", s["skills"], util.human_size(s["size"]))
|
|
723
|
+
for s in snaps],
|
|
724
|
+
headers=("ID", "WHEN", "LABEL", "SKILLS", "SIZE"))
|
|
725
|
+
return 0
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def _snapshot_restore(snap_id: str) -> int:
|
|
729
|
+
if not snap_id.startswith("snap-"):
|
|
730
|
+
snap_id = "snap-" + snap_id
|
|
731
|
+
tar_path = paths.snapshots_dir() / (snap_id + ".tar.gz")
|
|
732
|
+
if not tar_path.exists():
|
|
733
|
+
raise BoostError("no snapshot %s" % snap_id,
|
|
734
|
+
hint="see `boost snapshot list`")
|
|
735
|
+
root = paths.store_dir()
|
|
736
|
+
if not out.confirm("Restore %s? This replaces everything in %s"
|
|
737
|
+
% (snap_id, _tilde(root))):
|
|
738
|
+
out.info("cancelled")
|
|
739
|
+
return 0
|
|
740
|
+
paths.ensure_dirs()
|
|
741
|
+
# Read the whole archive BEFORE touching the store, so a corrupt
|
|
742
|
+
# snapshot can never leave us with an emptied environment.
|
|
743
|
+
try:
|
|
744
|
+
tf = tarfile.open(str(tar_path), "r:gz")
|
|
745
|
+
except (tarfile.TarError, OSError, EOFError) as e:
|
|
746
|
+
raise BoostError("snapshot %s is unreadable: %s" % (snap_id, e),
|
|
747
|
+
hint="the store was left untouched — "
|
|
748
|
+
"see `boost snapshot list` for other snapshots")
|
|
749
|
+
with tf:
|
|
750
|
+
try:
|
|
751
|
+
members = tf.getmembers()
|
|
752
|
+
except (tarfile.TarError, OSError, EOFError) as e:
|
|
753
|
+
raise BoostError("snapshot %s is corrupt: %s" % (snap_id, e),
|
|
754
|
+
hint="the store was left untouched — "
|
|
755
|
+
"see `boost snapshot list` for other snapshots")
|
|
756
|
+
for child in root.iterdir():
|
|
757
|
+
if child.is_dir() and not child.is_symlink():
|
|
758
|
+
shutil.rmtree(child)
|
|
759
|
+
else:
|
|
760
|
+
child.unlink()
|
|
761
|
+
try:
|
|
762
|
+
tf.extractall(str(root), members=members, filter="data")
|
|
763
|
+
except TypeError: # Python < 3.12
|
|
764
|
+
tf.extractall(str(root), members=members)
|
|
765
|
+
for action in store.sync_apply(store.sync_plan()):
|
|
766
|
+
out.ok(action)
|
|
767
|
+
journal.log("snapshot-restore", snap_id)
|
|
768
|
+
out.ok("restored %s (%s)" % (snap_id,
|
|
769
|
+
_plural(len(lockfile.installed()), "skill")))
|
|
770
|
+
return 0
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
# ── export ───────────────────────────────────────────────────────────────
|
|
774
|
+
|
|
775
|
+
def cmd_export(argv: List[str]) -> int:
|
|
776
|
+
ap = argparse.ArgumentParser(
|
|
777
|
+
prog="boost export",
|
|
778
|
+
description="Package skills as shareable zip/tar archives")
|
|
779
|
+
ap.add_argument("names", nargs="*", metavar="NAME",
|
|
780
|
+
help="skills to export (default: all installed)")
|
|
781
|
+
ap.add_argument("-o", "--out", metavar="OUT", help="output archive path")
|
|
782
|
+
ap.add_argument("--zip", action="store_true",
|
|
783
|
+
help="build a .zip instead of .tar.gz")
|
|
784
|
+
args = ap.parse_args(argv)
|
|
785
|
+
installed = lockfile.installed()
|
|
786
|
+
names = args.names or sorted(installed)
|
|
787
|
+
if not names:
|
|
788
|
+
raise BoostError("no skills installed to export",
|
|
789
|
+
hint="install some with `boost install`")
|
|
790
|
+
chosen = {}
|
|
791
|
+
for name in names:
|
|
792
|
+
if name not in installed:
|
|
793
|
+
raise BoostError("%s is not installed" % name,
|
|
794
|
+
hint="see what is with `boost list`")
|
|
795
|
+
if not store.skill_store_dir(name).is_dir():
|
|
796
|
+
raise BoostError("store dir for %s is missing" % name,
|
|
797
|
+
hint="repair with `boost sync`")
|
|
798
|
+
chosen[name] = installed[name]
|
|
799
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%d")
|
|
800
|
+
ext = ".zip" if args.zip else ".tar.gz"
|
|
801
|
+
dest = paths.expand(args.out) if args.out else Path(
|
|
802
|
+
"boost-skills-%s%s" % (stamp, ext))
|
|
803
|
+
manifest = _boostfile_text(chosen, via="boost export")
|
|
804
|
+
try:
|
|
805
|
+
if args.zip:
|
|
806
|
+
with zipfile.ZipFile(str(dest), "w", zipfile.ZIP_DEFLATED) as zf:
|
|
807
|
+
zf.writestr("Boostfile", manifest)
|
|
808
|
+
for name in chosen:
|
|
809
|
+
sdir = store.skill_store_dir(name)
|
|
810
|
+
for f in sorted(p for p in sdir.rglob("*") if p.is_file()):
|
|
811
|
+
zf.write(str(f), arcname="%s/%s" % (name, f.relative_to(sdir)))
|
|
812
|
+
else:
|
|
813
|
+
with tarfile.open(str(dest), "w:gz") as tf:
|
|
814
|
+
data = manifest.encode()
|
|
815
|
+
ti = tarfile.TarInfo("Boostfile")
|
|
816
|
+
ti.size = len(data)
|
|
817
|
+
ti.mtime = int(datetime.now(timezone.utc).timestamp())
|
|
818
|
+
ti.mode = 0o644
|
|
819
|
+
tf.addfile(ti, io.BytesIO(data))
|
|
820
|
+
for name in chosen:
|
|
821
|
+
tf.add(str(store.skill_store_dir(name)), arcname=name)
|
|
822
|
+
except OSError as e:
|
|
823
|
+
raise BoostError("cannot write %s: %s" % (_tilde(dest), e.strerror or e),
|
|
824
|
+
hint="check the output path exists and is writable")
|
|
825
|
+
out.ok("exported %s → %s (%s)" % (_plural(len(chosen), "skill"),
|
|
826
|
+
_tilde(dest.resolve()),
|
|
827
|
+
util.human_size(dest.stat().st_size)))
|
|
828
|
+
return 0
|