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
boost_cli/core/agents.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""AI agent targets: where installed skills get symlinked."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Dict
|
|
6
|
+
|
|
7
|
+
from . import config, paths
|
|
8
|
+
|
|
9
|
+
DISPLAY = {"claude-code": "Claude Code", "windsurf": "Windsurf", "cursor": "Cursor"}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def known_agents() -> Dict[str, dict]:
|
|
13
|
+
"""{name: {"dir": Path, "enabled": bool}} for every configured agent."""
|
|
14
|
+
out = {}
|
|
15
|
+
for name, spec in (config.get("agents") or {}).items():
|
|
16
|
+
out[name] = {
|
|
17
|
+
"dir": paths.expand(str(spec.get("dir", ""))),
|
|
18
|
+
"enabled": bool(spec.get("enabled", True)),
|
|
19
|
+
}
|
|
20
|
+
return out
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def enabled_agents() -> Dict[str, Path]:
|
|
24
|
+
return {n: s["dir"] for n, s in known_agents().items() if s["enabled"]}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def display_name(agent: str) -> str:
|
|
28
|
+
return DISPLAY.get(agent, agent)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def ensure_agent_dirs() -> None:
|
|
32
|
+
for d in enabled_agents().values():
|
|
33
|
+
d.mkdir(parents=True, exist_ok=True)
|
boost_cli/core/ai.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Optional AI bridge for the Intelligence commands and --smart ranking.
|
|
2
|
+
|
|
3
|
+
Strategy: prefer the `claude` CLI if on PATH, else the Anthropic API via
|
|
4
|
+
urllib when ANTHROPIC_API_KEY is set, else return None so every caller
|
|
5
|
+
degrades to its non-AI fallback.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import urllib.error
|
|
14
|
+
import urllib.request
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from . import config
|
|
18
|
+
|
|
19
|
+
API_URL = "https://api.anthropic.com/v1/messages"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def enabled() -> bool:
|
|
23
|
+
return bool(config.get("ai.enabled", True)) and not os.environ.get("BOOST_NO_AI")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def available() -> bool:
|
|
27
|
+
return enabled() and (has_cli() or bool(os.environ.get("ANTHROPIC_API_KEY")))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def has_cli() -> bool:
|
|
31
|
+
return shutil.which("claude") is not None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def fallback_note() -> str:
|
|
35
|
+
return ("AI features need the `claude` CLI on PATH or ANTHROPIC_API_KEY set "
|
|
36
|
+
"— using the heuristic fallback")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def ask(prompt: str, system: Optional[str] = None, model: Optional[str] = None,
|
|
40
|
+
max_tokens: int = 1500, timeout: int = 120) -> Optional[str]:
|
|
41
|
+
"""Ask Claude. Returns the text reply, or None if AI is unavailable/fails."""
|
|
42
|
+
if not enabled():
|
|
43
|
+
return None
|
|
44
|
+
model = model or str(config.get("ai.model"))
|
|
45
|
+
if has_cli():
|
|
46
|
+
text = _ask_cli(prompt, system, model, timeout)
|
|
47
|
+
if text:
|
|
48
|
+
return text
|
|
49
|
+
if os.environ.get("ANTHROPIC_API_KEY"):
|
|
50
|
+
return _ask_api(prompt, system, model, max_tokens, timeout)
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def ask_author(prompt: str, system: Optional[str] = None,
|
|
55
|
+
max_tokens: int = 4000) -> Optional[str]:
|
|
56
|
+
"""Authoring-grade call (infer/distill/evolve) using the bigger model."""
|
|
57
|
+
return ask(prompt, system=system, model=str(config.get("ai.author_model")),
|
|
58
|
+
max_tokens=max_tokens, timeout=240)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _ask_cli(prompt: str, system: Optional[str], model: str,
|
|
62
|
+
timeout: int) -> Optional[str]:
|
|
63
|
+
cmd = ["claude", "-p", "--model", model, "--output-format", "text"]
|
|
64
|
+
if system:
|
|
65
|
+
cmd += ["--append-system-prompt", system]
|
|
66
|
+
try:
|
|
67
|
+
proc = subprocess.run(cmd, input=prompt, capture_output=True,
|
|
68
|
+
text=True, timeout=timeout)
|
|
69
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
70
|
+
return None
|
|
71
|
+
if proc.returncode != 0:
|
|
72
|
+
return None
|
|
73
|
+
return proc.stdout.strip() or None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _ask_api(prompt: str, system: Optional[str], model: str,
|
|
77
|
+
max_tokens: int, timeout: int) -> Optional[str]:
|
|
78
|
+
body = {
|
|
79
|
+
"model": model,
|
|
80
|
+
"max_tokens": max_tokens,
|
|
81
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
82
|
+
}
|
|
83
|
+
if system:
|
|
84
|
+
body["system"] = system
|
|
85
|
+
req = urllib.request.Request(
|
|
86
|
+
API_URL,
|
|
87
|
+
data=json.dumps(body).encode(),
|
|
88
|
+
headers={
|
|
89
|
+
"content-type": "application/json",
|
|
90
|
+
"x-api-key": os.environ["ANTHROPIC_API_KEY"],
|
|
91
|
+
"anthropic-version": "2023-06-01",
|
|
92
|
+
},
|
|
93
|
+
method="POST",
|
|
94
|
+
)
|
|
95
|
+
try:
|
|
96
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
97
|
+
data = json.loads(resp.read().decode())
|
|
98
|
+
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError):
|
|
99
|
+
return None
|
|
100
|
+
parts = [b.get("text", "") for b in data.get("content", [])
|
|
101
|
+
if b.get("type") == "text"]
|
|
102
|
+
return "\n".join(parts).strip() or None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def extract_markdown(text: str) -> str:
|
|
106
|
+
"""Strip a ```markdown fence if the model wrapped its answer in one."""
|
|
107
|
+
t = (text or "").strip()
|
|
108
|
+
if t.startswith("```"):
|
|
109
|
+
lines = t.splitlines()
|
|
110
|
+
if lines[0].startswith("```"):
|
|
111
|
+
lines = lines[1:]
|
|
112
|
+
if lines and lines[-1].strip() == "```":
|
|
113
|
+
lines = lines[:-1]
|
|
114
|
+
return "\n".join(lines).strip()
|
|
115
|
+
return t
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Skill catalogs: scan tap repos for SKILL.md files -> JSON caches -> search.
|
|
2
|
+
|
|
3
|
+
A catalog entry (plain dict) has:
|
|
4
|
+
name, description, version, tap, curated,
|
|
5
|
+
rel_dir -- skill directory relative to the tap repo root
|
|
6
|
+
skill_md -- path of the SKILL.md relative to the repo root
|
|
7
|
+
meta -- full parsed frontmatter
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import List, Optional
|
|
15
|
+
|
|
16
|
+
from ..errors import BoostError
|
|
17
|
+
from . import frontmatter, gitutil, paths, registry, util
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def scan_dir(root: Path, tap_name: str = "local", curated: bool = False) -> List[dict]:
|
|
21
|
+
root = Path(root)
|
|
22
|
+
entries: List[dict] = []
|
|
23
|
+
for skill_md in sorted(root.rglob("SKILL.md")):
|
|
24
|
+
if any(part in util.IGNORED for part in skill_md.parts):
|
|
25
|
+
continue
|
|
26
|
+
try:
|
|
27
|
+
meta, body = frontmatter.parse(
|
|
28
|
+
skill_md.read_text(encoding="utf-8", errors="replace"))
|
|
29
|
+
except OSError:
|
|
30
|
+
continue
|
|
31
|
+
skill_dir = skill_md.parent
|
|
32
|
+
name = str(meta.get("name") or "").strip() or (
|
|
33
|
+
skill_dir.name if skill_dir != root else root.name)
|
|
34
|
+
desc = str(meta.get("description") or "").strip()
|
|
35
|
+
if not desc:
|
|
36
|
+
for line in body.splitlines():
|
|
37
|
+
line = line.strip()
|
|
38
|
+
if line and not line.startswith("#"):
|
|
39
|
+
desc = line[:160]
|
|
40
|
+
break
|
|
41
|
+
entries.append({
|
|
42
|
+
"name": util.slugify(name) if " " in name else name,
|
|
43
|
+
"description": desc,
|
|
44
|
+
"version": str(meta.get("version") or "0.0.0"),
|
|
45
|
+
"tap": tap_name,
|
|
46
|
+
"curated": curated,
|
|
47
|
+
"rel_dir": str(skill_dir.relative_to(root)) if skill_dir != root else ".",
|
|
48
|
+
"skill_md": str(skill_md.relative_to(root)),
|
|
49
|
+
"meta": meta,
|
|
50
|
+
})
|
|
51
|
+
return entries
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def rebuild_tap(tap: "registry.Tap") -> List[dict]:
|
|
55
|
+
if not tap.is_cloned:
|
|
56
|
+
raise BoostError("tap %s is not cloned" % tap.name,
|
|
57
|
+
hint="run `boost update %s`" % tap.name)
|
|
58
|
+
entries = scan_dir(tap.path, tap.name, tap.curated)
|
|
59
|
+
paths.ensure_dirs()
|
|
60
|
+
tap.cache_file.write_text(json.dumps({
|
|
61
|
+
"tap": tap.name,
|
|
62
|
+
"url": tap.url,
|
|
63
|
+
"generated": util.now_iso(),
|
|
64
|
+
"commit": gitutil.head_commit(tap.path),
|
|
65
|
+
"skills": entries,
|
|
66
|
+
}, indent=1))
|
|
67
|
+
return entries
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def load_tap(tap: "registry.Tap", rebuild: bool = False) -> List[dict]:
|
|
71
|
+
if not rebuild and tap.cache_file.exists():
|
|
72
|
+
try:
|
|
73
|
+
return json.loads(tap.cache_file.read_text()).get("skills", [])
|
|
74
|
+
except (json.JSONDecodeError, OSError):
|
|
75
|
+
pass
|
|
76
|
+
if tap.is_cloned:
|
|
77
|
+
return rebuild_tap(tap)
|
|
78
|
+
return []
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def all_entries() -> List[dict]:
|
|
82
|
+
out: List[dict] = []
|
|
83
|
+
for tap in registry.list_taps():
|
|
84
|
+
out.extend(load_tap(tap))
|
|
85
|
+
return out
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def find(name: str, tap: Optional[str] = None) -> List[dict]:
|
|
89
|
+
"""Exact-name lookup. Supports 'owner/repo:skill' qualified form."""
|
|
90
|
+
if ":" in name:
|
|
91
|
+
tap, name = name.rsplit(":", 1)
|
|
92
|
+
matches = [e for e in all_entries() if e["name"] == name]
|
|
93
|
+
if tap:
|
|
94
|
+
matches = [e for e in matches if e["tap"] == tap or
|
|
95
|
+
e["tap"].split("/")[-1] == tap]
|
|
96
|
+
return matches
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def resolve_one(name: str) -> dict:
|
|
100
|
+
"""Find exactly one entry or raise with a helpful hint."""
|
|
101
|
+
matches = find(name)
|
|
102
|
+
if not matches:
|
|
103
|
+
scored = search(name)[:3]
|
|
104
|
+
hint = None
|
|
105
|
+
if scored:
|
|
106
|
+
hint = "closest matches: " + ", ".join(e["name"] for e, _ in scored)
|
|
107
|
+
elif not registry.list_taps():
|
|
108
|
+
hint = "no taps configured — start with `boost tap --defaults`"
|
|
109
|
+
raise BoostError("no skill named %r in any tap" % name, hint=hint)
|
|
110
|
+
if len(matches) > 1:
|
|
111
|
+
raise BoostError(
|
|
112
|
+
"%r exists in multiple taps: %s" % (name, ", ".join(e["tap"] for e in matches)),
|
|
113
|
+
hint="qualify it, e.g. `%s:%s`" % (matches[0]["tap"], name))
|
|
114
|
+
return matches[0]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def search(query: str, entries: Optional[List[dict]] = None):
|
|
118
|
+
"""Rank entries against a query -> [(entry, score)] best-first."""
|
|
119
|
+
entries = all_entries() if entries is None else entries
|
|
120
|
+
q = query.lower().strip()
|
|
121
|
+
tokens = [t for t in re.split(r"[\s,/_-]+", q) if t]
|
|
122
|
+
scored = []
|
|
123
|
+
for e in entries:
|
|
124
|
+
name = e["name"].lower()
|
|
125
|
+
desc = (e["description"] or "").lower()
|
|
126
|
+
blob = " ".join([name, desc, json.dumps(e.get("meta", {})).lower()])
|
|
127
|
+
score = 0
|
|
128
|
+
if q == name:
|
|
129
|
+
score += 100
|
|
130
|
+
elif name.startswith(q):
|
|
131
|
+
score += 80
|
|
132
|
+
elif q in name:
|
|
133
|
+
score += 60
|
|
134
|
+
if q and q in desc:
|
|
135
|
+
score += 30
|
|
136
|
+
score += sum(12 for t in tokens if t in name)
|
|
137
|
+
score += sum(6 for t in tokens if t in desc)
|
|
138
|
+
score += sum(2 for t in tokens if t in blob)
|
|
139
|
+
if score > 0:
|
|
140
|
+
if e.get("curated"):
|
|
141
|
+
score += 3 # tiebreak only — never lifts a non-match into results
|
|
142
|
+
scored.append((e, score))
|
|
143
|
+
scored.sort(key=lambda x: (-x[1], x[0]["name"]))
|
|
144
|
+
return scored
|
boost_cli/core/config.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Configuration: ~/.boost/config.json with deep-merged defaults."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from . import paths
|
|
9
|
+
|
|
10
|
+
DEFAULTS = {
|
|
11
|
+
"agents": {
|
|
12
|
+
"claude-code": {"dir": "~/.claude/skills", "enabled": True},
|
|
13
|
+
"windsurf": {"dir": "~/.windsurf/skills", "enabled": True},
|
|
14
|
+
"cursor": {"dir": "~/.cursor/skills", "enabled": True},
|
|
15
|
+
},
|
|
16
|
+
"taps": [], # [{"name": "owner/repo", "url": "...", "curated": bool}]
|
|
17
|
+
"ai": {
|
|
18
|
+
"enabled": True,
|
|
19
|
+
"model": "claude-haiku-4-5-20251001", # ranking / quick summaries
|
|
20
|
+
"author_model": "claude-sonnet-5", # authoring (infer/distill/evolve)
|
|
21
|
+
},
|
|
22
|
+
"serve": {"port": 8787},
|
|
23
|
+
"policy_enforce": True,
|
|
24
|
+
"telemetry": False,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
# Recommended public registries, added via `boost tap --defaults`.
|
|
28
|
+
DEFAULT_TAPS = [
|
|
29
|
+
{"name": "anthropics/skills", "url": "https://github.com/anthropics/skills",
|
|
30
|
+
"curated": True,
|
|
31
|
+
"focus": "Official Anthropic skills — docs, artifacts, MCP, agent workflows"},
|
|
32
|
+
{"name": "obra/superpowers", "url": "https://github.com/obra/superpowers",
|
|
33
|
+
"curated": True,
|
|
34
|
+
"focus": "Community powerhouse — TDD, debugging, planning, subagents"},
|
|
35
|
+
{"name": "trailofbits/skills", "url": "https://github.com/trailofbits/skills",
|
|
36
|
+
"curated": True,
|
|
37
|
+
"focus": "Security auditing from Trail of Bits — CodeQL, Semgrep, vuln hunting"},
|
|
38
|
+
{"name": "expo/skills", "url": "https://github.com/expo/skills",
|
|
39
|
+
"curated": True,
|
|
40
|
+
"focus": "Official Expo team skills — EAS builds, app stores, deployments"},
|
|
41
|
+
{"name": "K-Dense-AI/claude-scientific-skills",
|
|
42
|
+
"url": "https://github.com/K-Dense-AI/claude-scientific-skills",
|
|
43
|
+
"curated": True,
|
|
44
|
+
"focus": "Scientific computing — research libraries, databases, analysis"},
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _merge(base: dict, override: dict) -> dict:
|
|
49
|
+
out = deepcopy(base)
|
|
50
|
+
for k, v in (override or {}).items():
|
|
51
|
+
if isinstance(v, dict) and isinstance(out.get(k), dict):
|
|
52
|
+
out[k] = _merge(out[k], v)
|
|
53
|
+
else:
|
|
54
|
+
out[k] = v
|
|
55
|
+
return out
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def load() -> dict:
|
|
59
|
+
p = paths.config_path()
|
|
60
|
+
if not p.exists():
|
|
61
|
+
return deepcopy(DEFAULTS)
|
|
62
|
+
try:
|
|
63
|
+
user = json.loads(p.read_text())
|
|
64
|
+
except (json.JSONDecodeError, OSError):
|
|
65
|
+
return deepcopy(DEFAULTS)
|
|
66
|
+
return _merge(DEFAULTS, user)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def save(cfg: dict) -> None:
|
|
70
|
+
paths.ensure_dirs()
|
|
71
|
+
paths.config_path().write_text(json.dumps(cfg, indent=2, sort_keys=False) + "\n")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def get(dotted: str, default=None):
|
|
75
|
+
node = load()
|
|
76
|
+
for part in dotted.split("."):
|
|
77
|
+
if not isinstance(node, dict) or part not in node:
|
|
78
|
+
return default
|
|
79
|
+
node = node[part]
|
|
80
|
+
return node
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def set_value(dotted: str, raw: str) -> None:
|
|
84
|
+
"""Set a dotted key. Values parse as JSON when possible, else string."""
|
|
85
|
+
try:
|
|
86
|
+
value = json.loads(raw)
|
|
87
|
+
except (json.JSONDecodeError, TypeError):
|
|
88
|
+
value = raw
|
|
89
|
+
cfg = load()
|
|
90
|
+
node: Any = cfg
|
|
91
|
+
parts = dotted.split(".")
|
|
92
|
+
for part in parts[:-1]:
|
|
93
|
+
node = node.setdefault(part, {})
|
|
94
|
+
if not isinstance(node, dict):
|
|
95
|
+
raise TypeError("config key %r is not a section" % part)
|
|
96
|
+
node[parts[-1]] = value
|
|
97
|
+
save(cfg)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def unset(dotted: str) -> bool:
|
|
101
|
+
cfg = load()
|
|
102
|
+
node: Any = cfg
|
|
103
|
+
parts = dotted.split(".")
|
|
104
|
+
for part in parts[:-1]:
|
|
105
|
+
node = node.get(part)
|
|
106
|
+
if not isinstance(node, dict):
|
|
107
|
+
return False
|
|
108
|
+
if parts[-1] in node:
|
|
109
|
+
del node[parts[-1]]
|
|
110
|
+
save(cfg)
|
|
111
|
+
return True
|
|
112
|
+
return False
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Minimal YAML-frontmatter parser for SKILL.md files (stdlib only).
|
|
2
|
+
|
|
3
|
+
Supports the subset actually used by skill files:
|
|
4
|
+
---
|
|
5
|
+
name: brainstorming
|
|
6
|
+
description: Structured ideation &
|
|
7
|
+
divergent-thinking facilitation # folded continuation lines
|
|
8
|
+
version: 1.4.0
|
|
9
|
+
tags: [ideation, thinking] # flow lists
|
|
10
|
+
requires:
|
|
11
|
+
- other-skill # block lists
|
|
12
|
+
---
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Tuple
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def split(text: str) -> Tuple[str, str]:
|
|
20
|
+
"""Return (frontmatter_block, body). Frontmatter block may be ""."""
|
|
21
|
+
if not text.startswith("---"):
|
|
22
|
+
return "", text
|
|
23
|
+
lines = text.splitlines()
|
|
24
|
+
if not lines or lines[0].strip() != "---":
|
|
25
|
+
return "", text
|
|
26
|
+
for i in range(1, len(lines)):
|
|
27
|
+
if lines[i].strip() in ("---", "..."):
|
|
28
|
+
return "\n".join(lines[1:i]), "\n".join(lines[i + 1:]).lstrip("\n")
|
|
29
|
+
return "", text
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _scalar(raw: str):
|
|
33
|
+
s = raw.strip()
|
|
34
|
+
if not s:
|
|
35
|
+
return ""
|
|
36
|
+
if len(s) >= 2 and s[0] == s[-1] and s[0] in "'\"":
|
|
37
|
+
return s[1:-1]
|
|
38
|
+
low = s.lower()
|
|
39
|
+
if low in ("true", "yes", "on"):
|
|
40
|
+
return True
|
|
41
|
+
if low in ("false", "no", "off"):
|
|
42
|
+
return False
|
|
43
|
+
if low in ("null", "~", "none"):
|
|
44
|
+
return None
|
|
45
|
+
try:
|
|
46
|
+
return int(s)
|
|
47
|
+
except ValueError:
|
|
48
|
+
pass
|
|
49
|
+
try:
|
|
50
|
+
return float(s)
|
|
51
|
+
except ValueError:
|
|
52
|
+
pass
|
|
53
|
+
return s
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _flow_list(s: str):
|
|
57
|
+
inner = s.strip()[1:-1]
|
|
58
|
+
if not inner.strip():
|
|
59
|
+
return []
|
|
60
|
+
return [_scalar(part) for part in _split_commas(inner)]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _split_commas(s: str):
|
|
64
|
+
parts, buf, quote = [], "", None
|
|
65
|
+
for ch in s:
|
|
66
|
+
if quote:
|
|
67
|
+
buf += ch
|
|
68
|
+
if ch == quote:
|
|
69
|
+
quote = None
|
|
70
|
+
elif ch in "'\"":
|
|
71
|
+
buf += ch
|
|
72
|
+
quote = ch
|
|
73
|
+
elif ch == ",":
|
|
74
|
+
parts.append(buf)
|
|
75
|
+
buf = ""
|
|
76
|
+
else:
|
|
77
|
+
buf += ch
|
|
78
|
+
if buf.strip():
|
|
79
|
+
parts.append(buf)
|
|
80
|
+
return parts
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def parse_block(block: str) -> dict:
|
|
84
|
+
"""Parse a frontmatter block into a dict. Best-effort, never raises."""
|
|
85
|
+
meta: dict = {}
|
|
86
|
+
key = None
|
|
87
|
+
in_block = False # inside a | / > block scalar
|
|
88
|
+
block_indent = 0 # indent of the key line that opened it
|
|
89
|
+
for raw in block.splitlines():
|
|
90
|
+
if not raw.strip():
|
|
91
|
+
continue
|
|
92
|
+
indent = len(raw) - len(raw.lstrip())
|
|
93
|
+
line = raw.strip()
|
|
94
|
+
# block-scalar content: every deeper-indented line folds in verbatim
|
|
95
|
+
if in_block:
|
|
96
|
+
if indent > block_indent:
|
|
97
|
+
prev = meta.get(key)
|
|
98
|
+
if isinstance(prev, str):
|
|
99
|
+
meta[key] = (prev + " " + line).strip()
|
|
100
|
+
continue
|
|
101
|
+
in_block = False
|
|
102
|
+
if line.startswith("#"):
|
|
103
|
+
continue
|
|
104
|
+
# block-list item under the current key
|
|
105
|
+
if line.startswith("- ") or line == "-":
|
|
106
|
+
if key is None:
|
|
107
|
+
continue
|
|
108
|
+
if not isinstance(meta.get(key), list):
|
|
109
|
+
meta[key] = [] if meta.get(key) in ("", None) else [meta[key]]
|
|
110
|
+
meta[key].append(_scalar(line[1:].strip()))
|
|
111
|
+
continue
|
|
112
|
+
# folded continuation of the previous plain scalar
|
|
113
|
+
if indent > 0 and ":" not in line and key is not None:
|
|
114
|
+
prev = meta.get(key)
|
|
115
|
+
if isinstance(prev, str):
|
|
116
|
+
meta[key] = (prev + " " + line).strip()
|
|
117
|
+
continue
|
|
118
|
+
if ":" not in line:
|
|
119
|
+
continue
|
|
120
|
+
k, _, v = line.partition(":")
|
|
121
|
+
key = k.strip()
|
|
122
|
+
v = v.strip()
|
|
123
|
+
# strip trailing comments on unquoted scalars
|
|
124
|
+
if v and not v.startswith(("'", '"', "[")) and " #" in v:
|
|
125
|
+
v = v.split(" #", 1)[0].strip()
|
|
126
|
+
if v.startswith("[") and v.endswith("]"):
|
|
127
|
+
meta[key] = _flow_list(v)
|
|
128
|
+
elif v in ("|", "|-", "|+", ">", ">-", ">+"):
|
|
129
|
+
meta[key] = "" # block scalar: folded from the indented lines below
|
|
130
|
+
in_block, block_indent = True, indent
|
|
131
|
+
else:
|
|
132
|
+
meta[key] = _scalar(v)
|
|
133
|
+
return meta
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def parse(text: str) -> Tuple[dict, str]:
|
|
137
|
+
"""Parse a SKILL.md's text -> (frontmatter dict, markdown body)."""
|
|
138
|
+
block, body = split(text)
|
|
139
|
+
return (parse_block(block) if block else {}), body
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def dump(meta: dict) -> str:
|
|
143
|
+
"""Serialize a dict back to a frontmatter block (--- fenced)."""
|
|
144
|
+
lines = ["---"]
|
|
145
|
+
for k, v in meta.items():
|
|
146
|
+
if isinstance(v, list):
|
|
147
|
+
lines.append("%s:" % k)
|
|
148
|
+
for item in v:
|
|
149
|
+
lines.append(" - %s" % item)
|
|
150
|
+
elif isinstance(v, bool):
|
|
151
|
+
lines.append("%s: %s" % (k, "true" if v else "false"))
|
|
152
|
+
elif v is None:
|
|
153
|
+
lines.append("%s:" % k)
|
|
154
|
+
else:
|
|
155
|
+
s = str(v)
|
|
156
|
+
if ":" in s or s != s.strip():
|
|
157
|
+
s = '"%s"' % s.replace('"', '\\"')
|
|
158
|
+
lines.append("%s: %s" % (k, s))
|
|
159
|
+
lines.append("---")
|
|
160
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Thin git wrapper (stdlib subprocess only)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import List, Optional
|
|
8
|
+
|
|
9
|
+
from ..errors import BoostError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def has_git() -> bool:
|
|
13
|
+
return shutil.which("git") is not None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def run(args: List[str], cwd: Optional[Path] = None, check: bool = True,
|
|
17
|
+
timeout: int = 300) -> subprocess.CompletedProcess:
|
|
18
|
+
if not has_git():
|
|
19
|
+
raise BoostError("git is required but was not found on PATH",
|
|
20
|
+
hint="install git, e.g. `xcode-select --install` or `brew install git`")
|
|
21
|
+
try:
|
|
22
|
+
proc = subprocess.run(
|
|
23
|
+
["git"] + args, cwd=str(cwd) if cwd else None,
|
|
24
|
+
capture_output=True, text=True, timeout=timeout,
|
|
25
|
+
)
|
|
26
|
+
except subprocess.TimeoutExpired:
|
|
27
|
+
raise BoostError("git %s timed out after %ds" % (args[0], timeout))
|
|
28
|
+
if check and proc.returncode != 0:
|
|
29
|
+
detail = (proc.stderr or proc.stdout or "").strip().splitlines()
|
|
30
|
+
raise BoostError("git %s failed: %s" % (args[0], detail[-1] if detail else "unknown error"))
|
|
31
|
+
return proc
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def clone_shallow(url: str, dest: Path) -> None:
|
|
35
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
run(["clone", "--depth", "1", "--quiet", url, str(dest)], timeout=600)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def pull(repo: Path) -> str:
|
|
40
|
+
"""Update a shallow clone. Returns a one-line summary."""
|
|
41
|
+
before = head_commit(repo)
|
|
42
|
+
run(["-C", str(repo), "fetch", "--depth", "1", "--quiet", "origin"])
|
|
43
|
+
run(["-C", str(repo), "reset", "--hard", "--quiet", "origin/HEAD"], check=False)
|
|
44
|
+
# origin/HEAD may be unset on old git; fall back to the fetched head
|
|
45
|
+
if head_commit(repo) == before:
|
|
46
|
+
run(["-C", str(repo), "reset", "--hard", "--quiet", "FETCH_HEAD"])
|
|
47
|
+
after = head_commit(repo)
|
|
48
|
+
return "already up to date" if before == after else "%s → %s" % (before[:7], after[:7])
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def head_commit(repo: Path) -> str:
|
|
52
|
+
proc = run(["-C", str(repo), "rev-parse", "HEAD"], check=False)
|
|
53
|
+
return proc.stdout.strip() if proc.returncode == 0 else ""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def remote_url(repo: Path) -> str:
|
|
57
|
+
proc = run(["-C", str(repo), "remote", "get-url", "origin"], check=False)
|
|
58
|
+
return proc.stdout.strip() if proc.returncode == 0 else ""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def log_for_path(repo: Path, rel_path: str = ".", n: int = 20) -> List[str]:
|
|
62
|
+
"""Formatted one-line log entries for a path inside a repo."""
|
|
63
|
+
proc = run(["-C", str(repo), "log", "--date=short", "-n", str(n),
|
|
64
|
+
"--pretty=format:%h %ad %an %s", "--", rel_path], check=False)
|
|
65
|
+
return [ln for ln in proc.stdout.splitlines() if ln.strip()]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def is_repo(path: Path) -> bool:
|
|
69
|
+
return (Path(path) / ".git").exists()
|