secondbrain-py 0.2.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 (273) hide show
  1. brain/__init__.py +0 -0
  2. brain/__main__.py +18 -0
  3. brain/_capture_command.py +445 -0
  4. brain/_compose.py +52 -0
  5. brain/activity.py +206 -0
  6. brain/ask.py +631 -0
  7. brain/audio.py +591 -0
  8. brain/backfill/__init__.py +12 -0
  9. brain/backfill/search_extras.py +141 -0
  10. brain/backfill/source_rows.py +101 -0
  11. brain/bin/__init__.py +1 -0
  12. brain/bin/_launcher.py +107 -0
  13. brain/bin/down.py +8 -0
  14. brain/bin/launchd.py +268 -0
  15. brain/bin/monitor.py +570 -0
  16. brain/bin/rebuild.py +8 -0
  17. brain/bin/status.py +8 -0
  18. brain/bin/up.py +8 -0
  19. brain/brief.py +272 -0
  20. brain/capture.py +49 -0
  21. brain/chat.py +293 -0
  22. brain/cli.py +9760 -0
  23. brain/cli_claude.py +81 -0
  24. brain/cli_connect.py +285 -0
  25. brain/cli_demo.py +266 -0
  26. brain/config.py +1949 -0
  27. brain/connect.py +925 -0
  28. brain/db.py +540 -0
  29. brain/demo/__init__.py +452 -0
  30. brain/demo/corpus/manifest.json +403 -0
  31. brain/demo/embedder.py +74 -0
  32. brain/durations.py +84 -0
  33. brain/edit_session.py +156 -0
  34. brain/editor.py +67 -0
  35. brain/elicit/__init__.py +16 -0
  36. brain/elicit/detectors.py +250 -0
  37. brain/elicit/drafter.py +70 -0
  38. brain/elicit/queue.py +220 -0
  39. brain/elicit/schema.py +48 -0
  40. brain/elicit/session.py +445 -0
  41. brain/embedding_targets.py +54 -0
  42. brain/embeddings.py +424 -0
  43. brain/enrichment.py +808 -0
  44. brain/errors.py +357 -0
  45. brain/eval/__init__.py +129 -0
  46. brain/eval/answer_eval.py +281 -0
  47. brain/eval/baseline.py +265 -0
  48. brain/eval/concept_extraction.py +378 -0
  49. brain/eval/corpus.py +152 -0
  50. brain/eval/errors.py +19 -0
  51. brain/eval/graph_baseline.py +226 -0
  52. brain/eval/graph_retrieval.py +202 -0
  53. brain/eval/graph_runner.py +319 -0
  54. brain/eval/metrics.py +101 -0
  55. brain/eval/runner.py +223 -0
  56. brain/format.py +783 -0
  57. brain/gaps.py +390 -0
  58. brain/graph_rag/__init__.py +94 -0
  59. brain/graph_rag/_retrieval_common.py +113 -0
  60. brain/graph_rag/aggregates.py +303 -0
  61. brain/graph_rag/aliases/__init__.py +583 -0
  62. brain/graph_rag/backends/__init__.py +10 -0
  63. brain/graph_rag/backends/_age_helpers.py +473 -0
  64. brain/graph_rag/backends/age.py +782 -0
  65. brain/graph_rag/backends/base.py +272 -0
  66. brain/graph_rag/build.py +344 -0
  67. brain/graph_rag/communities.py +644 -0
  68. brain/graph_rag/communities_summary.py +437 -0
  69. brain/graph_rag/concepts.py +202 -0
  70. brain/graph_rag/cooccur.py +193 -0
  71. brain/graph_rag/cross_type.py +312 -0
  72. brain/graph_rag/extract.py +885 -0
  73. brain/graph_rag/fuse.py +371 -0
  74. brain/graph_rag/global_.py +412 -0
  75. brain/graph_rag/grouping.py +372 -0
  76. brain/graph_rag/person_resolver.py +167 -0
  77. brain/graph_rag/reconcile.py +792 -0
  78. brain/graph_rag/relational.py +353 -0
  79. brain/graph_rag/retrieve.py +526 -0
  80. brain/graph_rag/router.py +288 -0
  81. brain/graph_rag/schema.py +320 -0
  82. brain/graph_rag/sync.py +237 -0
  83. brain/graph_rag/tenancy.py +43 -0
  84. brain/graph_rag/themes.py +501 -0
  85. brain/graph_rag/weighting.py +202 -0
  86. brain/ingest/__init__.py +1926 -0
  87. brain/ingest/chunker.py +249 -0
  88. brain/ingest/docx.py +40 -0
  89. brain/ingest/gmail.py +621 -0
  90. brain/ingest/markdown.py +37 -0
  91. brain/ingest/pdf.py +61 -0
  92. brain/ingest/stdin.py +22 -0
  93. brain/ingest/sub_tokens.py +91 -0
  94. brain/ingest/text.py +16 -0
  95. brain/interactions.py +205 -0
  96. brain/maintenance.py +355 -0
  97. brain/mcp_server.py +3405 -0
  98. brain/migrations/001_init.sql +43 -0
  99. brain/migrations/002_qwen3_embedding.sql +17 -0
  100. brain/migrations/003_vault_model.sql +41 -0
  101. brain/migrations/004_relax_content_hash_uniqueness.sql +18 -0
  102. brain/migrations/005_derived_links.sql +67 -0
  103. brain/migrations/006_dedup_file_by_source_path.sql +25 -0
  104. brain/migrations/007_email_thread_and_draft.sql +15 -0
  105. brain/migrations/008_gmail_thread_unique.sql +11 -0
  106. brain/migrations/009_chunks_weighted_tsv.sql +28 -0
  107. brain/migrations/010_interactions.sql +30 -0
  108. brain/migrations/011_documents_summary.sql +23 -0
  109. brain/migrations/012_graphrag.sql +171 -0
  110. brain/migrations/013_graphrag_communities.sql +125 -0
  111. brain/migrations/014_graphrag_community_summary_hash.sql +33 -0
  112. brain/migrations/015_interactions_graph_targets.sql +89 -0
  113. brain/migrations/016_index_hygiene.sql +61 -0
  114. brain/migrations/017_elicit.sql +30 -0
  115. brain/migrations/018_review_gap_signal_kinds.sql +40 -0
  116. brain/migrations/019_search_queries.sql +35 -0
  117. brain/migrations/020_link_suggestions.sql +40 -0
  118. brain/migrations/021_timeline_doc_date.sql +34 -0
  119. brain/migrations/022_link_suggestions_undirected.sql +84 -0
  120. brain/migrations/023_search_queries_fts_count.sql +28 -0
  121. brain/quartz_overrides/__init__.py +8 -0
  122. brain/quartz_overrides/quartz/bootstrap-cli.mjs +65 -0
  123. brain/quartz_overrides/quartz/build.ts +568 -0
  124. brain/quartz_overrides/quartz/cli/args.js +152 -0
  125. brain/quartz_overrides/quartz/cli/build_partial_handler.js +544 -0
  126. brain/quartz_overrides/quartz/cli/handlers.js +636 -0
  127. brain/quartz_overrides/quartz/components/CommandPalette.tsx +172 -0
  128. brain/quartz_overrides/quartz/components/Explorer.tsx +198 -0
  129. brain/quartz_overrides/quartz/components/Footer.tsx +27 -0
  130. brain/quartz_overrides/quartz/components/Graph.tsx +468 -0
  131. brain/quartz_overrides/quartz/components/PageTitle.tsx +72 -0
  132. brain/quartz_overrides/quartz/components/RelatedDocs.tsx +38 -0
  133. brain/quartz_overrides/quartz/components/Search.tsx +161 -0
  134. brain/quartz_overrides/quartz/components/SummaryLede.tsx +72 -0
  135. brain/quartz_overrides/quartz/components/index.ts +92 -0
  136. brain/quartz_overrides/quartz/components/pages/TagContent.tsx +272 -0
  137. brain/quartz_overrides/quartz/components/scripts/commandPalette.inline.ts +665 -0
  138. brain/quartz_overrides/quartz/components/scripts/explorer.inline.ts +768 -0
  139. brain/quartz_overrides/quartz/components/scripts/graph.inline.ts +2302 -0
  140. brain/quartz_overrides/quartz/components/scripts/relatedDocs.inline.ts +163 -0
  141. brain/quartz_overrides/quartz/components/scripts/search.inline.ts +1011 -0
  142. brain/quartz_overrides/quartz/plugins/emitters/contentIndex.ts +546 -0
  143. brain/quartz_overrides/quartz/plugins/transformers/codeCopy.ts +94 -0
  144. brain/quartz_overrides/quartz/plugins/transformers/derivedFenceMark.ts +302 -0
  145. brain/quartz_overrides/quartz/plugins/transformers/emailThread.ts +148 -0
  146. brain/quartz_overrides/quartz/plugins/transformers/emptyDoorFilter.ts +213 -0
  147. brain/quartz_overrides/quartz/plugins/transformers/index.ts +114 -0
  148. brain/quartz_overrides/quartz/plugins/transformers/linkKindMark.ts +205 -0
  149. brain/quartz_overrides/quartz/plugins/transformers/linkSourceTag.ts +104 -0
  150. brain/quartz_overrides/quartz/plugins/transformers/relativeDate.ts +100 -0
  151. brain/quartz_overrides/quartz/plugins/transformers/reloadSignal.ts +131 -0
  152. brain/quartz_overrides/quartz/processors/parse.ts +371 -0
  153. brain/quartz_overrides/quartz/processors/parser_cache.ts +78 -0
  154. brain/quartz_overrides/quartz/static/brain-logo-dark.png +0 -0
  155. brain/quartz_overrides/quartz/static/brain-logo-light.png +0 -0
  156. brain/quartz_overrides/quartz/static/codeCopy.js +196 -0
  157. brain/quartz_overrides/quartz/static/emailThread.js +334 -0
  158. brain/quartz_overrides/quartz/static/favicon.ico +0 -0
  159. brain/quartz_overrides/quartz/static/icon.png +0 -0
  160. brain/quartz_overrides/quartz/static/linkSourceTag.js +104 -0
  161. brain/quartz_overrides/quartz/static/relativeDate.js +142 -0
  162. brain/quartz_overrides/quartz/static/reload.js +168 -0
  163. brain/quartz_overrides/quartz/styles/brain/_article.scss +252 -0
  164. brain/quartz_overrides/quartz/styles/brain/_atmosphere.scss +113 -0
  165. brain/quartz_overrides/quartz/styles/brain/_callouts.scss +180 -0
  166. brain/quartz_overrides/quartz/styles/brain/_cmdk.scss +7 -0
  167. brain/quartz_overrides/quartz/styles/brain/_code.scss +208 -0
  168. brain/quartz_overrides/quartz/styles/brain/_command_palette.scss +369 -0
  169. brain/quartz_overrides/quartz/styles/brain/_email_thread.scss +228 -0
  170. brain/quartz_overrides/quartz/styles/brain/_explorer.scss +142 -0
  171. brain/quartz_overrides/quartz/styles/brain/_home.scss +182 -0
  172. brain/quartz_overrides/quartz/styles/brain/_links.scss +322 -0
  173. brain/quartz_overrides/quartz/styles/brain/_marginalia.scss +117 -0
  174. brain/quartz_overrides/quartz/styles/brain/_motion.scss +175 -0
  175. brain/quartz_overrides/quartz/styles/brain/_people_hub.scss +100 -0
  176. brain/quartz_overrides/quartz/styles/brain/_related_docs.scss +137 -0
  177. brain/quartz_overrides/quartz/styles/brain/_search.scss +252 -0
  178. brain/quartz_overrides/quartz/styles/brain/_sidebar.scss +468 -0
  179. brain/quartz_overrides/quartz/styles/brain/_summary_lede.scss +56 -0
  180. brain/quartz_overrides/quartz/styles/brain/_surface.scss +43 -0
  181. brain/quartz_overrides/quartz/styles/brain/_tag_content.scss +118 -0
  182. brain/quartz_overrides/quartz/styles/brain/_tokens.scss +197 -0
  183. brain/quartz_overrides/quartz/styles/brain/_typography.scss +92 -0
  184. brain/quartz_overrides/quartz/styles/custom.scss +89 -0
  185. brain/quartz_overrides/quartz/styles/graph.scss +505 -0
  186. brain/quartz_overrides/quartz/util/ctx.ts +92 -0
  187. brain/quartz_overrides/quartz/util/fastpath_manifest.ts +608 -0
  188. brain/quartz_overrides/quartz/util/path.ts +358 -0
  189. brain/quartz_overrides/quartz/util/sourceIcons.ts +55 -0
  190. brain/quartz_overrides/quartz.config.ts +270 -0
  191. brain/quartz_overrides/quartz.layout.ts +314 -0
  192. brain/queries.py +1188 -0
  193. brain/rank_fusion.py +8 -0
  194. brain/resurface.py +210 -0
  195. brain/review/__init__.py +26 -0
  196. brain/review/emit.py +27 -0
  197. brain/review/queries.py +436 -0
  198. brain/review/render.py +196 -0
  199. brain/review/scans.py +355 -0
  200. brain/review/weekly.py +413 -0
  201. brain/search.py +704 -0
  202. brain/set_similarity.py +15 -0
  203. brain/setup.py +1205 -0
  204. brain/tags.py +56 -0
  205. brain/templates/Caddyfile.j2 +9 -0
  206. brain/templates/__init__.py +1 -0
  207. brain/templates/bin/__init__.py +1 -0
  208. brain/templates/bin/_brain-brief-fg.sh +25 -0
  209. brain/templates/bin/_brain-build-fg.sh +53 -0
  210. brain/templates/bin/_brain-watcher-fg.sh +65 -0
  211. brain/templates/bin/brain-down.sh +89 -0
  212. brain/templates/bin/brain-status.sh +83 -0
  213. brain/templates/bin/brain-up.sh +221 -0
  214. brain/templates/docker/age/Dockerfile +79 -0
  215. brain/templates/docker-compose.stock.yml.j2 +26 -0
  216. brain/templates/docker-compose.yml.j2 +34 -0
  217. brain/templates/env.example +190 -0
  218. brain/templates/launchd/__init__.py +1 -0
  219. brain/templates/launchd/com.brain.brief.plist.j2 +45 -0
  220. brain/templates/launchd/com.brain.build.plist.j2 +46 -0
  221. brain/templates/launchd/com.brain.watcher.plist.j2 +46 -0
  222. brain/templates/skill/SKILL.md +63 -0
  223. brain/templates/skill/__init__.py +1 -0
  224. brain/timeline.py +834 -0
  225. brain/todo.py +124 -0
  226. brain/uninstall.py +185 -0
  227. brain/vault/__init__.py +115 -0
  228. brain/vault/_atomic.py +25 -0
  229. brain/vault/daily_index.py +228 -0
  230. brain/vault/derived_links/__init__.py +50 -0
  231. brain/vault/derived_links/directory.py +683 -0
  232. brain/vault/derived_links/fence.py +408 -0
  233. brain/vault/derived_links/gws.py +64 -0
  234. brain/vault/derived_links/participants.py +143 -0
  235. brain/vault/derived_links/pass_runner.py +362 -0
  236. brain/vault/derived_links/rules.py +137 -0
  237. brain/vault/export.py +683 -0
  238. brain/vault/frontmatter.py +165 -0
  239. brain/vault/graph.py +620 -0
  240. brain/vault/graph_format.py +388 -0
  241. brain/vault/link_rewrite.py +235 -0
  242. brain/vault/links.py +260 -0
  243. brain/vault/note_builder.py +211 -0
  244. brain/vault/paths.py +55 -0
  245. brain/vault/quartz_overlay.py +236 -0
  246. brain/vault/rename.py +591 -0
  247. brain/vault/resolver.py +304 -0
  248. brain/vault/slug.py +127 -0
  249. brain/vault/sync.py +1513 -0
  250. brain/vault/sync_summaries.py +264 -0
  251. brain/vault/templates.py +145 -0
  252. brain/vault/watch.py +1052 -0
  253. brain/wiki/__init__.py +6 -0
  254. brain/wiki/_github_slugger.py +76 -0
  255. brain/wiki/_person_name.py +314 -0
  256. brain/wiki/build_homepage.py +541 -0
  257. brain/wiki/build_partial.py +273 -0
  258. brain/wiki/build_people.py +934 -0
  259. brain/wiki/build_related.py +758 -0
  260. brain/wiki/build_swap.py +585 -0
  261. brain/wiki/build_watcher.py +975 -0
  262. brain/wiki/edit_classifier.py +215 -0
  263. brain/wiki/errors.py +10 -0
  264. brain/wiki/fastpath_manifest.py +475 -0
  265. brain/wiki/fastpath_state.py +174 -0
  266. brain/wiki/install.py +296 -0
  267. brain/wiki/slug.py +111 -0
  268. secondbrain_py-0.2.1.dist-info/METADATA +195 -0
  269. secondbrain_py-0.2.1.dist-info/RECORD +273 -0
  270. secondbrain_py-0.2.1.dist-info/WHEEL +5 -0
  271. secondbrain_py-0.2.1.dist-info/entry_points.txt +11 -0
  272. secondbrain_py-0.2.1.dist-info/licenses/LICENSE +21 -0
  273. secondbrain_py-0.2.1.dist-info/top_level.txt +1 -0
