kea-brain 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.
- kea/__init__.py +8 -0
- kea/__main__.py +3 -0
- kea/_version.py +1 -0
- kea/brain.py +506 -0
- kea/cli.py +1251 -0
- kea/council.py +630 -0
- kea/data/MANUAL.md +366 -0
- kea/data/example/DECISIONS.md +36 -0
- kea/data/example/IDEAS.md +16 -0
- kea/data/example/METRICS.md +13 -0
- kea/data/example/PREDICTIONS.md +68 -0
- kea/data/example/ROADMAP.md +27 -0
- kea/data/example/STATE.md +42 -0
- kea/data/example/frameworks/INDEX.md +7 -0
- kea/data/example/frameworks/constraints.md +94 -0
- kea/data/example/journal/wholesale-samples.md +11 -0
- kea/data/example/plans/second-cafe/00-proposal.md +64 -0
- kea/data/example/plans/second-cafe/01-review-questions.md +9 -0
- kea/data/example/plans/second-cafe/reviews/example_chair_your-agent.md +25 -0
- kea/data/example/plans/second-cafe/reviews/example_contrarian_gpt-oss-120b.md +26 -0
- kea/data/example/plans/second-cafe/reviews/example_operator_mistral-small-latest.md +26 -0
- kea/data/kea.1 +421 -0
- kea/data/skills/kea/SKILL.md +75 -0
- kea/data/skills/kea-council/SKILL.md +47 -0
- kea/data/skills/kea-frameworks/SKILL.md +32 -0
- kea/data/templates/AGENTS.md +63 -0
- kea/data/templates/CLAUDE.md +1 -0
- kea/data/templates/DECISIONS.md +9 -0
- kea/data/templates/IDEAS.md +8 -0
- kea/data/templates/LEARNING.md +17 -0
- kea/data/templates/METRICS.md +8 -0
- kea/data/templates/PREDICTIONS.md +11 -0
- kea/data/templates/README.md +21 -0
- kea/data/templates/ROADMAP.md +28 -0
- kea/data/templates/SOURCES.md +22 -0
- kea/data/templates/STATE.md +23 -0
- kea/data/templates/VOICE.md +16 -0
- kea/data/templates/council/PROTOCOL.md +69 -0
- kea/data/templates/council/roster.toml +80 -0
- kea/data/templates/frameworks/INDEX.md +16 -0
- kea/data/templates/frameworks/_TEMPLATE.md +80 -0
- kea/data/templates/gitignore +6 -0
- kea/data/templates/journal/README.md +4 -0
- kea/data/templates/kea.toml +27 -0
- kea/data/templates/plans/_template/00-proposal.md +79 -0
- kea/data/templates/plans/_template/01-review-questions.md +36 -0
- kea/data/templates/plans/_template/20-production-plan.md +44 -0
- kea/data/templates/plans/_template/30-execution-log.md +23 -0
- kea/digest.py +210 -0
- kea/ingest.py +1014 -0
- kea/ledger.py +913 -0
- kea/markdown.py +431 -0
- kea/mcp.py +756 -0
- kea/providers.py +333 -0
- kea/py.typed +0 -0
- kea/redact.py +517 -0
- kea/skills.py +92 -0
- kea/store.py +499 -0
- kea_brain-1.0.1.dist-info/METADATA +307 -0
- kea_brain-1.0.1.dist-info/RECORD +64 -0
- kea_brain-1.0.1.dist-info/WHEEL +5 -0
- kea_brain-1.0.1.dist-info/entry_points.txt +2 -0
- kea_brain-1.0.1.dist-info/licenses/LICENSE +21 -0
- kea_brain-1.0.1.dist-info/top_level.txt +1 -0
kea/__init__.py
ADDED
kea/__main__.py
ADDED
kea/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.1"
|
kea/brain.py
ADDED
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Find a brain, read its settings, keep its clock, and make new ones.
|
|
3
|
+
|
|
4
|
+
A brain is a folder with a ``kea.toml`` in it. kea finds one in this order: ``--brain``, then ``$KEA_BRAIN``, then the
|
|
5
|
+
current folder and its parents, then the default brain named in ``~/.config/kea/config.toml``. A folder without
|
|
6
|
+
``kea.toml`` is never treated as a brain, so notes that kea did not make are never touched.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import calendar
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess # nosec B404: runs git init and git rev-parse with fixed arguments only
|
|
14
|
+
import tomllib
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from datetime import date, datetime, timedelta
|
|
17
|
+
from importlib import resources
|
|
18
|
+
from importlib.resources.abc import Traversable
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
22
|
+
|
|
23
|
+
MARKER = "kea.toml"
|
|
24
|
+
INDEX_DIR = ".kea"
|
|
25
|
+
# Template files are shipped without a leading dot, so packaging tools never skip them.
|
|
26
|
+
DOTFILES = {"gitignore": ".gitignore"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class BrainError(Exception):
|
|
30
|
+
"""A brain could not be found, read or made."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def user_config_path() -> Path:
|
|
34
|
+
"""
|
|
35
|
+
Find the per user config file.
|
|
36
|
+
|
|
37
|
+
:returns: ``$XDG_CONFIG_HOME/kea/config.toml``, or ``~/.config/kea/config.toml``
|
|
38
|
+
"""
|
|
39
|
+
base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
|
|
40
|
+
return Path(base) / "kea" / "config.toml"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def default_brain() -> Path | None:
|
|
44
|
+
"""
|
|
45
|
+
Read the default brain from the per user config.
|
|
46
|
+
|
|
47
|
+
:returns: Its folder, or None when no default is set
|
|
48
|
+
"""
|
|
49
|
+
path = user_config_path()
|
|
50
|
+
if not path.is_file():
|
|
51
|
+
return None
|
|
52
|
+
try:
|
|
53
|
+
value = tomllib.loads(path.read_text(encoding="utf-8")).get("brain")
|
|
54
|
+
except tomllib.TOMLDecodeError:
|
|
55
|
+
return None
|
|
56
|
+
folder = Path(value).expanduser() if isinstance(value, str) and value else None
|
|
57
|
+
return folder if folder and (folder / MARKER).is_file() else None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def set_default_brain(folder: Path) -> Path:
|
|
61
|
+
"""
|
|
62
|
+
Name a brain as the default, keeping any other lines in the config.
|
|
63
|
+
|
|
64
|
+
:param folder: The brain folder
|
|
65
|
+
:returns: The config file written
|
|
66
|
+
"""
|
|
67
|
+
path = user_config_path()
|
|
68
|
+
line = f"brain = {_toml_string(str(folder.resolve()))}"
|
|
69
|
+
lines = path.read_text(encoding="utf-8").splitlines() if path.is_file() else ["# kea per user settings"]
|
|
70
|
+
lines = [ln for ln in lines if not re.match(r"^\s*brain\s*=", ln)]
|
|
71
|
+
first_table = next((i for i, ln in enumerate(lines) if ln.lstrip().startswith("[")), len(lines))
|
|
72
|
+
lines.insert(first_table, line)
|
|
73
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
75
|
+
return path
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _toml_string(value: str) -> str:
|
|
79
|
+
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class Brain:
|
|
84
|
+
"""A brain folder and its settings."""
|
|
85
|
+
|
|
86
|
+
root: Path
|
|
87
|
+
config: dict[str, Any]
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def load(cls, root: Path) -> "Brain":
|
|
91
|
+
"""
|
|
92
|
+
Open the brain in a folder.
|
|
93
|
+
|
|
94
|
+
:param root: The folder holding ``kea.toml``
|
|
95
|
+
:returns: The brain
|
|
96
|
+
:raises BrainError: When the folder holds no ``kea.toml`` or it is not valid TOML
|
|
97
|
+
"""
|
|
98
|
+
root = root.expanduser().resolve()
|
|
99
|
+
marker = root / MARKER
|
|
100
|
+
if not marker.is_file():
|
|
101
|
+
raise BrainError(f"{root} is not a kea brain (no {MARKER}); make one with: kea init {root}")
|
|
102
|
+
try:
|
|
103
|
+
config = tomllib.loads(marker.read_text(encoding="utf-8"))
|
|
104
|
+
except tomllib.TOMLDecodeError as exc:
|
|
105
|
+
raise BrainError(f"{marker}: {exc}") from exc
|
|
106
|
+
return cls(root, config)
|
|
107
|
+
|
|
108
|
+
@classmethod
|
|
109
|
+
def locate(cls, explicit: str | None = None, cwd: Path | None = None, prefer_default: bool = False) -> "Brain":
|
|
110
|
+
"""
|
|
111
|
+
Find the brain to work on.
|
|
112
|
+
|
|
113
|
+
:param explicit: A folder from ``--brain``, which wins over everything else
|
|
114
|
+
:param cwd: Where to start looking upwards; the current folder when omitted
|
|
115
|
+
:param prefer_default: Try the default brain before the folders above cwd, as the MCP server does, so a
|
|
116
|
+
``kea.toml`` inside a project an agent happens to run in never takes over
|
|
117
|
+
:returns: The brain
|
|
118
|
+
:raises BrainError: When no brain is found
|
|
119
|
+
"""
|
|
120
|
+
chosen = explicit or os.environ.get("KEA_BRAIN")
|
|
121
|
+
if chosen:
|
|
122
|
+
return cls.load(Path(chosen))
|
|
123
|
+
fallback = default_brain()
|
|
124
|
+
if prefer_default and fallback:
|
|
125
|
+
return cls.load(fallback)
|
|
126
|
+
here = (cwd or Path.cwd()).resolve()
|
|
127
|
+
for folder in (here, *here.parents):
|
|
128
|
+
if (folder / MARKER).is_file():
|
|
129
|
+
return cls.load(folder)
|
|
130
|
+
if fallback:
|
|
131
|
+
return cls.load(fallback)
|
|
132
|
+
raise BrainError(
|
|
133
|
+
"no kea brain found here, in any parent folder, or as the default. "
|
|
134
|
+
"Make one with: kea init ~/brain, or point at one with --brain or KEA_BRAIN"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def setting(self, section: str, key: str, default: Any = None) -> Any:
|
|
138
|
+
"""
|
|
139
|
+
Read one setting from ``kea.toml``.
|
|
140
|
+
|
|
141
|
+
:param section: The table, such as ``brain``
|
|
142
|
+
:param key: The key
|
|
143
|
+
:param default: What to return when it is missing
|
|
144
|
+
:returns: The value
|
|
145
|
+
"""
|
|
146
|
+
table = self.config.get(section)
|
|
147
|
+
return table.get(key, default) if isinstance(table, dict) else default
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def name(self) -> str:
|
|
151
|
+
"""
|
|
152
|
+
The brain's display name.
|
|
153
|
+
|
|
154
|
+
:returns: The brain's display name
|
|
155
|
+
"""
|
|
156
|
+
return str(self.setting("brain", "name", self.root.name))
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def owner(self) -> str:
|
|
160
|
+
"""
|
|
161
|
+
Who the brain belongs to: the person "Waiting for" means.
|
|
162
|
+
|
|
163
|
+
:returns: The owner's name
|
|
164
|
+
"""
|
|
165
|
+
return str(self.setting("brain", "owner", "") or "the owner")
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def timezone(self) -> str | None:
|
|
169
|
+
"""
|
|
170
|
+
The brain's IANA time zone, when set.
|
|
171
|
+
|
|
172
|
+
:returns: The brain's IANA time zone, when set
|
|
173
|
+
"""
|
|
174
|
+
value = self.setting("brain", "timezone")
|
|
175
|
+
return str(value) if value else None
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def index_path(self) -> Path:
|
|
179
|
+
"""
|
|
180
|
+
Where the local evidence index lives.
|
|
181
|
+
|
|
182
|
+
:returns: Where the local evidence index lives
|
|
183
|
+
"""
|
|
184
|
+
return self.root / INDEX_DIR / "index.sqlite"
|
|
185
|
+
|
|
186
|
+
def file(self, *parts: str) -> Path:
|
|
187
|
+
"""
|
|
188
|
+
Build a path inside the brain.
|
|
189
|
+
|
|
190
|
+
:param parts: Path parts
|
|
191
|
+
:returns: The path
|
|
192
|
+
"""
|
|
193
|
+
return self.root.joinpath(*parts)
|
|
194
|
+
|
|
195
|
+
def today(self) -> date:
|
|
196
|
+
"""
|
|
197
|
+
Give today's date in the brain's time zone.
|
|
198
|
+
|
|
199
|
+
:returns: Today
|
|
200
|
+
"""
|
|
201
|
+
return today(self.timezone)
|
|
202
|
+
|
|
203
|
+
def now(self) -> datetime:
|
|
204
|
+
"""
|
|
205
|
+
Give the time now in the brain's time zone.
|
|
206
|
+
|
|
207
|
+
:returns: Now
|
|
208
|
+
"""
|
|
209
|
+
return now(self.timezone)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def now(tz: str | None = None) -> datetime:
|
|
213
|
+
"""
|
|
214
|
+
Give the time now. ``$KEA_NOW`` fixes it, for reproducible demos and tests.
|
|
215
|
+
|
|
216
|
+
:param tz: An IANA time zone; the system's when omitted or unknown
|
|
217
|
+
:returns: An aware datetime
|
|
218
|
+
"""
|
|
219
|
+
fixed = os.environ.get("KEA_NOW")
|
|
220
|
+
if fixed:
|
|
221
|
+
moment = datetime.fromisoformat(fixed)
|
|
222
|
+
return moment if moment.tzinfo else moment.astimezone()
|
|
223
|
+
if tz:
|
|
224
|
+
try:
|
|
225
|
+
return datetime.now(ZoneInfo(tz))
|
|
226
|
+
except ZoneInfoNotFoundError, ValueError:
|
|
227
|
+
pass
|
|
228
|
+
return datetime.now().astimezone()
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def today(tz: str | None = None) -> date:
|
|
232
|
+
"""
|
|
233
|
+
Give today's date. ``$KEA_TODAY`` fixes it, for reproducible demos and tests.
|
|
234
|
+
|
|
235
|
+
:param tz: An IANA time zone
|
|
236
|
+
:returns: Today
|
|
237
|
+
"""
|
|
238
|
+
fixed = os.environ.get("KEA_TODAY")
|
|
239
|
+
return date.fromisoformat(fixed) if fixed else now(tz).date()
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def parse_date(text: str, base: date | None = None) -> date | None:
|
|
243
|
+
"""
|
|
244
|
+
Read a date: ``2026-12-01``, ``today``, ``tomorrow``, or an offset such as ``+30d``, ``+2w``, ``+3m`` or ``-7d``.
|
|
245
|
+
|
|
246
|
+
:param text: The text
|
|
247
|
+
:param base: The date offsets count from; today when omitted
|
|
248
|
+
:returns: The date, or None when the text is not a date
|
|
249
|
+
"""
|
|
250
|
+
text = text.strip().lower()
|
|
251
|
+
start = base or today()
|
|
252
|
+
if text == "today":
|
|
253
|
+
return start
|
|
254
|
+
if text == "tomorrow":
|
|
255
|
+
return start + timedelta(days=1)
|
|
256
|
+
m = re.fullmatch(r"([+-])(\d{1,4})([dwm])", text)
|
|
257
|
+
if m:
|
|
258
|
+
n, unit = int(m[2]) * (1 if m[1] == "+" else -1), m[3]
|
|
259
|
+
if unit == "d":
|
|
260
|
+
return start + timedelta(days=n)
|
|
261
|
+
if unit == "w":
|
|
262
|
+
return start + timedelta(weeks=n)
|
|
263
|
+
month = start.month - 1 + n
|
|
264
|
+
year, month = start.year + month // 12, month % 12 + 1
|
|
265
|
+
if not 1 <= year <= 9999:
|
|
266
|
+
return None
|
|
267
|
+
return date(year, month, min(start.day, calendar.monthrange(year, month)[1]))
|
|
268
|
+
m = re.search(r"\d{4}-\d{2}-\d{2}", text)
|
|
269
|
+
if m:
|
|
270
|
+
try:
|
|
271
|
+
return date.fromisoformat(m[0])
|
|
272
|
+
except ValueError:
|
|
273
|
+
return None
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _merge_gitignore(target: Path, text: str, written: list[str], notes: list[str]) -> None:
|
|
278
|
+
"""
|
|
279
|
+
Add kea's ignore lines to a .gitignore people wrote, skipping lines they already have or negate.
|
|
280
|
+
|
|
281
|
+
The file keeps its own bytes and line endings: kea appends ASCII lines, which is safe in UTF-8, Latin-1 and every
|
|
282
|
+
encoding that keeps ASCII as it is. git reads neither a linked nor a UTF-16 .gitignore, and cannot read one it
|
|
283
|
+
has no permission for, so for those kea writes its lines to the repository's ``info/exclude``, which git reads on
|
|
284
|
+
this machine only, or says what to do when the folder is in no repository.
|
|
285
|
+
"""
|
|
286
|
+
wanted = [line for line in text.splitlines() if line.strip() and not line.startswith("#")]
|
|
287
|
+
try:
|
|
288
|
+
raw: bytes | None = target.read_bytes()
|
|
289
|
+
except OSError:
|
|
290
|
+
raw = None
|
|
291
|
+
if target.is_symlink():
|
|
292
|
+
why = "git does not read a .gitignore that is a link"
|
|
293
|
+
elif raw is None:
|
|
294
|
+
why = "your .gitignore cannot be read"
|
|
295
|
+
elif raw.startswith((b"\xff\xfe", b"\xfe\xff")) or b"\x00" in raw:
|
|
296
|
+
why = "git does not read a .gitignore in UTF-16"
|
|
297
|
+
else:
|
|
298
|
+
why = ""
|
|
299
|
+
if why or raw is None:
|
|
300
|
+
_exclude(target.parent, wanted, notes, why or "your .gitignore cannot be read")
|
|
301
|
+
return
|
|
302
|
+
have = {line.strip().lstrip("!") for line in raw.decode("latin-1").splitlines()}
|
|
303
|
+
missing = [line for line in wanted if line.strip() not in have]
|
|
304
|
+
if not missing:
|
|
305
|
+
return
|
|
306
|
+
newline = "\r\n" if b"\r\n" in raw else "\n"
|
|
307
|
+
body = raw.decode("latin-1").rstrip("\r\n")
|
|
308
|
+
lines = ["# kea", *missing, ""]
|
|
309
|
+
target.write_bytes((body + newline * 2 + newline.join(lines) if body else newline.join(lines)).encode("latin-1"))
|
|
310
|
+
written.append(".gitignore")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _git_exclude(folder: Path) -> tuple[Path, str] | None:
|
|
314
|
+
"""Ask git where the repository holding a folder keeps info/exclude, and the folder's path from its top."""
|
|
315
|
+
git_bin = shutil.which("git")
|
|
316
|
+
if not git_bin:
|
|
317
|
+
return None
|
|
318
|
+
proc = subprocess.run( # nosec B603: git rev-parse, fixed arguments, the folder as a separate argument
|
|
319
|
+
[git_bin, "-C", str(folder), "rev-parse", "--git-path", "info/exclude", "--show-prefix"],
|
|
320
|
+
capture_output=True,
|
|
321
|
+
text=True,
|
|
322
|
+
check=False,
|
|
323
|
+
)
|
|
324
|
+
lines = proc.stdout.splitlines()
|
|
325
|
+
if proc.returncode != 0 or not lines:
|
|
326
|
+
return None
|
|
327
|
+
path = Path(lines[0])
|
|
328
|
+
return (path if path.is_absolute() else folder / path), (lines[1] if len(lines) > 1 else "")
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _exclude(folder: Path, wanted: list[str], notes: list[str], why: str) -> None:
|
|
332
|
+
"""Keep kea's private folders out of git when the .gitignore cannot do it: through the repository's exclude file."""
|
|
333
|
+
lines = ", ".join(wanted)
|
|
334
|
+
found = _git_exclude(folder)
|
|
335
|
+
if found is None:
|
|
336
|
+
notes.append(
|
|
337
|
+
f"{why}: make .gitignore a regular, readable UTF-8 file that holds {lines}, before you use git here"
|
|
338
|
+
)
|
|
339
|
+
return
|
|
340
|
+
exclude, prefix = found
|
|
341
|
+
# A pattern with a slash inside is anchored at the top of the repository, so it needs the brain's own path.
|
|
342
|
+
patterns = [f"/{prefix}{line.lstrip('/')}" if "/" in line.rstrip("/") else line for line in wanted]
|
|
343
|
+
try:
|
|
344
|
+
current = exclude.read_bytes().decode("latin-1") if exclude.is_file() else ""
|
|
345
|
+
have = {line.strip() for line in current.splitlines()}
|
|
346
|
+
missing = [line for line in patterns if line.strip() not in have]
|
|
347
|
+
if missing:
|
|
348
|
+
exclude.parent.mkdir(parents=True, exist_ok=True)
|
|
349
|
+
start = current.rstrip("\r\n") + "\n\n" if current.strip() else ""
|
|
350
|
+
exclude.write_bytes((start + "\n".join(["# kea", *missing]) + "\n").encode("latin-1"))
|
|
351
|
+
except OSError as exc:
|
|
352
|
+
notes.append(
|
|
353
|
+
f"{why}, and git's exclude file could not be written ({exc.strerror or exc}): add {lines} yourself"
|
|
354
|
+
)
|
|
355
|
+
return
|
|
356
|
+
notes.append(f"{why}, so kea put {lines} in {exclude}, which git reads on this machine only")
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def last_backup(folder: Path) -> Path | None:
|
|
360
|
+
"""
|
|
361
|
+
Find the newest backup ``kea init --force`` made in a brain.
|
|
362
|
+
|
|
363
|
+
:param folder: The brain folder
|
|
364
|
+
:returns: The backup folder, or None
|
|
365
|
+
"""
|
|
366
|
+
found = sorted((folder / INDEX_DIR).glob("backup-*"), key=lambda p: p.stat().st_mtime)
|
|
367
|
+
return found[-1] if found else None
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _render(text: str, values: dict[str, str], base: date) -> str:
|
|
371
|
+
def swap(m: re.Match[str]) -> str:
|
|
372
|
+
key = m[1]
|
|
373
|
+
if key.startswith("d:"):
|
|
374
|
+
return (base + timedelta(days=int(key[2:]))).isoformat()
|
|
375
|
+
return values.get(key, m[0])
|
|
376
|
+
|
|
377
|
+
return re.sub(r"\{\{\s*(name|owner|today|year|d:[+-]\d+)\s*\}\}", swap, text)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _walk(folder: Traversable, prefix: str = "") -> list[tuple[str, Traversable]]:
|
|
381
|
+
out = []
|
|
382
|
+
for item in sorted(folder.iterdir(), key=lambda t: t.name):
|
|
383
|
+
rel = f"{prefix}{item.name}"
|
|
384
|
+
if item.is_dir():
|
|
385
|
+
out += _walk(item, rel + "/")
|
|
386
|
+
elif not item.name.endswith((".pyc", ".pyo")):
|
|
387
|
+
out.append((rel, item))
|
|
388
|
+
return out
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def init(
|
|
392
|
+
folder: Path,
|
|
393
|
+
name: str | None = None,
|
|
394
|
+
owner: str | None = None,
|
|
395
|
+
example: bool = False,
|
|
396
|
+
force: bool = False,
|
|
397
|
+
git: bool = True,
|
|
398
|
+
add: bool = False,
|
|
399
|
+
notes: list[str] | None = None,
|
|
400
|
+
) -> list[str]:
|
|
401
|
+
"""
|
|
402
|
+
Make a new brain from the bundled templates, or put back the files an existing brain is missing.
|
|
403
|
+
|
|
404
|
+
On a folder that is already a brain, only missing files are written. ``force`` overwrites the rest too, after
|
|
405
|
+
copying each one to ``.kea/backup-<time>/``. A folder that holds other files becomes a brain only with ``add``,
|
|
406
|
+
which writes kea's files beside the ones already there and replaces none, or with ``force``. An existing
|
|
407
|
+
``.gitignore`` is never replaced: kea's lines are added to it. kea never writes through a link or into a linked
|
|
408
|
+
folder: ``force`` replaces a linked file with a regular one, after keeping the link in the backup.
|
|
409
|
+
The example only goes into a new folder. ``git init`` runs only for a new or empty folder.
|
|
410
|
+
|
|
411
|
+
:param folder: Where to make it; created when missing
|
|
412
|
+
:param name: The brain's name; the folder's name when omitted
|
|
413
|
+
:param owner: Whose brain it is
|
|
414
|
+
:param example: Fill it with the example brain, a made up coffee roastery, to explore
|
|
415
|
+
:param force: Overwrite files that already exist, keeping a backup of each
|
|
416
|
+
:param git: Run ``git init`` when the folder is new or empty
|
|
417
|
+
:param add: Turn a folder that holds other files into a brain, keeping every file already there
|
|
418
|
+
:param notes: Collects what the person should know, such as a .gitignore kea could not change
|
|
419
|
+
:returns: The files written, relative to the folder
|
|
420
|
+
:raises BrainError: When the folder is the home folder or the root, holds other files without add or force, add
|
|
421
|
+
and force come together, or the example is asked for in a folder that is not empty
|
|
422
|
+
"""
|
|
423
|
+
folder = folder.expanduser().resolve()
|
|
424
|
+
if folder == Path.home().resolve() or folder == Path(folder.anchor):
|
|
425
|
+
raise BrainError(f"make the brain in a folder of its own, such as {Path.home() / 'brain'}, not in {folder}")
|
|
426
|
+
fresh = not folder.exists() or not any(folder.iterdir())
|
|
427
|
+
existing = (folder / MARKER).is_file()
|
|
428
|
+
if example and not fresh:
|
|
429
|
+
raise BrainError(f"{folder} is not empty; the example goes in a new folder: kea init /tmp/demo --example")
|
|
430
|
+
if add and force:
|
|
431
|
+
raise BrainError(
|
|
432
|
+
"choose one: --add keeps every file already there, --force replaces kea's files after a backup"
|
|
433
|
+
)
|
|
434
|
+
if not fresh and not existing and not (force or add):
|
|
435
|
+
raise BrainError(
|
|
436
|
+
f"{folder} holds other files and is not a brain: make the brain in a new folder, such as "
|
|
437
|
+
f"{folder / 'brain'}, or pass --add to put kea's files beside yours (none of yours is replaced)"
|
|
438
|
+
)
|
|
439
|
+
base = today()
|
|
440
|
+
values = {
|
|
441
|
+
"name": name or ("Tidewater Coffee" if example else folder.name.replace("-", " ").replace("_", " ").title()),
|
|
442
|
+
"owner": owner or ("Mara" if example else "me"),
|
|
443
|
+
"today": base.isoformat(),
|
|
444
|
+
"year": str(base.year),
|
|
445
|
+
}
|
|
446
|
+
data = resources.files("kea") / "data"
|
|
447
|
+
layers = [data / "templates"] + ([data / "example"] if example else [])
|
|
448
|
+
planned: dict[str, Traversable] = {}
|
|
449
|
+
for layer in layers:
|
|
450
|
+
for rel, item in _walk(layer):
|
|
451
|
+
parts = rel.split("/")
|
|
452
|
+
parts[-1] = DOTFILES.get(parts[-1], parts[-1])
|
|
453
|
+
planned["/".join(parts)] = item
|
|
454
|
+
written: list[str] = []
|
|
455
|
+
(folder / INDEX_DIR).mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
456
|
+
(folder / INDEX_DIR).chmod(0o700)
|
|
457
|
+
stamp = folder / INDEX_DIR / f"backup-{now():%Y%m%d-%H%M%S-%f}"
|
|
458
|
+
backup, n = stamp, 1
|
|
459
|
+
while backup.exists():
|
|
460
|
+
backup, n = stamp.with_name(f"{stamp.name}-{n}"), n + 1
|
|
461
|
+
notes = notes if notes is not None else []
|
|
462
|
+
left_out: dict[str, list[str]] = {}
|
|
463
|
+
for rel, item in planned.items():
|
|
464
|
+
target = folder / rel
|
|
465
|
+
if not target.parent.resolve().is_relative_to(folder):
|
|
466
|
+
# A linked folder leads out of the brain, and kea init never writes outside it: say which files it left.
|
|
467
|
+
parts = rel.split("/")
|
|
468
|
+
link = next(
|
|
469
|
+
("/".join(parts[:i]) for i in range(1, len(parts)) if (folder / "/".join(parts[:i])).is_symlink()),
|
|
470
|
+
parts[0],
|
|
471
|
+
)
|
|
472
|
+
if force or not (target.exists() or target.is_symlink()):
|
|
473
|
+
left_out.setdefault(link, []).append(rel)
|
|
474
|
+
continue
|
|
475
|
+
text = _render(item.read_text(encoding="utf-8"), values, base)
|
|
476
|
+
if rel == ".gitignore" and (target.exists() or target.is_symlink()):
|
|
477
|
+
if not existing and (add or force):
|
|
478
|
+
_merge_gitignore(target, text, written, notes)
|
|
479
|
+
continue
|
|
480
|
+
linked = target.is_symlink()
|
|
481
|
+
if target.exists() or linked:
|
|
482
|
+
if not force:
|
|
483
|
+
if linked and not target.exists():
|
|
484
|
+
notes.append(
|
|
485
|
+
f"{rel} is a link to {os.readlink(target)}, which is missing, and kea init never writes "
|
|
486
|
+
f"through a link: restore that file, or remove the link and run kea init again"
|
|
487
|
+
)
|
|
488
|
+
continue
|
|
489
|
+
saved = backup / rel
|
|
490
|
+
saved.parent.mkdir(parents=True, exist_ok=True)
|
|
491
|
+
shutil.copy2(target, saved, follow_symlinks=False)
|
|
492
|
+
if linked:
|
|
493
|
+
target.unlink() # replace the link itself, never the file it points to
|
|
494
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
495
|
+
target.write_text(text, encoding="utf-8")
|
|
496
|
+
written.append(rel)
|
|
497
|
+
for link, rels in left_out.items():
|
|
498
|
+
notes.append(
|
|
499
|
+
f"{link}/ is a link to a folder outside the brain, so kea did not write {', '.join(rels)}: copy them "
|
|
500
|
+
f"there yourself, or make {link}/ a regular folder"
|
|
501
|
+
)
|
|
502
|
+
(folder / INDEX_DIR).mkdir(mode=0o700, exist_ok=True)
|
|
503
|
+
git_bin = shutil.which("git")
|
|
504
|
+
if git and fresh and git_bin and not (folder / ".git").exists():
|
|
505
|
+
subprocess.run([git_bin, "init", "-q", str(folder)], check=False) # nosec B603: git init, fixed arguments
|
|
506
|
+
return sorted(written)
|