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,70 @@
|
|
|
1
|
+
"""The pulse journal: append-only feed of skill-management events.
|
|
2
|
+
|
|
3
|
+
Powers `boost pulse`, `boost trending`, `boost stats`, and `boost who`.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import getpass
|
|
8
|
+
import json
|
|
9
|
+
from typing import List, Optional
|
|
10
|
+
|
|
11
|
+
from . import paths, util
|
|
12
|
+
|
|
13
|
+
ROTATE_AT = 5000
|
|
14
|
+
ROTATE_KEEP = 2500
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def log(action: str, subject: str = "", **fields) -> None:
|
|
18
|
+
paths.ensure_dirs()
|
|
19
|
+
event = {"ts": util.now_iso(), "user": _user(), "action": action,
|
|
20
|
+
"subject": subject}
|
|
21
|
+
event.update({k: v for k, v in fields.items() if v is not None})
|
|
22
|
+
p = paths.pulse_path()
|
|
23
|
+
with p.open("a") as f:
|
|
24
|
+
f.write(json.dumps(event) + "\n")
|
|
25
|
+
_maybe_rotate()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _user() -> str:
|
|
29
|
+
try:
|
|
30
|
+
return getpass.getuser()
|
|
31
|
+
except Exception:
|
|
32
|
+
return "unknown"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def events(n: Optional[int] = None, action: Optional[str] = None,
|
|
36
|
+
subject: Optional[str] = None) -> List[dict]:
|
|
37
|
+
"""Most-recent-first list of journal events."""
|
|
38
|
+
p = paths.pulse_path()
|
|
39
|
+
if not p.exists():
|
|
40
|
+
return []
|
|
41
|
+
out = []
|
|
42
|
+
for line in p.read_text().splitlines():
|
|
43
|
+
try:
|
|
44
|
+
e = json.loads(line)
|
|
45
|
+
except json.JSONDecodeError:
|
|
46
|
+
continue
|
|
47
|
+
if action and e.get("action") != action:
|
|
48
|
+
continue
|
|
49
|
+
if subject and e.get("subject") != subject:
|
|
50
|
+
continue
|
|
51
|
+
out.append(e)
|
|
52
|
+
out.reverse()
|
|
53
|
+
return out[:n] if n else out
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def rotation_healthy() -> bool:
|
|
57
|
+
p = paths.pulse_path()
|
|
58
|
+
if not p.exists():
|
|
59
|
+
return True
|
|
60
|
+
return sum(1 for _ in p.open()) <= ROTATE_AT
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _maybe_rotate() -> None:
|
|
64
|
+
p = paths.pulse_path()
|
|
65
|
+
try:
|
|
66
|
+
lines = p.read_text().splitlines()
|
|
67
|
+
except OSError:
|
|
68
|
+
return
|
|
69
|
+
if len(lines) > ROTATE_AT:
|
|
70
|
+
p.write_text("\n".join(lines[-ROTATE_KEEP:]) + "\n")
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""The v3 lock file: ~/.agents/skills/.skill-lock.json
|
|
2
|
+
|
|
3
|
+
Every write snapshots the previous version into ~/.boost/state/lock-history/
|
|
4
|
+
so `boost replay` can show history and roll back.
|
|
5
|
+
|
|
6
|
+
Skill entry schema (v3):
|
|
7
|
+
version, tap, source_dir, commit, sha256,
|
|
8
|
+
installed_at, updated_at, pinned, quarantined, agents[], tags[]
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import shutil
|
|
14
|
+
from typing import List, Optional
|
|
15
|
+
|
|
16
|
+
from . import paths, util
|
|
17
|
+
|
|
18
|
+
SCHEMA_VERSION = 3
|
|
19
|
+
HISTORY_KEEP = 50
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _skeleton() -> dict:
|
|
23
|
+
return {"version": SCHEMA_VERSION, "updated": util.now_iso(), "skills": {}}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def read() -> dict:
|
|
27
|
+
p = paths.lockfile_path()
|
|
28
|
+
if not p.exists():
|
|
29
|
+
return _skeleton()
|
|
30
|
+
try:
|
|
31
|
+
lock = json.loads(p.read_text())
|
|
32
|
+
except (json.JSONDecodeError, OSError):
|
|
33
|
+
return _skeleton()
|
|
34
|
+
lock.setdefault("version", SCHEMA_VERSION)
|
|
35
|
+
lock.setdefault("skills", {})
|
|
36
|
+
return lock
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def write(lock: dict) -> None:
|
|
40
|
+
paths.ensure_dirs()
|
|
41
|
+
p = paths.lockfile_path()
|
|
42
|
+
if p.exists():
|
|
43
|
+
stamp = util.now_iso().replace(":", "").replace("-", "")
|
|
44
|
+
dest = paths.lock_history_dir() / ("lock-%s.json" % stamp)
|
|
45
|
+
n = 2
|
|
46
|
+
while dest.exists(): # same-second writes each keep their snapshot
|
|
47
|
+
dest = paths.lock_history_dir() / ("lock-%s-%d.json" % (stamp, n))
|
|
48
|
+
n += 1
|
|
49
|
+
# plain copy: the snapshot's mtime is when it was TAKEN (copy2 would
|
|
50
|
+
# inherit the lock file's older mtime and mis-sort it as oldest)
|
|
51
|
+
shutil.copy(p, dest)
|
|
52
|
+
_prune_history()
|
|
53
|
+
lock["version"] = SCHEMA_VERSION
|
|
54
|
+
lock["updated"] = util.now_iso()
|
|
55
|
+
p.write_text(json.dumps(lock, indent=2, sort_keys=True) + "\n")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _history_files() -> List:
|
|
59
|
+
"""History snapshots oldest→newest (mtime, then name — '-2' suffixed
|
|
60
|
+
same-second snapshots would sort before their base name otherwise)."""
|
|
61
|
+
return sorted(paths.lock_history_dir().glob("lock-*.json"),
|
|
62
|
+
key=lambda f: (f.stat().st_mtime, f.name))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _prune_history() -> None:
|
|
66
|
+
for old in _history_files()[:-HISTORY_KEEP]:
|
|
67
|
+
old.unlink()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get_skill(name: str) -> Optional[dict]:
|
|
71
|
+
return read()["skills"].get(name)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def set_skill(name: str, entry: dict) -> None:
|
|
75
|
+
lock = read()
|
|
76
|
+
lock["skills"][name] = entry
|
|
77
|
+
write(lock)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def remove_skill(name: str) -> bool:
|
|
81
|
+
lock = read()
|
|
82
|
+
if name in lock["skills"]:
|
|
83
|
+
del lock["skills"][name]
|
|
84
|
+
write(lock)
|
|
85
|
+
return True
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def installed() -> dict:
|
|
90
|
+
return read()["skills"]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def history_list() -> List[dict]:
|
|
94
|
+
"""[{id, path, updated, count}] oldest→newest."""
|
|
95
|
+
out = []
|
|
96
|
+
for p in _history_files():
|
|
97
|
+
try:
|
|
98
|
+
data = json.loads(p.read_text())
|
|
99
|
+
except (json.JSONDecodeError, OSError):
|
|
100
|
+
continue
|
|
101
|
+
out.append({
|
|
102
|
+
"id": p.stem.replace("lock-", ""),
|
|
103
|
+
"path": str(p),
|
|
104
|
+
"updated": data.get("updated", "?"),
|
|
105
|
+
"count": len(data.get("skills", {})),
|
|
106
|
+
})
|
|
107
|
+
return out
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def history_read(hist_id: str) -> dict:
|
|
111
|
+
p = paths.lock_history_dir() / ("lock-%s.json" % hist_id)
|
|
112
|
+
if not p.exists():
|
|
113
|
+
from ..errors import BoostError
|
|
114
|
+
raise BoostError("no lock history entry %s" % hist_id,
|
|
115
|
+
hint="list entries with `boost replay`")
|
|
116
|
+
return json.loads(p.read_text())
|
boost_cli/core/output.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Terminal output helpers — colors, symbols, tables.
|
|
2
|
+
|
|
3
|
+
Conventions used across all commands:
|
|
4
|
+
ok("copied to ...") -> " ✓ copied to ..." (green check)
|
|
5
|
+
warn("...") -> " ! ..." (yellow)
|
|
6
|
+
err("...") -> "Error: ..." on stderr (red)
|
|
7
|
+
info("...") -> plain indented line
|
|
8
|
+
heading("...") -> bold section header
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
RESET = "\033[0m"
|
|
16
|
+
BOLD = "\033[1m"
|
|
17
|
+
DIM = "\033[2m"
|
|
18
|
+
RED = "\033[31m"
|
|
19
|
+
GREEN = "\033[32m"
|
|
20
|
+
YELLOW = "\033[33m"
|
|
21
|
+
BLUE = "\033[34m"
|
|
22
|
+
MAGENTA = "\033[35m"
|
|
23
|
+
CYAN = "\033[36m"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def use_color(stream=None) -> bool:
|
|
27
|
+
if os.environ.get("NO_COLOR"):
|
|
28
|
+
return False
|
|
29
|
+
if os.environ.get("CLICOLOR_FORCE"):
|
|
30
|
+
return True
|
|
31
|
+
stream = stream or sys.stdout
|
|
32
|
+
return hasattr(stream, "isatty") and stream.isatty()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def c(text: str, *styles: str) -> str:
|
|
36
|
+
if not styles or not use_color():
|
|
37
|
+
return text
|
|
38
|
+
return "".join(styles) + text + RESET
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def ok(msg: str) -> None:
|
|
42
|
+
print(" " + c("✓", GREEN) + " " + msg)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def warn(msg: str) -> None:
|
|
46
|
+
print(" " + c("!", YELLOW) + " " + c(msg, YELLOW))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def err(msg: str, hint: str | None = None) -> None:
|
|
50
|
+
print(c("Error: ", RED, BOLD) + msg, file=sys.stderr)
|
|
51
|
+
if hint:
|
|
52
|
+
print(c(" hint: " + hint, DIM), file=sys.stderr)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def info(msg: str = "") -> None:
|
|
56
|
+
print(" " + msg if msg else "")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def dim(msg: str) -> None:
|
|
60
|
+
print(c(msg, DIM))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def heading(msg: str) -> None:
|
|
64
|
+
print(c("==> ", BLUE, BOLD) + c(msg, BOLD))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def kv(key: str, value: str, width: int = 14) -> None:
|
|
68
|
+
print(" " + c(key.ljust(width), DIM) + str(value))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def table(rows, headers=None) -> None:
|
|
72
|
+
"""Print an aligned table. rows: list of tuples of strings."""
|
|
73
|
+
rows = [[str(x) for x in r] for r in rows]
|
|
74
|
+
all_rows = ([list(map(str, headers))] if headers else []) + rows
|
|
75
|
+
if not all_rows:
|
|
76
|
+
return
|
|
77
|
+
widths = [max(len(r[i]) for r in all_rows if i < len(r))
|
|
78
|
+
for i in range(max(len(r) for r in all_rows))]
|
|
79
|
+
if headers:
|
|
80
|
+
line = " ".join(str(h).ljust(widths[i]) for i, h in enumerate(headers))
|
|
81
|
+
print(c(line, BOLD))
|
|
82
|
+
for r in rows:
|
|
83
|
+
print(" ".join(cell.ljust(widths[i]) for i, cell in enumerate(r)).rstrip())
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def confirm(prompt: str, default: bool = False) -> bool:
|
|
87
|
+
if os.environ.get("BOOST_ASSUME_YES") or "--yes" in sys.argv or "-y" in sys.argv:
|
|
88
|
+
return True
|
|
89
|
+
if not sys.stdin.isatty():
|
|
90
|
+
return default
|
|
91
|
+
suffix = " [Y/n] " if default else " [y/N] "
|
|
92
|
+
try:
|
|
93
|
+
answer = input(prompt + suffix).strip().lower()
|
|
94
|
+
except (EOFError, KeyboardInterrupt):
|
|
95
|
+
print()
|
|
96
|
+
return False
|
|
97
|
+
if not answer:
|
|
98
|
+
return default
|
|
99
|
+
return answer in ("y", "yes")
|
boost_cli/core/paths.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""All filesystem locations boost touches.
|
|
2
|
+
|
|
3
|
+
Everything derives from $HOME (or explicit env overrides) at call time so
|
|
4
|
+
tests can sandbox the whole tool by exporting HOME=/tmp/somewhere.
|
|
5
|
+
|
|
6
|
+
Layout:
|
|
7
|
+
~/.boost/repos/ shallow git clones of tap registries
|
|
8
|
+
~/.boost/cache/ JSON catalogs built from SKILL.md frontmatter
|
|
9
|
+
~/.boost/logs/ command logs
|
|
10
|
+
~/.boost/state/ pins, tags, policy, profiles, pulse feed, snapshots
|
|
11
|
+
~/.boost/config.json configuration
|
|
12
|
+
~/.agents/skills/ canonical store — single source of truth
|
|
13
|
+
~/.agents/skills/.skill-lock.json v3 lock file
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import shutil
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def home() -> Path:
|
|
23
|
+
return Path(os.environ.get("HOME") or str(Path.home()))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def expand(p: str) -> Path:
|
|
27
|
+
"""Expand a leading ~ against home() (which respects the HOME env var)."""
|
|
28
|
+
if p == "~":
|
|
29
|
+
return home()
|
|
30
|
+
if p.startswith("~/"):
|
|
31
|
+
return home() / p[2:]
|
|
32
|
+
return Path(p)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def boost_home() -> Path:
|
|
36
|
+
override = os.environ.get("BOOST_HOME")
|
|
37
|
+
return Path(override) if override else home() / ".boost"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def repos_dir() -> Path:
|
|
41
|
+
return boost_home() / "repos"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def cache_dir() -> Path:
|
|
45
|
+
return boost_home() / "cache"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def logs_dir() -> Path:
|
|
49
|
+
return boost_home() / "logs"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def state_dir() -> Path:
|
|
53
|
+
return boost_home() / "state"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def snapshots_dir() -> Path:
|
|
57
|
+
return state_dir() / "snapshots"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def lock_history_dir() -> Path:
|
|
61
|
+
return state_dir() / "lock-history"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def profiles_dir() -> Path:
|
|
65
|
+
return state_dir() / "profiles"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def config_path() -> Path:
|
|
69
|
+
return boost_home() / "config.json"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def store_dir() -> Path:
|
|
73
|
+
"""Canonical store for installed skills."""
|
|
74
|
+
override = os.environ.get("BOOST_AGENTS_STORE")
|
|
75
|
+
return Path(override) if override else home() / ".agents" / "skills"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def lockfile_path() -> Path:
|
|
79
|
+
return store_dir() / ".skill-lock.json"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def pulse_path() -> Path:
|
|
83
|
+
return state_dir() / "pulse.jsonl"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def policy_path() -> Path:
|
|
87
|
+
return state_dir() / "policy.json"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def repo_root() -> Path:
|
|
91
|
+
"""The boost source checkout this module runs from."""
|
|
92
|
+
return Path(__file__).resolve().parent.parent.parent
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def launcher() -> Path:
|
|
96
|
+
"""Absolute path other processes should use to invoke boost.
|
|
97
|
+
|
|
98
|
+
A pip/pipx install has no `boost` shim next to the package (repo_root()
|
|
99
|
+
lands inside site-packages), so prefer the console script on PATH and
|
|
100
|
+
fall back to the source-checkout shim.
|
|
101
|
+
"""
|
|
102
|
+
found = shutil.which("boost")
|
|
103
|
+
return Path(found) if found else repo_root() / "boost"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def ensure_dirs() -> None:
|
|
107
|
+
for d in (
|
|
108
|
+
boost_home(), repos_dir(), cache_dir(), logs_dir(), state_dir(),
|
|
109
|
+
snapshots_dir(), lock_history_dir(), profiles_dir(), store_dir(),
|
|
110
|
+
):
|
|
111
|
+
d.mkdir(parents=True, exist_ok=True)
|
boost_cli/core/policy.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Governance policies: ~/.boost/state/policy.json
|
|
2
|
+
|
|
3
|
+
Consulted by store.install() (when config policy_enforce is true) and by
|
|
4
|
+
`boost audit` / `boost policy check`.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from typing import List
|
|
10
|
+
|
|
11
|
+
from . import config, paths
|
|
12
|
+
|
|
13
|
+
DEFAULTS = {
|
|
14
|
+
"blocked_skills": [], # names never allowed
|
|
15
|
+
"blocked_taps": [], # tap names never allowed
|
|
16
|
+
"allowed_taps": [], # if non-empty, ONLY these taps allowed
|
|
17
|
+
"require_description": False,
|
|
18
|
+
"require_version": False,
|
|
19
|
+
"min_quality_score": 0, # enforced by `boost audit`, advisory at install
|
|
20
|
+
"max_skills": None, # cap on installed count
|
|
21
|
+
"pin_only": False, # block installs/updates entirely (frozen env)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load() -> dict:
|
|
26
|
+
p = paths.policy_path()
|
|
27
|
+
base = dict(DEFAULTS)
|
|
28
|
+
if p.exists():
|
|
29
|
+
try:
|
|
30
|
+
base.update(json.loads(p.read_text()))
|
|
31
|
+
except (json.JSONDecodeError, OSError):
|
|
32
|
+
pass
|
|
33
|
+
return base
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def save(pol: dict) -> None:
|
|
37
|
+
paths.ensure_dirs()
|
|
38
|
+
known = {k: pol.get(k, DEFAULTS[k]) for k in DEFAULTS}
|
|
39
|
+
paths.policy_path().write_text(json.dumps(known, indent=2) + "\n")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def check_install(entry: dict, installed_count: int) -> List[str]:
|
|
43
|
+
"""Return a list of violation strings (empty = allowed)."""
|
|
44
|
+
if not config.get("policy_enforce", True):
|
|
45
|
+
return []
|
|
46
|
+
pol = load()
|
|
47
|
+
v: List[str] = []
|
|
48
|
+
name, tap = entry.get("name", ""), entry.get("tap", "")
|
|
49
|
+
if pol["pin_only"]:
|
|
50
|
+
v.append("environment is pin-only (frozen)")
|
|
51
|
+
if name in pol["blocked_skills"]:
|
|
52
|
+
v.append("skill %r is on the blocklist" % name)
|
|
53
|
+
if tap in pol["blocked_taps"]:
|
|
54
|
+
v.append("tap %r is blocked" % tap)
|
|
55
|
+
if pol["allowed_taps"] and tap not in pol["allowed_taps"] and tap != "local":
|
|
56
|
+
v.append("tap %r is not on the allowlist" % tap)
|
|
57
|
+
if pol["require_description"] and not entry.get("description"):
|
|
58
|
+
v.append("skill has no description (required by policy)")
|
|
59
|
+
if pol["require_version"] and entry.get("version") in (None, "", "0.0.0"):
|
|
60
|
+
v.append("skill has no version (required by policy)")
|
|
61
|
+
if pol["max_skills"] is not None and installed_count >= int(pol["max_skills"]):
|
|
62
|
+
v.append("max_skills limit (%s) reached" % pol["max_skills"])
|
|
63
|
+
return v
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Tap registries: GitHub repos (or local paths) full of SKILL.md files."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import difflib
|
|
5
|
+
import shutil
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import List, Optional
|
|
9
|
+
|
|
10
|
+
from ..errors import BoostError
|
|
11
|
+
from . import config, gitutil, paths
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Tap:
|
|
16
|
+
name: str # "anthropics/skills" or a short alias
|
|
17
|
+
url: str # https URL or local path
|
|
18
|
+
curated: bool = False
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def safe_name(self) -> str:
|
|
22
|
+
return self.name.replace("/", "__")
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def path(self) -> Path:
|
|
26
|
+
return paths.repos_dir() / self.safe_name
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def cache_file(self) -> Path:
|
|
30
|
+
return paths.cache_dir() / (self.safe_name + ".json")
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def is_cloned(self) -> bool:
|
|
34
|
+
return self.path.is_dir()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_spec(spec: str):
|
|
38
|
+
"""Resolve a tap spec -> (name, url).
|
|
39
|
+
|
|
40
|
+
Accepts: owner/repo | full git URL | existing local path.
|
|
41
|
+
"""
|
|
42
|
+
spec = spec.strip().rstrip("/")
|
|
43
|
+
p = Path(spec).expanduser()
|
|
44
|
+
if p.exists() and p.is_dir():
|
|
45
|
+
return (p.resolve().name, str(p.resolve()))
|
|
46
|
+
if spec.startswith(("http://", "https://", "git@", "ssh://")):
|
|
47
|
+
tail = spec.split(":")[-1] if spec.startswith("git@") else spec
|
|
48
|
+
parts = [x for x in tail.replace(".git", "").split("/") if x][-2:]
|
|
49
|
+
return ("/".join(parts), spec)
|
|
50
|
+
if "/" in spec and " " not in spec:
|
|
51
|
+
return (spec, "https://github.com/%s" % spec)
|
|
52
|
+
raise BoostError("cannot parse tap spec %r" % spec,
|
|
53
|
+
hint="use owner/repo, a git URL, or a local directory")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def list_taps() -> List[Tap]:
|
|
57
|
+
return [Tap(name=t["name"], url=t.get("url", ""), curated=bool(t.get("curated")))
|
|
58
|
+
for t in config.get("taps", [])]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get(name: str) -> Tap:
|
|
62
|
+
taps = list_taps()
|
|
63
|
+
for t in taps:
|
|
64
|
+
if t.name == name or t.safe_name == name or t.name.split("/")[-1] == name:
|
|
65
|
+
return t
|
|
66
|
+
close = difflib.get_close_matches(name, [t.name for t in taps], n=1)
|
|
67
|
+
raise BoostError("no such tap: %s" % name,
|
|
68
|
+
hint=("did you mean %s?" % close[0]) if close
|
|
69
|
+
else "list taps with `boost taps`")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def add(spec: str, curated: bool = False) -> Tap:
|
|
73
|
+
name, url = parse_spec(spec)
|
|
74
|
+
for existing in list_taps():
|
|
75
|
+
if existing.name == name:
|
|
76
|
+
raise BoostError("tap %s is already configured" % name,
|
|
77
|
+
hint="`boost update %s` to refresh it" % name)
|
|
78
|
+
tap = Tap(name=name, url=url, curated=curated)
|
|
79
|
+
paths.ensure_dirs()
|
|
80
|
+
if tap.path.exists():
|
|
81
|
+
shutil.rmtree(tap.path)
|
|
82
|
+
gitutil.clone_shallow(url, tap.path)
|
|
83
|
+
cfg = config.load()
|
|
84
|
+
cfg.setdefault("taps", []).append(
|
|
85
|
+
{"name": name, "url": url, "curated": curated})
|
|
86
|
+
config.save(cfg)
|
|
87
|
+
return tap
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def remove(name: str) -> Tap:
|
|
91
|
+
tap = get(name)
|
|
92
|
+
cfg = config.load()
|
|
93
|
+
cfg["taps"] = [t for t in cfg.get("taps", []) if t["name"] != tap.name]
|
|
94
|
+
config.save(cfg)
|
|
95
|
+
if tap.path.exists():
|
|
96
|
+
shutil.rmtree(tap.path)
|
|
97
|
+
if tap.cache_file.exists():
|
|
98
|
+
tap.cache_file.unlink()
|
|
99
|
+
return tap
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def update(name: Optional[str] = None) -> dict:
|
|
103
|
+
"""git-pull one tap (or all). Returns {tap_name: summary}."""
|
|
104
|
+
targets = [get(name)] if name else list_taps()
|
|
105
|
+
results = {}
|
|
106
|
+
for tap in targets:
|
|
107
|
+
if not tap.is_cloned:
|
|
108
|
+
gitutil.clone_shallow(tap.url, tap.path)
|
|
109
|
+
results[tap.name] = "cloned"
|
|
110
|
+
else:
|
|
111
|
+
results[tap.name] = gitutil.pull(tap.path)
|
|
112
|
+
return results
|