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.
- brain/__init__.py +0 -0
- brain/__main__.py +18 -0
- brain/_capture_command.py +445 -0
- brain/_compose.py +52 -0
- brain/activity.py +206 -0
- brain/ask.py +631 -0
- brain/audio.py +591 -0
- brain/backfill/__init__.py +12 -0
- brain/backfill/search_extras.py +141 -0
- brain/backfill/source_rows.py +101 -0
- brain/bin/__init__.py +1 -0
- brain/bin/_launcher.py +107 -0
- brain/bin/down.py +8 -0
- brain/bin/launchd.py +268 -0
- brain/bin/monitor.py +570 -0
- brain/bin/rebuild.py +8 -0
- brain/bin/status.py +8 -0
- brain/bin/up.py +8 -0
- brain/brief.py +272 -0
- brain/capture.py +49 -0
- brain/chat.py +293 -0
- brain/cli.py +9760 -0
- brain/cli_claude.py +81 -0
- brain/cli_connect.py +285 -0
- brain/cli_demo.py +266 -0
- brain/config.py +1949 -0
- brain/connect.py +925 -0
- brain/db.py +540 -0
- brain/demo/__init__.py +452 -0
- brain/demo/corpus/manifest.json +403 -0
- brain/demo/embedder.py +74 -0
- brain/durations.py +84 -0
- brain/edit_session.py +156 -0
- brain/editor.py +67 -0
- brain/elicit/__init__.py +16 -0
- brain/elicit/detectors.py +250 -0
- brain/elicit/drafter.py +70 -0
- brain/elicit/queue.py +220 -0
- brain/elicit/schema.py +48 -0
- brain/elicit/session.py +445 -0
- brain/embedding_targets.py +54 -0
- brain/embeddings.py +424 -0
- brain/enrichment.py +808 -0
- brain/errors.py +357 -0
- brain/eval/__init__.py +129 -0
- brain/eval/answer_eval.py +281 -0
- brain/eval/baseline.py +265 -0
- brain/eval/concept_extraction.py +378 -0
- brain/eval/corpus.py +152 -0
- brain/eval/errors.py +19 -0
- brain/eval/graph_baseline.py +226 -0
- brain/eval/graph_retrieval.py +202 -0
- brain/eval/graph_runner.py +319 -0
- brain/eval/metrics.py +101 -0
- brain/eval/runner.py +223 -0
- brain/format.py +783 -0
- brain/gaps.py +390 -0
- brain/graph_rag/__init__.py +94 -0
- brain/graph_rag/_retrieval_common.py +113 -0
- brain/graph_rag/aggregates.py +303 -0
- brain/graph_rag/aliases/__init__.py +583 -0
- brain/graph_rag/backends/__init__.py +10 -0
- brain/graph_rag/backends/_age_helpers.py +473 -0
- brain/graph_rag/backends/age.py +782 -0
- brain/graph_rag/backends/base.py +272 -0
- brain/graph_rag/build.py +344 -0
- brain/graph_rag/communities.py +644 -0
- brain/graph_rag/communities_summary.py +437 -0
- brain/graph_rag/concepts.py +202 -0
- brain/graph_rag/cooccur.py +193 -0
- brain/graph_rag/cross_type.py +312 -0
- brain/graph_rag/extract.py +885 -0
- brain/graph_rag/fuse.py +371 -0
- brain/graph_rag/global_.py +412 -0
- brain/graph_rag/grouping.py +372 -0
- brain/graph_rag/person_resolver.py +167 -0
- brain/graph_rag/reconcile.py +792 -0
- brain/graph_rag/relational.py +353 -0
- brain/graph_rag/retrieve.py +526 -0
- brain/graph_rag/router.py +288 -0
- brain/graph_rag/schema.py +320 -0
- brain/graph_rag/sync.py +237 -0
- brain/graph_rag/tenancy.py +43 -0
- brain/graph_rag/themes.py +501 -0
- brain/graph_rag/weighting.py +202 -0
- brain/ingest/__init__.py +1926 -0
- brain/ingest/chunker.py +249 -0
- brain/ingest/docx.py +40 -0
- brain/ingest/gmail.py +621 -0
- brain/ingest/markdown.py +37 -0
- brain/ingest/pdf.py +61 -0
- brain/ingest/stdin.py +22 -0
- brain/ingest/sub_tokens.py +91 -0
- brain/ingest/text.py +16 -0
- brain/interactions.py +205 -0
- brain/maintenance.py +355 -0
- brain/mcp_server.py +3405 -0
- brain/migrations/001_init.sql +43 -0
- brain/migrations/002_qwen3_embedding.sql +17 -0
- brain/migrations/003_vault_model.sql +41 -0
- brain/migrations/004_relax_content_hash_uniqueness.sql +18 -0
- brain/migrations/005_derived_links.sql +67 -0
- brain/migrations/006_dedup_file_by_source_path.sql +25 -0
- brain/migrations/007_email_thread_and_draft.sql +15 -0
- brain/migrations/008_gmail_thread_unique.sql +11 -0
- brain/migrations/009_chunks_weighted_tsv.sql +28 -0
- brain/migrations/010_interactions.sql +30 -0
- brain/migrations/011_documents_summary.sql +23 -0
- brain/migrations/012_graphrag.sql +171 -0
- brain/migrations/013_graphrag_communities.sql +125 -0
- brain/migrations/014_graphrag_community_summary_hash.sql +33 -0
- brain/migrations/015_interactions_graph_targets.sql +89 -0
- brain/migrations/016_index_hygiene.sql +61 -0
- brain/migrations/017_elicit.sql +30 -0
- brain/migrations/018_review_gap_signal_kinds.sql +40 -0
- brain/migrations/019_search_queries.sql +35 -0
- brain/migrations/020_link_suggestions.sql +40 -0
- brain/migrations/021_timeline_doc_date.sql +34 -0
- brain/migrations/022_link_suggestions_undirected.sql +84 -0
- brain/migrations/023_search_queries_fts_count.sql +28 -0
- brain/quartz_overrides/__init__.py +8 -0
- brain/quartz_overrides/quartz/bootstrap-cli.mjs +65 -0
- brain/quartz_overrides/quartz/build.ts +568 -0
- brain/quartz_overrides/quartz/cli/args.js +152 -0
- brain/quartz_overrides/quartz/cli/build_partial_handler.js +544 -0
- brain/quartz_overrides/quartz/cli/handlers.js +636 -0
- brain/quartz_overrides/quartz/components/CommandPalette.tsx +172 -0
- brain/quartz_overrides/quartz/components/Explorer.tsx +198 -0
- brain/quartz_overrides/quartz/components/Footer.tsx +27 -0
- brain/quartz_overrides/quartz/components/Graph.tsx +468 -0
- brain/quartz_overrides/quartz/components/PageTitle.tsx +72 -0
- brain/quartz_overrides/quartz/components/RelatedDocs.tsx +38 -0
- brain/quartz_overrides/quartz/components/Search.tsx +161 -0
- brain/quartz_overrides/quartz/components/SummaryLede.tsx +72 -0
- brain/quartz_overrides/quartz/components/index.ts +92 -0
- brain/quartz_overrides/quartz/components/pages/TagContent.tsx +272 -0
- brain/quartz_overrides/quartz/components/scripts/commandPalette.inline.ts +665 -0
- brain/quartz_overrides/quartz/components/scripts/explorer.inline.ts +768 -0
- brain/quartz_overrides/quartz/components/scripts/graph.inline.ts +2302 -0
- brain/quartz_overrides/quartz/components/scripts/relatedDocs.inline.ts +163 -0
- brain/quartz_overrides/quartz/components/scripts/search.inline.ts +1011 -0
- brain/quartz_overrides/quartz/plugins/emitters/contentIndex.ts +546 -0
- brain/quartz_overrides/quartz/plugins/transformers/codeCopy.ts +94 -0
- brain/quartz_overrides/quartz/plugins/transformers/derivedFenceMark.ts +302 -0
- brain/quartz_overrides/quartz/plugins/transformers/emailThread.ts +148 -0
- brain/quartz_overrides/quartz/plugins/transformers/emptyDoorFilter.ts +213 -0
- brain/quartz_overrides/quartz/plugins/transformers/index.ts +114 -0
- brain/quartz_overrides/quartz/plugins/transformers/linkKindMark.ts +205 -0
- brain/quartz_overrides/quartz/plugins/transformers/linkSourceTag.ts +104 -0
- brain/quartz_overrides/quartz/plugins/transformers/relativeDate.ts +100 -0
- brain/quartz_overrides/quartz/plugins/transformers/reloadSignal.ts +131 -0
- brain/quartz_overrides/quartz/processors/parse.ts +371 -0
- brain/quartz_overrides/quartz/processors/parser_cache.ts +78 -0
- brain/quartz_overrides/quartz/static/brain-logo-dark.png +0 -0
- brain/quartz_overrides/quartz/static/brain-logo-light.png +0 -0
- brain/quartz_overrides/quartz/static/codeCopy.js +196 -0
- brain/quartz_overrides/quartz/static/emailThread.js +334 -0
- brain/quartz_overrides/quartz/static/favicon.ico +0 -0
- brain/quartz_overrides/quartz/static/icon.png +0 -0
- brain/quartz_overrides/quartz/static/linkSourceTag.js +104 -0
- brain/quartz_overrides/quartz/static/relativeDate.js +142 -0
- brain/quartz_overrides/quartz/static/reload.js +168 -0
- brain/quartz_overrides/quartz/styles/brain/_article.scss +252 -0
- brain/quartz_overrides/quartz/styles/brain/_atmosphere.scss +113 -0
- brain/quartz_overrides/quartz/styles/brain/_callouts.scss +180 -0
- brain/quartz_overrides/quartz/styles/brain/_cmdk.scss +7 -0
- brain/quartz_overrides/quartz/styles/brain/_code.scss +208 -0
- brain/quartz_overrides/quartz/styles/brain/_command_palette.scss +369 -0
- brain/quartz_overrides/quartz/styles/brain/_email_thread.scss +228 -0
- brain/quartz_overrides/quartz/styles/brain/_explorer.scss +142 -0
- brain/quartz_overrides/quartz/styles/brain/_home.scss +182 -0
- brain/quartz_overrides/quartz/styles/brain/_links.scss +322 -0
- brain/quartz_overrides/quartz/styles/brain/_marginalia.scss +117 -0
- brain/quartz_overrides/quartz/styles/brain/_motion.scss +175 -0
- brain/quartz_overrides/quartz/styles/brain/_people_hub.scss +100 -0
- brain/quartz_overrides/quartz/styles/brain/_related_docs.scss +137 -0
- brain/quartz_overrides/quartz/styles/brain/_search.scss +252 -0
- brain/quartz_overrides/quartz/styles/brain/_sidebar.scss +468 -0
- brain/quartz_overrides/quartz/styles/brain/_summary_lede.scss +56 -0
- brain/quartz_overrides/quartz/styles/brain/_surface.scss +43 -0
- brain/quartz_overrides/quartz/styles/brain/_tag_content.scss +118 -0
- brain/quartz_overrides/quartz/styles/brain/_tokens.scss +197 -0
- brain/quartz_overrides/quartz/styles/brain/_typography.scss +92 -0
- brain/quartz_overrides/quartz/styles/custom.scss +89 -0
- brain/quartz_overrides/quartz/styles/graph.scss +505 -0
- brain/quartz_overrides/quartz/util/ctx.ts +92 -0
- brain/quartz_overrides/quartz/util/fastpath_manifest.ts +608 -0
- brain/quartz_overrides/quartz/util/path.ts +358 -0
- brain/quartz_overrides/quartz/util/sourceIcons.ts +55 -0
- brain/quartz_overrides/quartz.config.ts +270 -0
- brain/quartz_overrides/quartz.layout.ts +314 -0
- brain/queries.py +1188 -0
- brain/rank_fusion.py +8 -0
- brain/resurface.py +210 -0
- brain/review/__init__.py +26 -0
- brain/review/emit.py +27 -0
- brain/review/queries.py +436 -0
- brain/review/render.py +196 -0
- brain/review/scans.py +355 -0
- brain/review/weekly.py +413 -0
- brain/search.py +704 -0
- brain/set_similarity.py +15 -0
- brain/setup.py +1205 -0
- brain/tags.py +56 -0
- brain/templates/Caddyfile.j2 +9 -0
- brain/templates/__init__.py +1 -0
- brain/templates/bin/__init__.py +1 -0
- brain/templates/bin/_brain-brief-fg.sh +25 -0
- brain/templates/bin/_brain-build-fg.sh +53 -0
- brain/templates/bin/_brain-watcher-fg.sh +65 -0
- brain/templates/bin/brain-down.sh +89 -0
- brain/templates/bin/brain-status.sh +83 -0
- brain/templates/bin/brain-up.sh +221 -0
- brain/templates/docker/age/Dockerfile +79 -0
- brain/templates/docker-compose.stock.yml.j2 +26 -0
- brain/templates/docker-compose.yml.j2 +34 -0
- brain/templates/env.example +190 -0
- brain/templates/launchd/__init__.py +1 -0
- brain/templates/launchd/com.brain.brief.plist.j2 +45 -0
- brain/templates/launchd/com.brain.build.plist.j2 +46 -0
- brain/templates/launchd/com.brain.watcher.plist.j2 +46 -0
- brain/templates/skill/SKILL.md +63 -0
- brain/templates/skill/__init__.py +1 -0
- brain/timeline.py +834 -0
- brain/todo.py +124 -0
- brain/uninstall.py +185 -0
- brain/vault/__init__.py +115 -0
- brain/vault/_atomic.py +25 -0
- brain/vault/daily_index.py +228 -0
- brain/vault/derived_links/__init__.py +50 -0
- brain/vault/derived_links/directory.py +683 -0
- brain/vault/derived_links/fence.py +408 -0
- brain/vault/derived_links/gws.py +64 -0
- brain/vault/derived_links/participants.py +143 -0
- brain/vault/derived_links/pass_runner.py +362 -0
- brain/vault/derived_links/rules.py +137 -0
- brain/vault/export.py +683 -0
- brain/vault/frontmatter.py +165 -0
- brain/vault/graph.py +620 -0
- brain/vault/graph_format.py +388 -0
- brain/vault/link_rewrite.py +235 -0
- brain/vault/links.py +260 -0
- brain/vault/note_builder.py +211 -0
- brain/vault/paths.py +55 -0
- brain/vault/quartz_overlay.py +236 -0
- brain/vault/rename.py +591 -0
- brain/vault/resolver.py +304 -0
- brain/vault/slug.py +127 -0
- brain/vault/sync.py +1513 -0
- brain/vault/sync_summaries.py +264 -0
- brain/vault/templates.py +145 -0
- brain/vault/watch.py +1052 -0
- brain/wiki/__init__.py +6 -0
- brain/wiki/_github_slugger.py +76 -0
- brain/wiki/_person_name.py +314 -0
- brain/wiki/build_homepage.py +541 -0
- brain/wiki/build_partial.py +273 -0
- brain/wiki/build_people.py +934 -0
- brain/wiki/build_related.py +758 -0
- brain/wiki/build_swap.py +585 -0
- brain/wiki/build_watcher.py +975 -0
- brain/wiki/edit_classifier.py +215 -0
- brain/wiki/errors.py +10 -0
- brain/wiki/fastpath_manifest.py +475 -0
- brain/wiki/fastpath_state.py +174 -0
- brain/wiki/install.py +296 -0
- brain/wiki/slug.py +111 -0
- secondbrain_py-0.2.1.dist-info/METADATA +195 -0
- secondbrain_py-0.2.1.dist-info/RECORD +273 -0
- secondbrain_py-0.2.1.dist-info/WHEEL +5 -0
- secondbrain_py-0.2.1.dist-info/entry_points.txt +11 -0
- secondbrain_py-0.2.1.dist-info/licenses/LICENSE +21 -0
- 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
|
+
)
|