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,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())
@@ -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")
@@ -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)
@@ -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