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