brain/setup.py ADDED
@@ -0,0 +1,1205 @@
1
+ """brain setup — interactive one-command installer for the second-brain runtime."""
2
+ import enum
3
+ import os
4
+ import shutil
5
+ import socket
6
+ import subprocess
7
+ import sys
8
+ import time
9
+ from dataclasses import dataclass
10
+ from importlib.resources import files as resource_files
11
+ from pathlib import Path
12
+
13
+ import typer
14
+
15
+ from ._compose import DEFAULT_COMPOSE_PROJECT, compose_cmd, compose_project_name
16
+ from .bin._launcher import ensure_shim
17
+ from .config import DEFAULT_VAULT_PATH, _brain_home_root
18
+ from .errors import BrainError
19
+ from .vault._atomic import atomic_write_text
20
+ from .wiki import QUARTZ_PINNED_COMMIT, QUARTZ_REPO_URL
21
+
22
+
23
+ class SetupError(BrainError):
24
+ """Raised when `brain setup` cannot proceed."""
25
+
26
+
27
+ @dataclass
28
+ class PreflightResult:
29
+ """Result of a single preflight check."""
30
+
31
+ name: str
32
+ ok: bool
33
+ message: str
34
+ remediation: str | None = None
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class SetupProfile:
39
+ """The dependency matrix for one `brain setup --profile` choice.
40
+
41
+ Every gating decision in :func:`run_setup` derives from this object — no
42
+ scattered string compares on the profile name. Three profiles ship:
43
+
44
+ * **minimal** — Postgres + FTS only. Stock compose, ``BRAIN_EMBEDDER=none``,
45
+ graph off, no Ollama, no wiki/daemons/skill. A user with only Docker +
46
+ Git + Python gets a working brain.
47
+ * **standard** — + Ollama hybrid search. Stock compose,
48
+ ``BRAIN_EMBEDDER=arctic`` (or ``--embedder``), graph off, Ollama preflight
49
+ + pull, soft skill install (only if ``~/.claude`` exists), no wiki/daemons.
50
+ * **full** — today's behaviour: the AGE compose + Dockerfile, graph on,
51
+ Ollama pull, and the interactive wiki / daemons / skill offers.
52
+ """
53
+
54
+ name: str
55
+ needs_ollama: bool
56
+ wants_graph: bool
57
+ compose_template: str
58
+ offer_wiki: bool
59
+ offer_daemons: bool
60
+ offer_skill: bool
61
+ soft_skill: bool
62
+ env_embedder_default: str
63
+ env_graph_enabled: bool
64
+
65
+
66
+ class ProfileName(enum.StrEnum):
67
+ """Valid ``brain setup --profile`` choices.
68
+
69
+ Used directly as the Typer option type so Typer renders it as a
70
+ ``click.Choice`` — earlier validation (exit 2 on a bad value), shell
71
+ completion, and a richer ``--help`` — with :func:`resolve_profile`'s
72
+ ``SetupError`` as the backstop for direct ``run_setup`` callers.
73
+ """
74
+
75
+ minimal = "minimal"
76
+ standard = "standard"
77
+ full = "full"
78
+
79
+
80
+ _STOCK_COMPOSE = "docker-compose.stock.yml.j2"
81
+ _AGE_COMPOSE = "docker-compose.yml.j2"
82
+
83
+ _PROFILES: dict[str, SetupProfile] = {
84
+ "minimal": SetupProfile(
85
+ name="minimal",
86
+ needs_ollama=False,
87
+ wants_graph=False,
88
+ compose_template=_STOCK_COMPOSE,
89
+ offer_wiki=False,
90
+ offer_daemons=False,
91
+ offer_skill=False,
92
+ soft_skill=False,
93
+ env_embedder_default="none",
94
+ env_graph_enabled=False,
95
+ ),
96
+ "standard": SetupProfile(
97
+ name="standard",
98
+ needs_ollama=True,
99
+ wants_graph=False,
100
+ compose_template=_STOCK_COMPOSE,
101
+ offer_wiki=False,
102
+ offer_daemons=False,
103
+ offer_skill=True,
104
+ soft_skill=True,
105
+ env_embedder_default="arctic",
106
+ env_graph_enabled=False,
107
+ ),
108
+ "full": SetupProfile(
109
+ name="full",
110
+ needs_ollama=True,
111
+ wants_graph=True,
112
+ compose_template=_AGE_COMPOSE,
113
+ offer_wiki=True,
114
+ offer_daemons=True,
115
+ offer_skill=True,
116
+ soft_skill=False,
117
+ env_embedder_default="arctic",
118
+ env_graph_enabled=True,
119
+ ),
120
+ }
121
+
122
+ # Public tuple of valid profile names, in install-weight order (for --help + validation).
123
+ PROFILE_NAMES: tuple[str, ...] = tuple(p.value for p in ProfileName)
124
+
125
+
126
+ def resolve_profile(name: str) -> SetupProfile:
127
+ """Return the :class:`SetupProfile` for *name*, or raise on an unknown choice."""
128
+ try:
129
+ return _PROFILES[name]
130
+ except KeyError as exc:
131
+ valid = ", ".join(PROFILE_NAMES)
132
+ raise SetupError(
133
+ f"unknown profile {name!r} — choose one of: {valid}"
134
+ ) from exc
135
+
136
+
137
+ # Printed when the standard profile auto-detects a MISSING Ollama and falls back
138
+ # to the FTS-only backend (the surprise case — leads with "Ollama not found").
139
+ # Shown at fallback time and reiterated in the final report.
140
+ OLLAMA_FALLBACK_HINT = (
141
+ "Ollama not found — installing FTS-only (BRAIN_EMBEDDER=none). To enable "
142
+ "semantic search later: install Ollama, set BRAIN_EMBEDDER=arctic in .env, "
143
+ "then run 'brain init' and 'brain reembed'."
144
+ )
145
+
146
+ # Printed in the final report when the brain is FTS-only BY CHOICE (minimal
147
+ # profile, or an explicit --embedder none) — a truthful variant that does NOT
148
+ # claim Ollama was missing.
149
+ FTS_ONLY_NOTE = (
150
+ "FTS-only brain (BRAIN_EMBEDDER=none). To enable semantic search later: "
151
+ "install Ollama, set BRAIN_EMBEDDER=arctic in .env, then run 'brain init' "
152
+ "and 'brain reembed'."
153
+ )
154
+
155
+
156
+ def resolve_effective_embedder(
157
+ profile: SetupProfile, embedder_choice: str | None
158
+ ) -> tuple[str, bool]:
159
+ """Resolve the embedder to use, applying the standard-profile FTS fallback.
160
+
161
+ Returns ``(embedder, fell_back)``. An explicit ``--embedder`` always wins.
162
+ Otherwise the profile default applies — except the **standard** profile
163
+ falls back to the FTS-only ``none`` backend when the ``ollama`` binary is
164
+ absent. Resolved BEFORE preflight so a standard run doesn't hard-fail on the
165
+ very binary it was about to fall back from. Pure except for the
166
+ ``shutil.which`` probe — no printing — so the caller controls when the hint
167
+ is shown (once before preflight, once in the final report).
168
+ """
169
+ if embedder_choice is not None:
170
+ return embedder_choice, False
171
+ if profile.name == "standard" and shutil.which("ollama") is None:
172
+ return "none", True
173
+ return profile.env_embedder_default, False
174
+
175
+
176
+ # Shim names installed to $BRAIN_HOME/.shims/ (sans .sh suffix).
177
+ # Each must have a corresponding <name>.sh in brain.templates.bin/.
178
+ _SHIM_NAMES: tuple[str, ...] = (
179
+ "brain-up",
180
+ "brain-down",
181
+ "brain-status",
182
+ "_brain-watcher-fg",
183
+ "_brain-build-fg",
184
+ )
185
+
186
+
187
+ # ---------------------------------------------------------------------------
188
+ # Individual preflight checks
189
+ # ---------------------------------------------------------------------------
190
+
191
+
192
+ def _check_python_version() -> PreflightResult:
193
+ """Check 1: Python >= 3.11."""
194
+ v = sys.version_info
195
+ ok = v >= (3, 11)
196
+ if ok:
197
+ return PreflightResult(
198
+ name="python>=3.11",
199
+ ok=True,
200
+ message=f"Python {v.major}.{v.minor}.{v.micro}",
201
+ )
202
+ return PreflightResult(
203
+ name="python>=3.11",
204
+ ok=False,
205
+ message=f"Python 3.11+ required (got {v.major}.{v.minor})",
206
+ remediation="Install Python 3.11+ via pyenv, brew, or the official installer at https://python.org",
207
+ )
208
+
209
+
210
+ def _check_docker(*, dry_run: bool = False) -> PreflightResult:
211
+ """Check 2: docker CLI present and daemon running."""
212
+ if shutil.which("docker") is None:
213
+ return PreflightResult(
214
+ name="docker",
215
+ ok=False,
216
+ message="docker CLI not found on PATH",
217
+ remediation=(
218
+ "macOS: install Docker Desktop from https://docker.com/get-started "
219
+ "or `brew install --cask docker`\n"
220
+ " Linux: https://docs.docker.com/engine/install/"
221
+ ),
222
+ )
223
+ if dry_run:
224
+ # Skip daemon probe in dry-run — avoid real subprocess calls.
225
+ return PreflightResult(
226
+ name="docker",
227
+ ok=True,
228
+ message="docker found (daemon check skipped in dry-run)",
229
+ )
230
+ try:
231
+ result = subprocess.run(
232
+ ["docker", "info"],
233
+ capture_output=True,
234
+ timeout=10,
235
+ )
236
+ ok = result.returncode == 0
237
+ except (OSError, subprocess.TimeoutExpired):
238
+ ok = False
239
+ if ok:
240
+ return PreflightResult(name="docker", ok=True, message="docker daemon running")
241
+ return PreflightResult(
242
+ name="docker",
243
+ ok=False,
244
+ message="docker daemon not running (docker info failed)",
245
+ remediation="Start Docker Desktop or run `sudo systemctl start docker` (Linux)",
246
+ )
247
+
248
+
249
+ def _check_ollama() -> PreflightResult:
250
+ """Check 3: ollama CLI present.
251
+
252
+ Only invoked when :func:`run_preflight` has already established that the
253
+ resolved embedder needs a local Ollama (:func:`_embedder_uses_ollama`), so
254
+ the SaaS ``voyage`` / FTS-only ``none`` skip lives with that single owner —
255
+ this function no longer second-guesses the gate.
256
+ """
257
+ ok = shutil.which("ollama") is not None
258
+ if ok:
259
+ return PreflightResult(name="ollama", ok=True, message="ollama found")
260
+ return PreflightResult(
261
+ name="ollama",
262
+ ok=False,
263
+ message="ollama CLI not found on PATH",
264
+ remediation=(
265
+ "macOS: brew install ollama && brew services start ollama\n"
266
+ " Linux: https://ollama.com/install"
267
+ ),
268
+ )
269
+
270
+
271
+ def _check_port_free(port: int, check_name: str) -> PreflightResult:
272
+ """Check: port is not already bound (portable socket probe, no lsof)."""
273
+ try:
274
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
275
+ s.bind(("127.0.0.1", port))
276
+ return PreflightResult(name=check_name, ok=True, message=f"port {port} is free")
277
+ except OSError:
278
+ return PreflightResult(
279
+ name=check_name,
280
+ ok=False,
281
+ message=f"port {port} is already in use",
282
+ remediation=(
283
+ f"Stop the process using port {port} or pass a different "
284
+ f"--port / --wiki-port value"
285
+ ),
286
+ )
287
+
288
+
289
+ def _check_caddy() -> PreflightResult:
290
+ """Check 6: caddy installed — NOT auto-installed."""
291
+ ok = shutil.which("caddy") is not None
292
+ if ok:
293
+ return PreflightResult(name="caddy", ok=True, message="caddy found")
294
+ return PreflightResult(
295
+ name="caddy",
296
+ ok=False,
297
+ message="caddy CLI not found on PATH",
298
+ remediation=(
299
+ "macOS: brew install caddy\n"
300
+ " Linux: https://caddyserver.com/docs/install"
301
+ ),
302
+ )
303
+
304
+
305
+ def _check_skills_dir_writable(*, skip: bool) -> PreflightResult:
306
+ """Check 7: ~/.claude/skills/ writable — skipped with --skip-skill."""
307
+ if skip:
308
+ return PreflightResult(
309
+ name="skills-dir",
310
+ ok=True,
311
+ message="~/.claude/skills/ (skipped — --skip-skill)",
312
+ )
313
+ skills_parent = Path.home() / ".claude"
314
+ skills_dir = skills_parent / "skills"
315
+ parent_exists_and_writable = skills_parent.exists() and os.access(skills_parent, os.W_OK)
316
+ dir_ok = skills_dir.exists() or parent_exists_and_writable
317
+ ok = parent_exists_and_writable and dir_ok
318
+ if ok:
319
+ return PreflightResult(name="skills-dir", ok=True, message="~/.claude/skills/ writable")
320
+ return PreflightResult(
321
+ name="skills-dir",
322
+ ok=False,
323
+ message="~/.claude/skills/ not writable or ~/.claude/ missing",
324
+ remediation="mkdir -p ~/.claude/skills && chmod u+w ~/.claude",
325
+ )
326
+
327
+
328
+ def _check_quartz_sha(*, dry_run: bool = False) -> PreflightResult:
329
+ """Check 8: pinned Quartz commit SHA is reachable — skipped in dry-run."""
330
+ if dry_run:
331
+ return PreflightResult(
332
+ name="quartz-sha",
333
+ ok=True,
334
+ message="quartz SHA reachability (skipped in dry-run)",
335
+ )
336
+ try:
337
+ result = subprocess.run(
338
+ ["git", "ls-remote", QUARTZ_REPO_URL, QUARTZ_PINNED_COMMIT],
339
+ capture_output=True,
340
+ text=True,
341
+ timeout=30,
342
+ )
343
+ except (OSError, subprocess.TimeoutExpired) as exc:
344
+ return PreflightResult(
345
+ name="quartz-sha",
346
+ ok=False,
347
+ message=f"git ls-remote error: {exc}",
348
+ remediation=(
349
+ "Ensure git is installed and you have network access to github.com"
350
+ ),
351
+ )
352
+ if result.returncode != 0:
353
+ return PreflightResult(
354
+ name="quartz-sha",
355
+ ok=False,
356
+ message="git ls-remote failed (network error or git not installed)",
357
+ remediation="Check internet connectivity and re-run",
358
+ )
359
+ stdout = result.stdout.strip()
360
+ short = QUARTZ_PINNED_COMMIT[:8]
361
+ ok = bool(stdout) and short in stdout
362
+ if ok:
363
+ return PreflightResult(
364
+ name="quartz-sha",
365
+ ok=True,
366
+ message=f"quartz commit {short} reachable at remote",
367
+ )
368
+ return PreflightResult(
369
+ name="quartz-sha",
370
+ ok=False,
371
+ message=f"quartz commit {short} not found at {QUARTZ_REPO_URL}",
372
+ remediation=(
373
+ "Check network connectivity or update QUARTZ_PINNED_COMMIT in brain.wiki.__init__"
374
+ ),
375
+ )
376
+
377
+
378
+ # ---------------------------------------------------------------------------
379
+ # Preflight orchestration
380
+ # ---------------------------------------------------------------------------
381
+
382
+
383
+ def _embedder_uses_ollama(embedder: str | None) -> bool:
384
+ """True when the (resolved) embedder needs a local Ollama daemon.
385
+
386
+ ``arctic`` and ``qwen3`` embed over Ollama; ``voyage`` is SaaS and ``none``
387
+ is FTS-only. ``None`` defaults to ``arctic`` (the historical default).
388
+ """
389
+ return embedder in (None, "arctic", "qwen3")
390
+
391
+
392
+ def run_preflight(
393
+ pg_port: int,
394
+ wiki_port: int,
395
+ embedder: str | None,
396
+ profile: SetupProfile,
397
+ skip_wiki: bool,
398
+ skip_skill: bool,
399
+ *,
400
+ dry_run: bool = False,
401
+ ) -> list[PreflightResult]:
402
+ """Run the profile's preflight checks; return the result list.
403
+
404
+ Every optional check derives from *profile* + the resolved *embedder*:
405
+
406
+ * ollama — only when the profile needs Ollama AND the resolved embedder
407
+ actually embeds over it (an auto-detect fallback to ``none`` drops it).
408
+ * wiki-port / caddy / skills-dir / quartz-sha — only for the full profile
409
+ (``offer_wiki``), which hard-requires the heavy optional components;
410
+ standard/minimal skip them so a missing caddy/quartz/skills-dir is never
411
+ a preflight failure. Order preserved: Python, docker, ollama, PG port,
412
+ wiki port, caddy, skills dir, quartz SHA.
413
+ """
414
+ results: list[PreflightResult] = []
415
+ results.append(_check_python_version())
416
+ results.append(_check_docker(dry_run=dry_run))
417
+ if profile.needs_ollama and _embedder_uses_ollama(embedder):
418
+ results.append(_check_ollama())
419
+ results.append(_check_port_free(pg_port, "postgres-port"))
420
+ if profile.offer_wiki:
421
+ if not skip_wiki:
422
+ results.append(_check_port_free(wiki_port, "wiki-port"))
423
+ results.append(_check_caddy())
424
+ results.append(_check_skills_dir_writable(skip=skip_skill))
425
+ if not skip_wiki:
426
+ results.append(_check_quartz_sha(dry_run=dry_run))
427
+ return results
428
+
429
+
430
+ def _print_preflight(results: list[PreflightResult]) -> None:
431
+ """Print each preflight result; failures go to stderr with Remediation hint."""
432
+ for r in results:
433
+ if r.ok:
434
+ typer.secho(f" [ok] {r.name}: {r.message}", fg="green")
435
+ else:
436
+ typer.secho(f" [fail] {r.name}: {r.message}", fg="red", err=True)
437
+ if r.remediation:
438
+ typer.secho(
439
+ f" Remediation: {r.remediation}", fg="yellow", err=True
440
+ )
441
+
442
+
443
+ # ---------------------------------------------------------------------------
444
+ # Dry-run action helper
445
+ # ---------------------------------------------------------------------------
446
+
447
+
448
+ def _perform_action(label: str, fn: object, dry_run: bool) -> None:
449
+ """Execute *fn* or, in dry-run mode, print what would happen.
450
+
451
+ Args:
452
+ label: Human-readable description of the action (shown in dry-run).
453
+ fn: Callable that performs the mutation. Only invoked when
454
+ ``dry_run`` is False.
455
+ dry_run: When True, print ``[dry-run] would: <label>`` and return.
456
+ """
457
+ if dry_run:
458
+ typer.echo(f"[dry-run] would: {label}")
459
+ return
460
+ if callable(fn):
461
+ fn()
462
+
463
+
464
+ def materialize_age_dockerfile(brain_home: Path) -> Path:
465
+ """Copy the packaged AGE Dockerfile into ``$BRAIN_HOME/docker/age/Dockerfile``.
466
+
467
+ The rendered ``docker-compose.yml`` builds the custom PG16 + pgvector + AGE
468
+ image from ``$BRAIN_HOME/docker/age``; this materializes the canonical packaged
469
+ Dockerfile (``brain.templates/docker/age/Dockerfile``) there so the build
470
+ context resolves on a fresh install — mirroring how the compose/env templates
471
+ and bin shims are shipped from package data. Idempotent (overwrites in place).
472
+
473
+ Returns the destination path.
474
+ """
475
+ src = resource_files("brain.templates") / "docker" / "age" / "Dockerfile"
476
+ dest = brain_home / "docker" / "age" / "Dockerfile"
477
+ dest.parent.mkdir(parents=True, exist_ok=True)
478
+ atomic_write_text(dest, src.read_text(encoding="utf-8"))
479
+ return dest
480
+
481
+
482
+ def _container_name_for_project(compose_project: str) -> str:
483
+ """Derive the postgres container name for a compose project.
484
+
485
+ The DEFAULT project (``brain``) keeps the exact historical literal
486
+ ``second-brain-postgres`` for backward compatibility — existing users
487
+ re-running ``brain setup`` (a documented upgrade flow) must not have their
488
+ container recreated under a new name. A non-default project (the D5b QA
489
+ isolation seam) derives ``<project>-postgres`` so it can't collide.
490
+ """
491
+ if compose_project == DEFAULT_COMPOSE_PROJECT:
492
+ return "second-brain-postgres"
493
+ return f"{compose_project}-postgres"
494
+
495
+
496
+ def render_compose_from_template(
497
+ template_text: str,
498
+ *,
499
+ brain_home: Path,
500
+ pg_port: int,
501
+ compose_project: str,
502
+ ) -> str:
503
+ """Render a packaged ``docker-compose*.yml.j2`` into a concrete compose body.
504
+
505
+ Substitutes the placeholders every compose template shares
506
+ (``{{ brain_home }}``, ``{{ pg_port }}``, ``{{ compose_project }}``,
507
+ ``{{ container_name }}``). The project name flows into the top-level
508
+ ``name:``; the container name is derived so the default ``brain`` project
509
+ keeps the historical ``second-brain-postgres`` while a non-default
510
+ ``BRAIN_COMPOSE_PROJECT`` gets an isolated, non-colliding name. Pure
511
+ function — unit-testable in isolation.
512
+ """
513
+ return (
514
+ template_text.replace("{{ brain_home }}", str(brain_home))
515
+ .replace("{{ pg_port }}", str(pg_port))
516
+ .replace("{{ container_name }}", _container_name_for_project(compose_project))
517
+ .replace("{{ compose_project }}", compose_project)
518
+ )
519
+
520
+
521
+ def _require_replace(text: str, target: str, replacement: str) -> str:
522
+ """Replace *target* in *text*, raising :class:`SetupError` if it is absent.
523
+
524
+ Fails loud at the point of use (not only in CI) so a drifted template — the
525
+ packaged marker renamed or removed — can never silently render a
526
+ partially-substituted ``.env`` that leaves a placeholder or a stale default
527
+ in the live config.
528
+ """
529
+ if target not in text:
530
+ raise SetupError(
531
+ f".env template missing expected marker {target!r} — refusing to "
532
+ "render a silently-wrong .env (has the packaged env.example drifted?)"
533
+ )
534
+ return text.replace(target, replacement)
535
+
536
+
537
+ def render_env_from_template(
538
+ template_text: str,
539
+ *,
540
+ pg_port: int,
541
+ vault_path: Path | None,
542
+ embedder: str | None = None,
543
+ graph_enabled: bool | None = None,
544
+ ) -> str:
545
+ """Render the packaged ``env.example`` template into a concrete ``.env`` body.
546
+
547
+ Substitutes the chosen Postgres host port into ``DATABASE_URL`` via the same
548
+ ``{{ pg_port }}`` placeholder mechanism ``docker-compose.yml.j2`` uses, so a
549
+ non-default ``--port`` produces a live ``.env`` instead of one pinned to the
550
+ template's default port (the bug this fixes: the compose file honoured
551
+ ``--port`` but the ``.env`` was copied verbatim, leaving a dead
552
+ ``DATABASE_URL`` whenever ``--port`` differed). When ``vault_path`` is given,
553
+ the commented-out ``# BRAIN_VAULT_PATH=`` line is activated with the chosen
554
+ vault.
555
+
556
+ ``embedder`` and ``graph_enabled`` let a profile pin those two settings
557
+ EXPLICITLY: minimal/standard must write ``BRAIN_GRAPH_ENABLED=false`` because
558
+ :class:`~brain.config.Config` defaults the graph ON, and minimal writes
559
+ ``BRAIN_EMBEDDER=none`` for an FTS-only brain. Left as ``None`` (the default)
560
+ the template's own ``BRAIN_EMBEDDER=arctic`` line and the commented
561
+ ``# BRAIN_GRAPH_ENABLED=true`` line are preserved untouched. Pure function —
562
+ no filesystem or environment access — so the substitution is unit-testable
563
+ in isolation.
564
+ """
565
+ text = _require_replace(template_text, "{{ pg_port }}", str(pg_port))
566
+ if embedder is not None:
567
+ text = _require_replace(text, "BRAIN_EMBEDDER=arctic", f"BRAIN_EMBEDDER={embedder}")
568
+ if graph_enabled is not None:
569
+ value = "true" if graph_enabled else "false"
570
+ text = _require_replace(
571
+ text, "# BRAIN_GRAPH_ENABLED=true", f"BRAIN_GRAPH_ENABLED={value}"
572
+ )
573
+ if vault_path is not None:
574
+ text = _require_replace(
575
+ text, "# BRAIN_VAULT_PATH=", f"BRAIN_VAULT_PATH={vault_path}"
576
+ )
577
+ return text
578
+
579
+
580
+ # ---------------------------------------------------------------------------
581
+ # T3.5 — brain init + doctor
582
+ # ---------------------------------------------------------------------------
583
+
584
+
585
+ def _run_brain_init(dry_run: bool) -> None:
586
+ """Run `brain init` to apply migrations + reconcile chunks.embedding dim."""
587
+ if dry_run:
588
+ typer.echo("[dry-run] would: brain init")
589
+ return
590
+ typer.echo("Running brain init …")
591
+ result = subprocess.run(
592
+ [sys.executable, "-m", "brain", "init"],
593
+ check=False,
594
+ )
595
+ if result.returncode != 0:
596
+ raise SetupError("brain init failed — see output above")
597
+
598
+
599
+ def _run_brain_doctor(dry_run: bool) -> None:
600
+ """Run `brain doctor` and surface non-zero exit as a setup failure."""
601
+ if dry_run:
602
+ typer.echo("[dry-run] would: brain doctor")
603
+ return
604
+ typer.echo("Running brain doctor …")
605
+ result = subprocess.run(
606
+ [sys.executable, "-m", "brain", "doctor"],
607
+ check=False,
608
+ )
609
+ if result.returncode != 0:
610
+ raise SetupError(
611
+ "brain doctor reported issues — fix them and re-run `brain setup`"
612
+ )
613
+
614
+
615
+ # ---------------------------------------------------------------------------
616
+ # T3.6 — interactive wiki + skill installs
617
+ # ---------------------------------------------------------------------------
618
+
619
+
620
+ def _maybe_install_wiki(
621
+ skip: bool,
622
+ non_interactive: bool,
623
+ dry_run: bool,
624
+ vault: Path,
625
+ brain_home: Path,
626
+ wiki_port: int,
627
+ ) -> bool:
628
+ """Prompt for and optionally perform the wiki sub-install.
629
+
630
+ Returns:
631
+ True — wiki was installed (or dry-run would install it).
632
+ False — skipped via flag or user declined interactively.
633
+
634
+ The return value is forwarded to ``_maybe_install_launchd`` so the
635
+ watcher supervisors are only registered when there is actually a wiki
636
+ workspace to supervise.
637
+ """
638
+ if skip:
639
+ typer.echo("[skipped] wiki install (--skip-wiki)")
640
+ return False
641
+ if non_interactive:
642
+ do_install = True
643
+ else:
644
+ do_install = typer.confirm(
645
+ "Install the wiki UI? Adds ~150 MB of node_modules.",
646
+ default=True,
647
+ )
648
+ if not do_install:
649
+ typer.echo("[skipped] wiki install (user declined)")
650
+ return False
651
+ if dry_run:
652
+ typer.echo(
653
+ f"[dry-run] would: brain wiki install --vault {vault} --port {wiki_port}"
654
+ )
655
+ return True # dry-run counts as "would have installed"
656
+ from .wiki.install import wiki_install
657
+
658
+ wiki_install(vault=vault, port=wiki_port)
659
+ return True
660
+
661
+
662
+ def _maybe_install_skill(
663
+ skip: bool,
664
+ non_interactive: bool,
665
+ dry_run: bool,
666
+ *,
667
+ soft: bool = False,
668
+ ) -> bool:
669
+ """Prompt for and optionally perform the Claude Code skill sub-install.
670
+
671
+ Returns True if the skill was installed (or dry-run would install it),
672
+ False if skipped via flag or user declined.
673
+
674
+ When ``soft`` is set (the standard profile), a missing ``~/.claude``
675
+ directory is a silent skip rather than an install attempt — the user isn't
676
+ a Claude Code user, and this is never a preflight failure. The full profile
677
+ passes ``soft=False`` and keeps the interactive offer.
678
+ """
679
+ if skip:
680
+ typer.echo("[skipped] Claude Code skill (--skip-skill)")
681
+ return False
682
+ if soft and not dry_run and not (Path.home() / ".claude").exists():
683
+ typer.echo("[skipped] Claude Code skill (~/.claude not present)")
684
+ return False
685
+ if non_interactive:
686
+ do_install = True
687
+ else:
688
+ do_install = typer.confirm(
689
+ "Install the Claude Code skill at ~/.claude/skills/brain/SKILL.md?",
690
+ default=True,
691
+ )
692
+ if not do_install:
693
+ typer.echo("[skipped] Claude Code skill (user declined)")
694
+ return False
695
+ if dry_run:
696
+ typer.echo("[dry-run] would: brain claude install-skill")
697
+ return True
698
+ from .cli_claude import install_skill
699
+
700
+ install_skill()
701
+ return True
702
+
703
+
704
+ # ---------------------------------------------------------------------------
705
+ # T3.7 — launchd install (LAST, after all prereqs pass)
706
+ # ---------------------------------------------------------------------------
707
+
708
+
709
+ def _maybe_install_launchd(
710
+ wiki_installed: bool,
711
+ vault_path: Path,
712
+ brain_home: Path,
713
+ dry_run: bool,
714
+ *,
715
+ non_interactive: bool = False,
716
+ daemons: bool = False,
717
+ ) -> None:
718
+ """Install launchd plists ONLY after every prereq passed — opt-in, default No.
719
+
720
+ Runs LAST so DB + wiki are healthy before supervisors start.
721
+
722
+ Background daemons are OPT-IN (ticket contract):
723
+
724
+ * ``--non-interactive`` NEVER installs daemons — a scripted install must not
725
+ register three KeepAlive LaunchAgents behind the user's back.
726
+ * Interactively the prompt defaults to **No**; pass ``--daemons`` to install
727
+ without being asked.
728
+
729
+ Guarded by ``wiki_installed`` (not the raw ``--skip-wiki`` flag) so that an
730
+ interactive decline also suppresses the launchd registration — there would
731
+ be no Quartz workspace for the watcher to supervise.
732
+
733
+ Passes ``vault_path`` and ``brain_home`` directly to ``install_plists``
734
+ rather than going through ``install_main()``, which would silently use the
735
+ default vault and brain_home resolved from env/config at call time instead
736
+ of the values chosen for this setup run.
737
+ """
738
+ if not wiki_installed:
739
+ typer.echo("[skipped] launchd install (wiki not installed)")
740
+ return
741
+ if sys.platform != "darwin":
742
+ typer.echo("[skipped] launchd install (not macOS)")
743
+ return
744
+ if non_interactive:
745
+ # Never auto-install daemons in a scripted run — opt in later with
746
+ # `brain-install-launchd` or an interactive `brain setup --daemons`.
747
+ typer.echo(
748
+ "[skipped] launchd background daemons "
749
+ "(--non-interactive; opt in with `brain-install-launchd`)"
750
+ )
751
+ return
752
+ if daemons:
753
+ do_install = True
754
+ else:
755
+ do_install = typer.confirm(
756
+ "Install launchd background daemons "
757
+ "(auto-start Postgres + wiki watcher on login)?",
758
+ default=False,
759
+ )
760
+ if not do_install:
761
+ typer.echo("[skipped] launchd background daemons (declined)")
762
+ return
763
+ if dry_run:
764
+ typer.echo("[dry-run] would: brain-install-launchd")
765
+ return
766
+ from .bin.launchd import install_plists
767
+
768
+ launchd_dir = Path(
769
+ os.environ.get("BRAIN_LAUNCHD_DIR") or Path.home() / "Library" / "LaunchAgents"
770
+ )
771
+ launchctl = os.environ.get("BRAIN_LAUNCHCTL") or "launchctl"
772
+ install_plists(brain_home, launchd_dir, launchctl, vault_path=vault_path)
773
+ typer.echo(f" [ok] brain LaunchAgents installed in {launchd_dir}")
774
+
775
+
776
+ # ---------------------------------------------------------------------------
777
+ # T3.8 — final report
778
+ # ---------------------------------------------------------------------------
779
+
780
+
781
+ def _print_final_report(
782
+ brain_home: Path,
783
+ vault: Path,
784
+ wiki_port: int,
785
+ wiki_installed: bool,
786
+ skill_installed: bool,
787
+ effective_embedder: str,
788
+ ollama_fell_back: bool,
789
+ ) -> None:
790
+ """Print the closing banner matching brain-up's visual style.
791
+
792
+ Uses the actual install results (not the raw skip flags) so that an
793
+ interactive decline is reflected correctly in the summary. When the brain
794
+ ended up FTS-only (``BRAIN_EMBEDDER=none``), reiterates how to enable
795
+ semantic search later — the exact "Ollama not found" hint only when it was
796
+ an auto-detect fallback; a truthful "FTS-only brain" note when FTS was
797
+ chosen (minimal profile / explicit ``--embedder none``).
798
+ """
799
+ typer.echo("")
800
+ typer.echo("🧠 brain setup complete:")
801
+ typer.echo(f" brain_home: {brain_home}")
802
+ typer.echo(f" vault: {vault}")
803
+ typer.echo(f" embedder: {effective_embedder}")
804
+ if wiki_installed:
805
+ typer.echo(f" wiki url: http://localhost:{wiki_port}")
806
+ typer.echo(f" start wiki: caddy run --config {brain_home}/Caddyfile")
807
+ if skill_installed:
808
+ typer.echo(" skill: ~/.claude/skills/brain/SKILL.md")
809
+ if effective_embedder == "none":
810
+ typer.echo("")
811
+ typer.echo(f" {OLLAMA_FALLBACK_HINT if ollama_fell_back else FTS_ONLY_NOTE}")
812
+ typer.echo("")
813
+ typer.echo(" next steps:")
814
+ typer.echo(" brain ingest <path> # ingest a file or directory")
815
+ typer.echo(' brain search "..." # query the corpus')
816
+ typer.echo(" brain doctor # re-check health")
817
+
818
+
819
+ # ---------------------------------------------------------------------------
820
+ # Public orchestrator
821
+ # ---------------------------------------------------------------------------
822
+
823
+
824
+ def run_setup(
825
+ *,
826
+ profile: str = "standard",
827
+ non_interactive: bool = False,
828
+ dry_run: bool = False,
829
+ brain_home_override: Path | None = None,
830
+ vault_override: Path | None = None,
831
+ pg_port: int = 55432,
832
+ wiki_port: int = 8080,
833
+ embedder_choice: str | None = None,
834
+ skip_wiki: bool = False,
835
+ skip_skill: bool = False,
836
+ daemons: bool = False,
837
+ reset: bool = False,
838
+ ) -> None:
839
+ """Orchestrate the full setup flow. Public surface for testing.
840
+
841
+ **WARNING — reset is destructive.** When ``reset=True``, this function
842
+ deletes ``$BRAIN_HOME`` and all its contents (Postgres data, logs,
843
+ .env, shims) after explicit typed confirmation. Even in
844
+ ``--non-interactive`` mode the confirmation prompt cannot be bypassed;
845
+ the only valid input is the exact string ``"yes, delete my data"``.
846
+
847
+ """
848
+ # ------------------------------------------------------------------
849
+ # Resolve $BRAIN_HOME and vault path
850
+ # ------------------------------------------------------------------
851
+ if brain_home_override is not None:
852
+ brain_home: Path = brain_home_override
853
+ elif os.environ.get("BRAIN_HOME"):
854
+ brain_home = Path(os.environ["BRAIN_HOME"]).expanduser()
855
+ else:
856
+ brain_home = _brain_home_root()
857
+
858
+ # Vault: explicit override > env var > DEFAULT_VAULT_PATH.
859
+ if vault_override is not None:
860
+ vault_path: Path = vault_override
861
+ elif os.environ.get("BRAIN_VAULT_PATH"):
862
+ vault_path = Path(os.environ["BRAIN_VAULT_PATH"]).expanduser()
863
+ else:
864
+ vault_path = DEFAULT_VAULT_PATH
865
+
866
+ # ------------------------------------------------------------------
867
+ # Resolve the profile + the effective embedder BEFORE preflight so every
868
+ # downstream gate (preflight checks, compose template, AGE materialize, .env
869
+ # contents, ollama pull, optional installs) derives from one object.
870
+ # ------------------------------------------------------------------
871
+ prof = resolve_profile(profile)
872
+ effective_embedder, ollama_fell_back = resolve_effective_embedder(
873
+ prof, embedder_choice
874
+ )
875
+ if ollama_fell_back:
876
+ typer.echo(OLLAMA_FALLBACK_HINT)
877
+
878
+ # Warn loudly when --daemons was asked for but will be ignored, rather than
879
+ # silently dropping the request.
880
+ if daemons and non_interactive:
881
+ typer.secho(
882
+ "--daemons ignored under --non-interactive "
883
+ "(daemons are never auto-installed in a scripted run)",
884
+ fg="yellow",
885
+ err=True,
886
+ )
887
+ elif daemons and not prof.offer_daemons:
888
+ typer.secho(
889
+ f"--daemons ignored (profile {prof.name} has no background daemons; "
890
+ "use --profile full)",
891
+ fg="yellow",
892
+ err=True,
893
+ )
894
+
895
+ # ------------------------------------------------------------------
896
+ # T3.1 — Reset handling (DESTRUCTIVE; typed confirmation required)
897
+ # ------------------------------------------------------------------
898
+ if reset:
899
+ typer.secho(
900
+ f"\nWARNING: --reset will permanently DELETE {brain_home} and all its "
901
+ "contents, including your Postgres data.\n"
902
+ "This action is IRREVERSIBLE.",
903
+ fg="red",
904
+ err=True,
905
+ )
906
+ # No auto-bypass: prompt even when --non-interactive is set.
907
+ confirmation = typer.prompt('Type "yes, delete my data" to confirm')
908
+ if confirmation != "yes, delete my data":
909
+ typer.secho(
910
+ 'Reset aborted — confirmation did not match "yes, delete my data".',
911
+ fg="red",
912
+ err=True,
913
+ )
914
+ raise typer.Exit(code=1)
915
+ if not dry_run:
916
+ if brain_home.exists():
917
+ shutil.rmtree(brain_home)
918
+ typer.echo(f"[reset] deleted {brain_home}")
919
+ else:
920
+ typer.echo(f"[reset] {brain_home} did not exist; nothing to delete")
921
+ else:
922
+ typer.echo(f"[dry-run] would: shutil.rmtree({brain_home})")
923
+
924
+ # ------------------------------------------------------------------
925
+ # T3.2 — Pre-flight checks
926
+ # ------------------------------------------------------------------
927
+ typer.echo("\n── Preflight checks ──────────────────────────────")
928
+ results = run_preflight(
929
+ pg_port=pg_port,
930
+ wiki_port=wiki_port,
931
+ embedder=effective_embedder,
932
+ profile=prof,
933
+ skip_wiki=skip_wiki,
934
+ skip_skill=skip_skill,
935
+ dry_run=dry_run,
936
+ )
937
+ _print_preflight(results)
938
+ failures = [r for r in results if not r.ok]
939
+ if failures:
940
+ typer.secho(
941
+ "\nsetup aborted; fix the items above and re-run",
942
+ fg="red",
943
+ err=True,
944
+ )
945
+ raise typer.Exit(code=1)
946
+ typer.echo("")
947
+
948
+ # Expose BRAIN_HOME (and optionally BRAIN_VAULT_PATH) to downstream
949
+ # subprocess calls (idempotent; dry-run prints without mutating).
950
+ if dry_run:
951
+ typer.echo(f"[dry-run] would: export BRAIN_HOME={brain_home}")
952
+ if vault_path is not None:
953
+ typer.echo(f"[dry-run] would: export BRAIN_VAULT_PATH={vault_path}")
954
+ else:
955
+ os.environ["BRAIN_HOME"] = str(brain_home)
956
+ if vault_path is not None:
957
+ os.environ["BRAIN_VAULT_PATH"] = str(vault_path)
958
+
959
+ # ------------------------------------------------------------------
960
+ # T3.3 — Idempotent state creation
961
+ # ------------------------------------------------------------------
962
+ typer.echo("── State creation ─────────────────────────────────")
963
+
964
+ # 1. mkdir -p $BRAIN_HOME/{data/postgres,logs,bin}
965
+ for subdir in ("data/postgres", "logs", "bin"):
966
+ target_dir = brain_home / subdir
967
+ _perform_action(
968
+ f"mkdir -p {target_dir}",
969
+ lambda d=target_dir: d.mkdir(parents=True, exist_ok=True),
970
+ dry_run,
971
+ )
972
+
973
+ # 2. Install bash shims to $BRAIN_HOME/.shims/<name>
974
+ for shim_name in _SHIM_NAMES:
975
+ _perform_action(
976
+ f"install shim {shim_name} → {brain_home / '.shims' / shim_name}",
977
+ lambda n=shim_name: ensure_shim(n, brain_home),
978
+ dry_run,
979
+ )
980
+
981
+ # 3. Render docker-compose.yml
982
+ compose_dest = brain_home / "docker-compose.yml"
983
+
984
+ def _write_compose_yml() -> None:
985
+ template = resource_files("brain.templates") / prof.compose_template
986
+ text = render_compose_from_template(
987
+ template.read_text(encoding="utf-8"),
988
+ brain_home=brain_home,
989
+ pg_port=pg_port,
990
+ compose_project=compose_project_name(),
991
+ )
992
+ compose_dest.parent.mkdir(parents=True, exist_ok=True)
993
+ atomic_write_text(compose_dest, text)
994
+ typer.echo(f" [ok] wrote {compose_dest} ({prof.compose_template})")
995
+
996
+ _perform_action(f"render {compose_dest}", _write_compose_yml, dry_run)
997
+
998
+ # 3b. Materialize the packaged AGE Dockerfile so the compose build context
999
+ # ($BRAIN_HOME/docker/age) resolves before `docker compose up` runs. Only the
1000
+ # full profile's AGE compose has a build stanza; the stock (minimal/standard)
1001
+ # compose has no build context, so the Dockerfile is not materialized.
1002
+ if prof.wants_graph:
1003
+ dockerfile_dest = brain_home / "docker" / "age" / "Dockerfile"
1004
+ _perform_action(
1005
+ f"materialize AGE Dockerfile → {dockerfile_dest}",
1006
+ lambda: materialize_age_dockerfile(brain_home),
1007
+ dry_run,
1008
+ )
1009
+
1010
+ # 4. Render .env — only if missing; never overwrite.
1011
+ env_dest = brain_home / ".env"
1012
+ if not dry_run and env_dest.exists():
1013
+ typer.echo(
1014
+ f" [skipped] {env_dest} (already exists — profile env defaults NOT "
1015
+ "applied; edit .env or re-run with --reset to change profiles)"
1016
+ )
1017
+ # If --vault was given and BRAIN_VAULT_PATH isn't already in the file,
1018
+ # append it so the running .env reflects the chosen vault location.
1019
+ if vault_path is not None:
1020
+ existing_env = env_dest.read_text(encoding="utf-8")
1021
+ if "BRAIN_VAULT_PATH" not in existing_env:
1022
+ with open(env_dest, "a", encoding="utf-8") as fh:
1023
+ fh.write(f"\nBRAIN_VAULT_PATH={vault_path}\n")
1024
+ typer.echo(f" [ok] BRAIN_VAULT_PATH={vault_path} appended to existing .env")
1025
+ else:
1026
+
1027
+ def _write_env() -> None:
1028
+ template_src = resource_files("brain.templates") / "env.example"
1029
+ # Substitute the chosen Postgres port into DATABASE_URL (same
1030
+ # {{ pg_port }} mechanism as the compose render), activate the
1031
+ # commented-out BRAIN_VAULT_PATH line if --vault was given, and pin
1032
+ # BRAIN_EMBEDDER + BRAIN_GRAPH_ENABLED EXPLICITLY to the profile's
1033
+ # values (Config defaults the graph ON, so minimal/standard must
1034
+ # write it false).
1035
+ env_text = render_env_from_template(
1036
+ template_src.read_text(encoding="utf-8"),
1037
+ pg_port=pg_port,
1038
+ vault_path=vault_path,
1039
+ embedder=effective_embedder,
1040
+ graph_enabled=prof.env_graph_enabled,
1041
+ )
1042
+ env_dest.parent.mkdir(parents=True, exist_ok=True)
1043
+ atomic_write_text(env_dest, env_text)
1044
+ typer.echo(f" [ok] wrote {env_dest}")
1045
+
1046
+ _perform_action(f"render {env_dest} from env.example template", _write_env, dry_run)
1047
+
1048
+ # 5. Voyage API key prompt
1049
+ if effective_embedder == "voyage" and not os.environ.get("VOYAGE_API_KEY"):
1050
+ if non_interactive:
1051
+ typer.secho(
1052
+ "error: --embedder voyage requires VOYAGE_API_KEY to be set "
1053
+ "(export it in the environment before running setup)",
1054
+ fg="red",
1055
+ err=True,
1056
+ )
1057
+ raise typer.Exit(code=1)
1058
+ if not dry_run:
1059
+ api_key = typer.prompt("Voyage API key", hide_input=True)
1060
+ with open(env_dest, "a", encoding="utf-8") as fh:
1061
+ fh.write(f"\nVOYAGE_API_KEY={api_key}\n")
1062
+ typer.echo(" [ok] VOYAGE_API_KEY appended to .env")
1063
+ else:
1064
+ typer.echo("[dry-run] would: prompt for VOYAGE_API_KEY and append to .env")
1065
+
1066
+ # ------------------------------------------------------------------
1067
+ # T3.4 — Service startup
1068
+ # ------------------------------------------------------------------
1069
+ typer.echo("\n── Service startup ────────────────────────────────")
1070
+
1071
+ # 1. docker compose pull (best-effort) + up -d.
1072
+ def _compose_up() -> None:
1073
+ # Best-effort prebuilt-image pull BEFORE `up -d`: activates the GHCR
1074
+ # prebuilt AGE image for full-profile users (once the package is public)
1075
+ # and refreshes the stock pgvector image otherwise. A non-zero pull (the
1076
+ # registry image is private / unavailable / offline) is swallowed with a
1077
+ # log line so `up -d` still resolves via the local image or build stanza.
1078
+ pull = subprocess.run(
1079
+ compose_cmd("pull", "postgres", brain_home=brain_home),
1080
+ check=False,
1081
+ )
1082
+ if pull.returncode != 0:
1083
+ typer.echo(
1084
+ " [info] docker compose pull failed — falling back to the "
1085
+ "local image / build"
1086
+ )
1087
+ subprocess.run(
1088
+ compose_cmd("up", "-d", brain_home=brain_home),
1089
+ check=True,
1090
+ )
1091
+
1092
+ _perform_action("docker compose pull + up -d (Postgres)", _compose_up, dry_run)
1093
+
1094
+ # 2. Wait for Postgres readiness via pg_isready inside the container.
1095
+ def _wait_for_postgres() -> None:
1096
+ pg_ready_cmd = compose_cmd(
1097
+ "exec", "-T", "postgres", "pg_isready", "-U", "brain",
1098
+ brain_home=brain_home,
1099
+ )
1100
+ typer.echo(" Waiting for Postgres", nl=False)
1101
+ deadline = time.monotonic() + 30.0
1102
+ last_tick = time.monotonic()
1103
+ while time.monotonic() < deadline:
1104
+ result = subprocess.run(pg_ready_cmd, capture_output=True)
1105
+ if result.returncode == 0:
1106
+ typer.echo(" ready!")
1107
+ return
1108
+ now = time.monotonic()
1109
+ if now - last_tick >= 5.0:
1110
+ typer.echo(".", nl=False)
1111
+ last_tick = now
1112
+ time.sleep(1)
1113
+ typer.secho(
1114
+ "\n Postgres did not become ready within 30 seconds",
1115
+ fg="red",
1116
+ err=True,
1117
+ )
1118
+ raise SetupError("Postgres startup timed out after 30 seconds")
1119
+
1120
+ _perform_action("wait for Postgres (pg_isready inside container)", _wait_for_postgres, dry_run)
1121
+
1122
+ # 3. ollama pull (arctic or qwen3 only; voyage is SaaS, none is FTS-only).
1123
+ # Uses the profile-resolved effective_embedder — a minimal profile or a
1124
+ # standard run that fell back to FTS-only never pulls a ~1 GB model.
1125
+ if effective_embedder in {"arctic", "qwen3"}:
1126
+ model = (
1127
+ "snowflake-arctic-embed2" if effective_embedder == "arctic" else "qwen3-embedding:8b"
1128
+ )
1129
+
1130
+ def _ollama_pull() -> None:
1131
+ list_result = subprocess.run(
1132
+ ["ollama", "list"], capture_output=True, check=True, text=True
1133
+ )
1134
+ if model in list_result.stdout:
1135
+ typer.echo(f" [skipped] {model} already present in ollama")
1136
+ return
1137
+ typer.echo(f" Pulling {model} (this may take a few minutes)…")
1138
+ subprocess.run(["ollama", "pull", model], check=True)
1139
+
1140
+ _perform_action(f"ollama pull {model}", _ollama_pull, dry_run)
1141
+
1142
+ # ------------------------------------------------------------------
1143
+ # T3.5 — brain init + doctor
1144
+ # ------------------------------------------------------------------
1145
+ typer.echo("\n── Database initialisation ────────────────────────")
1146
+ _run_brain_init(dry_run)
1147
+ _run_brain_doctor(dry_run)
1148
+
1149
+ # ------------------------------------------------------------------
1150
+ # T3.6 — optional sub-installs (wiki + Claude Code skill), gated by profile
1151
+ # ------------------------------------------------------------------
1152
+ typer.echo("\n── Optional components ────────────────────────────")
1153
+ if prof.offer_wiki:
1154
+ wiki_installed = _maybe_install_wiki(
1155
+ skip=skip_wiki,
1156
+ non_interactive=non_interactive,
1157
+ dry_run=dry_run,
1158
+ vault=vault_path,
1159
+ brain_home=brain_home,
1160
+ wiki_port=wiki_port,
1161
+ )
1162
+ else:
1163
+ wiki_installed = False
1164
+ typer.echo(f"[skipped] wiki install (profile {prof.name})")
1165
+
1166
+ if prof.offer_skill:
1167
+ # Standard does a SOFT install (only when ~/.claude exists); full offers
1168
+ # it interactively. minimal never installs the skill.
1169
+ skill_installed = _maybe_install_skill(
1170
+ skip=skip_skill,
1171
+ non_interactive=non_interactive,
1172
+ dry_run=dry_run,
1173
+ soft=prof.soft_skill,
1174
+ )
1175
+ else:
1176
+ skill_installed = False
1177
+ typer.echo(f"[skipped] Claude Code skill (profile {prof.name})")
1178
+
1179
+ # ------------------------------------------------------------------
1180
+ # T3.7 — launchd install LAST (after DB + wiki are healthy); full only
1181
+ # ------------------------------------------------------------------
1182
+ if prof.offer_daemons:
1183
+ _maybe_install_launchd(
1184
+ wiki_installed=wiki_installed,
1185
+ vault_path=vault_path,
1186
+ brain_home=brain_home,
1187
+ dry_run=dry_run,
1188
+ non_interactive=non_interactive,
1189
+ daemons=daemons,
1190
+ )
1191
+ else:
1192
+ typer.echo(f"[skipped] launchd background daemons (profile {prof.name})")
1193
+
1194
+ # ------------------------------------------------------------------
1195
+ # T3.8 — final report
1196
+ # ------------------------------------------------------------------
1197
+ _print_final_report(
1198
+ brain_home=brain_home,
1199
+ vault=vault_path,
1200
+ wiki_port=wiki_port,
1201
+ wiki_installed=wiki_installed,
1202
+ skill_installed=skill_installed,
1203
+ effective_embedder=effective_embedder,
1204
+ ollama_fell_back=ollama_fell_back,
1205
+ )