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.
Files changed (64) hide show
  1. kea/__init__.py +8 -0
  2. kea/__main__.py +3 -0
  3. kea/_version.py +1 -0
  4. kea/brain.py +506 -0
  5. kea/cli.py +1251 -0
  6. kea/council.py +630 -0
  7. kea/data/MANUAL.md +366 -0
  8. kea/data/example/DECISIONS.md +36 -0
  9. kea/data/example/IDEAS.md +16 -0
  10. kea/data/example/METRICS.md +13 -0
  11. kea/data/example/PREDICTIONS.md +68 -0
  12. kea/data/example/ROADMAP.md +27 -0
  13. kea/data/example/STATE.md +42 -0
  14. kea/data/example/frameworks/INDEX.md +7 -0
  15. kea/data/example/frameworks/constraints.md +94 -0
  16. kea/data/example/journal/wholesale-samples.md +11 -0
  17. kea/data/example/plans/second-cafe/00-proposal.md +64 -0
  18. kea/data/example/plans/second-cafe/01-review-questions.md +9 -0
  19. kea/data/example/plans/second-cafe/reviews/example_chair_your-agent.md +25 -0
  20. kea/data/example/plans/second-cafe/reviews/example_contrarian_gpt-oss-120b.md +26 -0
  21. kea/data/example/plans/second-cafe/reviews/example_operator_mistral-small-latest.md +26 -0
  22. kea/data/kea.1 +421 -0
  23. kea/data/skills/kea/SKILL.md +75 -0
  24. kea/data/skills/kea-council/SKILL.md +47 -0
  25. kea/data/skills/kea-frameworks/SKILL.md +32 -0
  26. kea/data/templates/AGENTS.md +63 -0
  27. kea/data/templates/CLAUDE.md +1 -0
  28. kea/data/templates/DECISIONS.md +9 -0
  29. kea/data/templates/IDEAS.md +8 -0
  30. kea/data/templates/LEARNING.md +17 -0
  31. kea/data/templates/METRICS.md +8 -0
  32. kea/data/templates/PREDICTIONS.md +11 -0
  33. kea/data/templates/README.md +21 -0
  34. kea/data/templates/ROADMAP.md +28 -0
  35. kea/data/templates/SOURCES.md +22 -0
  36. kea/data/templates/STATE.md +23 -0
  37. kea/data/templates/VOICE.md +16 -0
  38. kea/data/templates/council/PROTOCOL.md +69 -0
  39. kea/data/templates/council/roster.toml +80 -0
  40. kea/data/templates/frameworks/INDEX.md +16 -0
  41. kea/data/templates/frameworks/_TEMPLATE.md +80 -0
  42. kea/data/templates/gitignore +6 -0
  43. kea/data/templates/journal/README.md +4 -0
  44. kea/data/templates/kea.toml +27 -0
  45. kea/data/templates/plans/_template/00-proposal.md +79 -0
  46. kea/data/templates/plans/_template/01-review-questions.md +36 -0
  47. kea/data/templates/plans/_template/20-production-plan.md +44 -0
  48. kea/data/templates/plans/_template/30-execution-log.md +23 -0
  49. kea/digest.py +210 -0
  50. kea/ingest.py +1014 -0
  51. kea/ledger.py +913 -0
  52. kea/markdown.py +431 -0
  53. kea/mcp.py +756 -0
  54. kea/providers.py +333 -0
  55. kea/py.typed +0 -0
  56. kea/redact.py +517 -0
  57. kea/skills.py +92 -0
  58. kea/store.py +499 -0
  59. kea_brain-1.0.1.dist-info/METADATA +307 -0
  60. kea_brain-1.0.1.dist-info/RECORD +64 -0
  61. kea_brain-1.0.1.dist-info/WHEEL +5 -0
  62. kea_brain-1.0.1.dist-info/entry_points.txt +2 -0
  63. kea_brain-1.0.1.dist-info/licenses/LICENSE +21 -0
  64. kea_brain-1.0.1.dist-info/top_level.txt +1 -0
kea/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """kea: a second brain that checks its facts."""
2
+
3
+ try:
4
+ from kea._version import __version__
5
+ except ImportError: # a source tree that was never installed
6
+ __version__ = "0.0.0"
7
+
8
+ __all__ = ["__version__"]
kea/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from kea.cli import main
2
+
3
+ raise SystemExit(main())
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)