shellbrain 0.1.0__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.
- app/__init__.py +1 -0
- app/__main__.py +7 -0
- app/boot/__init__.py +1 -0
- app/boot/admin_db.py +88 -0
- app/boot/config.py +14 -0
- app/boot/create_policy.py +52 -0
- app/boot/db.py +70 -0
- app/boot/embeddings.py +55 -0
- app/boot/home.py +45 -0
- app/boot/migrations.py +61 -0
- app/boot/read_policy.py +179 -0
- app/boot/repos.py +15 -0
- app/boot/retrieval.py +3 -0
- app/boot/thresholds.py +19 -0
- app/boot/update_policy.py +34 -0
- app/boot/use_cases.py +22 -0
- app/config/__init__.py +1 -0
- app/config/defaults/create_policy.yaml +7 -0
- app/config/defaults/read_policy.yaml +25 -0
- app/config/defaults/runtime.yaml +10 -0
- app/config/defaults/thresholds.yaml +3 -0
- app/config/defaults/update_policy.yaml +5 -0
- app/config/loader.py +58 -0
- app/core/__init__.py +1 -0
- app/core/contracts/__init__.py +1 -0
- app/core/contracts/errors.py +29 -0
- app/core/contracts/requests.py +211 -0
- app/core/contracts/responses.py +15 -0
- app/core/entities/__init__.py +1 -0
- app/core/entities/associations.py +58 -0
- app/core/entities/episodes.py +66 -0
- app/core/entities/evidence.py +29 -0
- app/core/entities/facts.py +30 -0
- app/core/entities/guidance.py +47 -0
- app/core/entities/identity.py +48 -0
- app/core/entities/memory.py +34 -0
- app/core/entities/runtime_context.py +19 -0
- app/core/entities/session_state.py +31 -0
- app/core/entities/telemetry.py +152 -0
- app/core/entities/utility.py +14 -0
- app/core/interfaces/__init__.py +1 -0
- app/core/interfaces/clock.py +12 -0
- app/core/interfaces/config.py +28 -0
- app/core/interfaces/embeddings.py +12 -0
- app/core/interfaces/idgen.py +11 -0
- app/core/interfaces/repos.py +279 -0
- app/core/interfaces/retrieval.py +20 -0
- app/core/interfaces/session_state_store.py +33 -0
- app/core/interfaces/unit_of_work.py +50 -0
- app/core/policies/__init__.py +1 -0
- app/core/policies/_shared/__init__.py +1 -0
- app/core/policies/_shared/executor.py +132 -0
- app/core/policies/_shared/side_effects.py +9 -0
- app/core/policies/create_policy/__init__.py +1 -0
- app/core/policies/create_policy/pipeline.py +96 -0
- app/core/policies/read_policy/__init__.py +1 -0
- app/core/policies/read_policy/bm25.py +114 -0
- app/core/policies/read_policy/context_pack_builder.py +140 -0
- app/core/policies/read_policy/expansion.py +132 -0
- app/core/policies/read_policy/fusion_rrf.py +34 -0
- app/core/policies/read_policy/lexical_query.py +101 -0
- app/core/policies/read_policy/pipeline.py +93 -0
- app/core/policies/read_policy/scenario_lift.py +11 -0
- app/core/policies/read_policy/scoring.py +61 -0
- app/core/policies/read_policy/seed_retrieval.py +54 -0
- app/core/policies/read_policy/utility_prior.py +11 -0
- app/core/policies/update_policy/__init__.py +1 -0
- app/core/policies/update_policy/pipeline.py +80 -0
- app/core/use_cases/__init__.py +1 -0
- app/core/use_cases/build_guidance.py +85 -0
- app/core/use_cases/create_memory.py +26 -0
- app/core/use_cases/manage_session_state.py +159 -0
- app/core/use_cases/read_memory.py +21 -0
- app/core/use_cases/record_episode_sync_telemetry.py +19 -0
- app/core/use_cases/record_operation_telemetry.py +32 -0
- app/core/use_cases/sync_episode.py +162 -0
- app/core/use_cases/update_memory.py +40 -0
- app/migrations/__init__.py +1 -0
- app/migrations/env.py +65 -0
- app/migrations/versions/20260226_0001_initial_schema.py +232 -0
- app/migrations/versions/20260312_0002_add_hard_invariants.py +60 -0
- app/migrations/versions/20260312_0003_drop_create_confidence.py +40 -0
- app/migrations/versions/20260313_0004_episode_sync_hardening.py +71 -0
- app/migrations/versions/20260313_0005_evidence_episode_event_refs.py +45 -0
- app/migrations/versions/20260318_0006_usage_telemetry_schema.py +175 -0
- app/migrations/versions/20260319_0007_identity_session_guidance.py +49 -0
- app/migrations/versions/20260320_0008_instance_metadata_and_backup_safety.py +31 -0
- app/migrations/versions/__init__.py +1 -0
- app/periphery/__init__.py +1 -0
- app/periphery/admin/__init__.py +1 -0
- app/periphery/admin/backup.py +360 -0
- app/periphery/admin/destructive_guard.py +32 -0
- app/periphery/admin/doctor.py +192 -0
- app/periphery/admin/init.py +996 -0
- app/periphery/admin/instance_guard.py +211 -0
- app/periphery/admin/machine_state.py +354 -0
- app/periphery/admin/privileges.py +42 -0
- app/periphery/admin/repo_state.py +266 -0
- app/periphery/admin/restore.py +30 -0
- app/periphery/cli/__init__.py +1 -0
- app/periphery/cli/handlers.py +830 -0
- app/periphery/cli/hydration.py +119 -0
- app/periphery/cli/main.py +710 -0
- app/periphery/cli/presenter_json.py +10 -0
- app/periphery/cli/schema_validation.py +201 -0
- app/periphery/db/__init__.py +1 -0
- app/periphery/db/engine.py +10 -0
- app/periphery/db/models/__init__.py +1 -0
- app/periphery/db/models/associations.py +55 -0
- app/periphery/db/models/episodes.py +55 -0
- app/periphery/db/models/evidence.py +19 -0
- app/periphery/db/models/experiences.py +33 -0
- app/periphery/db/models/instance_metadata.py +17 -0
- app/periphery/db/models/memories.py +39 -0
- app/periphery/db/models/metadata.py +6 -0
- app/periphery/db/models/registry.py +18 -0
- app/periphery/db/models/telemetry.py +174 -0
- app/periphery/db/models/utility.py +19 -0
- app/periphery/db/models/views.py +154 -0
- app/periphery/db/repos/__init__.py +1 -0
- app/periphery/db/repos/relational/__init__.py +1 -0
- app/periphery/db/repos/relational/associations_repo.py +117 -0
- app/periphery/db/repos/relational/episodes_repo.py +188 -0
- app/periphery/db/repos/relational/evidence_repo.py +82 -0
- app/periphery/db/repos/relational/experiences_repo.py +41 -0
- app/periphery/db/repos/relational/memories_repo.py +99 -0
- app/periphery/db/repos/relational/read_policy_repo.py +202 -0
- app/periphery/db/repos/relational/telemetry_repo.py +161 -0
- app/periphery/db/repos/relational/utility_repo.py +30 -0
- app/periphery/db/repos/semantic/__init__.py +1 -0
- app/periphery/db/repos/semantic/keyword_retrieval_repo.py +63 -0
- app/periphery/db/repos/semantic/semantic_retrieval_repo.py +111 -0
- app/periphery/db/session.py +10 -0
- app/periphery/db/uow.py +75 -0
- app/periphery/embeddings/__init__.py +1 -0
- app/periphery/embeddings/local_provider.py +35 -0
- app/periphery/embeddings/query_vector_search.py +18 -0
- app/periphery/episodes/__init__.py +1 -0
- app/periphery/episodes/claude_code.py +387 -0
- app/periphery/episodes/codex.py +423 -0
- app/periphery/episodes/launcher.py +66 -0
- app/periphery/episodes/normalization.py +31 -0
- app/periphery/episodes/poller.py +299 -0
- app/periphery/episodes/source_discovery.py +66 -0
- app/periphery/episodes/tool_filter.py +165 -0
- app/periphery/identity/__init__.py +1 -0
- app/periphery/identity/claude_hook_install.py +67 -0
- app/periphery/identity/claude_runtime.py +83 -0
- app/periphery/identity/codex_runtime.py +32 -0
- app/periphery/identity/compatibility.py +38 -0
- app/periphery/identity/resolver.py +163 -0
- app/periphery/session_state/__init__.py +1 -0
- app/periphery/session_state/file_store.py +100 -0
- app/periphery/telemetry/__init__.py +33 -0
- app/periphery/telemetry/operation_summary.py +299 -0
- app/periphery/telemetry/session_selection.py +156 -0
- app/periphery/telemetry/sync_summary.py +65 -0
- app/periphery/validation/__init__.py +1 -0
- app/periphery/validation/integrity_validation.py +253 -0
- app/periphery/validation/semantic_validation.py +94 -0
- shellbrain-0.1.0.dist-info/METADATA +130 -0
- shellbrain-0.1.0.dist-info/RECORD +165 -0
- shellbrain-0.1.0.dist-info/WHEEL +5 -0
- shellbrain-0.1.0.dist-info/entry_points.txt +2 -0
- shellbrain-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
"""This module defines the CLI entry point for shellbrain operations and admin commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import sys
|
|
9
|
+
from textwrap import dedent
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Sequence
|
|
11
|
+
from uuid import uuid4
|
|
12
|
+
|
|
13
|
+
from app.periphery.cli.hydration import resolve_repo_context
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from app.periphery.cli.hydration import RepoContext
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _HelpFormatter(argparse.RawDescriptionHelpFormatter):
|
|
20
|
+
"""Keep multiline examples readable in CLI help output."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_TOP_LEVEL_HELP = dedent(
|
|
24
|
+
"""\
|
|
25
|
+
Use Shellbrain as a case-based memory system for agent work.
|
|
26
|
+
|
|
27
|
+
Install and bootstrap:
|
|
28
|
+
1. `pipx install shellbrain`
|
|
29
|
+
2. `shellbrain init`
|
|
30
|
+
|
|
31
|
+
Mental model:
|
|
32
|
+
- `read` retrieves durable memories related to the concrete problem or subproblem.
|
|
33
|
+
- `events` inspects episodic transcript evidence from the active session.
|
|
34
|
+
- `create` authors durable memories from that evidence.
|
|
35
|
+
- `update` records utility, truth-evolution links, and explicit associations.
|
|
36
|
+
|
|
37
|
+
Typical workflow:
|
|
38
|
+
0. Run `shellbrain init` once per machine, then rerun it whenever Shellbrain says repair is needed.
|
|
39
|
+
1. Query with the concrete bug, subsystem, decision, or constraint you are working on.
|
|
40
|
+
Avoid generic prompts like "what should I know about this repo?"
|
|
41
|
+
2. Re-run `read` whenever the search shifts or you get stuck.
|
|
42
|
+
3. Run `events` before every write and reuse returned ids verbatim as `evidence_refs`.
|
|
43
|
+
4. At session end, normalize the episode into `problem`, `failed_tactic`, `solution`, `fact`, `preference`, and `change` memories, then record `utility_vote` updates for memories that helped or misled.
|
|
44
|
+
|
|
45
|
+
Examples:
|
|
46
|
+
shellbrain init
|
|
47
|
+
shellbrain read --json '{"query":"Have we seen this migration lock timeout before?","kinds":["problem","solution","failed_tactic"]}'
|
|
48
|
+
shellbrain read --json '{"query":"What repo constraints or user preferences matter for this auth refactor?","kinds":["fact","preference","change"]}'
|
|
49
|
+
shellbrain events --json '{"limit":10}'
|
|
50
|
+
shellbrain create --json '{"memory":{"text":"Migration failed because the lock timeout was too low","kind":"problem","evidence_refs":["evt-123"]}}'
|
|
51
|
+
shellbrain update --json '{"memory_id":"mem-older-solution","update":{"type":"utility_vote","problem_id":"mem-problem-123","vote":1.0,"evidence_refs":["evt-124"]}}'
|
|
52
|
+
shellbrain admin migrate
|
|
53
|
+
shellbrain admin backup create
|
|
54
|
+
shellbrain admin doctor
|
|
55
|
+
|
|
56
|
+
Common recovery steps:
|
|
57
|
+
- `shellbrain: command not found`: reinstall with `pipx install shellbrain`.
|
|
58
|
+
- `Shellbrain machine config is unreadable`: rerun `shellbrain init` to repair the managed instance.
|
|
59
|
+
- `Outcome: blocked_dependency`: install Docker or start the Docker daemon, then rerun `shellbrain init`.
|
|
60
|
+
- No active host session found: verify Codex/Claude Code transcript availability, then rerun `events`.
|
|
61
|
+
- Evidence ref rejected: rerun `events` and use the returned `episode_event` ids verbatim.
|
|
62
|
+
- Wrong working tree: rerun with `--repo-root` (and optionally `--repo-id`) for the target repo.
|
|
63
|
+
"""
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
_CREATE_HELP = dedent(
|
|
67
|
+
"""\
|
|
68
|
+
Create one durable Shellbrain entry from explicit evidence.
|
|
69
|
+
|
|
70
|
+
Choose the memory kind deliberately:
|
|
71
|
+
- `problem`: the obstacle or failure mode
|
|
72
|
+
- `solution`: what worked for a specific problem
|
|
73
|
+
- `failed_tactic`: what did not work for a specific problem
|
|
74
|
+
- `fact`: durable truth
|
|
75
|
+
- `preference`: durable convention
|
|
76
|
+
- `change`: truth invalidation or revision
|
|
77
|
+
|
|
78
|
+
`solution` and `failed_tactic` require `memory.links.problem_id`.
|
|
79
|
+
|
|
80
|
+
Example:
|
|
81
|
+
shellbrain create --json '{"memory":{"text":"The staging DB migration needs a 30s lock timeout","kind":"fact","evidence_refs":["evt-123"]}}'
|
|
82
|
+
"""
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
_READ_HELP = dedent(
|
|
86
|
+
"""\
|
|
87
|
+
Retrieve Shellbrain context without mutating state.
|
|
88
|
+
|
|
89
|
+
Use concrete failure modes, subsystem names, decisions, or constraints.
|
|
90
|
+
Avoid generic prompts like "what should I know about this repo?"
|
|
91
|
+
|
|
92
|
+
Returned pack sections:
|
|
93
|
+
- `direct`
|
|
94
|
+
- `explicit_related`
|
|
95
|
+
- `implicit_related`
|
|
96
|
+
|
|
97
|
+
Example:
|
|
98
|
+
shellbrain read --json '{"query":"Have we seen this migration lock timeout before?","kinds":["problem","solution","failed_tactic"]}'
|
|
99
|
+
"""
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
_EVENTS_HELP = dedent(
|
|
103
|
+
"""\
|
|
104
|
+
Inspect the newest repo-matching host session and return recent `episode_event` ids.
|
|
105
|
+
|
|
106
|
+
`events` performs an inline transcript sync before returning normalized episodic evidence.
|
|
107
|
+
|
|
108
|
+
Example:
|
|
109
|
+
shellbrain events --json '{"limit":10}'
|
|
110
|
+
"""
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
_UPDATE_HELP = dedent(
|
|
114
|
+
"""\
|
|
115
|
+
Update one existing Shellbrain entry.
|
|
116
|
+
|
|
117
|
+
Update types:
|
|
118
|
+
- `archive_state`
|
|
119
|
+
- `utility_vote` (`-1.0` to `1.0`; negative = unhelpful, `0.0` = neutral, positive = helpful)
|
|
120
|
+
- `fact_update_link`
|
|
121
|
+
- `association_link`
|
|
122
|
+
|
|
123
|
+
Example:
|
|
124
|
+
shellbrain update --json '{"memory_id":"mem-older-solution","update":{"type":"utility_vote","problem_id":"mem-problem-123","vote":1.0,"evidence_refs":["evt-456"]}}'
|
|
125
|
+
"""
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
_ADMIN_HELP = dedent(
|
|
129
|
+
"""\
|
|
130
|
+
Administrative commands for bootstrapping and maintaining the shellbrain database.
|
|
131
|
+
|
|
132
|
+
Example:
|
|
133
|
+
shellbrain init
|
|
134
|
+
shellbrain admin migrate
|
|
135
|
+
"""
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
_INIT_HELP = dedent(
|
|
139
|
+
"""\
|
|
140
|
+
Bootstrap or repair the machine-local Shellbrain runtime, then register the current repo.
|
|
141
|
+
|
|
142
|
+
Happy path:
|
|
143
|
+
- `shellbrain init`
|
|
144
|
+
- Shellbrain provisions or reuses one managed local Postgres instance, prepares embeddings, and registers the repo.
|
|
145
|
+
|
|
146
|
+
Advanced:
|
|
147
|
+
- `--repo-root` targets a different repo root.
|
|
148
|
+
- `--repo-id` overrides repo identity when multiple remotes exist or a weak local identity is not acceptable.
|
|
149
|
+
- `--host claude` installs the Claude hook even when not running inside Claude Code.
|
|
150
|
+
|
|
151
|
+
Examples:
|
|
152
|
+
shellbrain init
|
|
153
|
+
shellbrain init --repo-root /path/to/repo
|
|
154
|
+
shellbrain init --host claude
|
|
155
|
+
shellbrain init --skip-model-download
|
|
156
|
+
"""
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
_BACKUP_HELP = dedent(
|
|
160
|
+
"""\
|
|
161
|
+
Create, list, verify, and restore Shellbrain logical backups.
|
|
162
|
+
|
|
163
|
+
Examples:
|
|
164
|
+
shellbrain admin backup create
|
|
165
|
+
shellbrain admin backup list
|
|
166
|
+
shellbrain admin backup verify
|
|
167
|
+
shellbrain admin backup restore --target-db shellbrain_restore_001
|
|
168
|
+
"""
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
_DOCTOR_HELP = dedent(
|
|
172
|
+
"""\
|
|
173
|
+
Print one safety report for the current Shellbrain database configuration.
|
|
174
|
+
|
|
175
|
+
Example:
|
|
176
|
+
shellbrain admin doctor
|
|
177
|
+
"""
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
_INSTALL_CLAUDE_HOOK_HELP = dedent(
|
|
181
|
+
"""\
|
|
182
|
+
Install or update the repo-local Claude Code SessionStart hook used for trusted Shellbrain caller identity.
|
|
183
|
+
|
|
184
|
+
Example:
|
|
185
|
+
shellbrain admin install-claude-hook --repo-root /path/to/repo
|
|
186
|
+
"""
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
_SESSION_STATE_HELP = dedent(
|
|
190
|
+
"""\
|
|
191
|
+
Inspect or clean repo-local per-caller Shellbrain session state.
|
|
192
|
+
|
|
193
|
+
Examples:
|
|
194
|
+
shellbrain admin session-state inspect --caller-id codex:thread-123
|
|
195
|
+
shellbrain admin session-state clear --caller-id codex:thread-123
|
|
196
|
+
shellbrain admin session-state gc
|
|
197
|
+
"""
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
_MIGRATE_HELP = dedent(
|
|
201
|
+
"""\
|
|
202
|
+
Apply packaged Alembic migrations to the database referenced by `SHELLBRAIN_DB_ADMIN_DSN`.
|
|
203
|
+
|
|
204
|
+
Example:
|
|
205
|
+
SHELLBRAIN_DB_ADMIN_DSN=postgresql+psycopg://shellbrain_admin:shellbrain_admin@localhost:5432/shellbrain shellbrain admin migrate
|
|
206
|
+
"""
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _load_payload(json_text: str | None, json_file: str | None) -> dict[str, Any]:
|
|
211
|
+
"""This function loads a payload from either inline JSON text or a JSON file."""
|
|
212
|
+
|
|
213
|
+
if json_text:
|
|
214
|
+
return json.loads(json_text)
|
|
215
|
+
if json_file:
|
|
216
|
+
content = Path(json_file).read_text(encoding="utf-8")
|
|
217
|
+
return json.loads(content)
|
|
218
|
+
raise ValueError("Either --json or --json-file is required")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
222
|
+
"""Build the public CLI parser with operator help and subcommands."""
|
|
223
|
+
|
|
224
|
+
parser = argparse.ArgumentParser(
|
|
225
|
+
prog="shellbrain",
|
|
226
|
+
description="Shellbrain CLI for repo-scoped recall and evidence-backed writes.",
|
|
227
|
+
epilog=_TOP_LEVEL_HELP,
|
|
228
|
+
formatter_class=_HelpFormatter,
|
|
229
|
+
)
|
|
230
|
+
_add_repo_context_arguments(parser)
|
|
231
|
+
subparsers = parser.add_subparsers(dest="command", required=True, metavar="command")
|
|
232
|
+
|
|
233
|
+
init_parser = subparsers.add_parser(
|
|
234
|
+
"init",
|
|
235
|
+
help="Bootstrap or repair the managed Shellbrain runtime.",
|
|
236
|
+
description="Bootstrap or repair the machine-local Shellbrain runtime and register the current repo.",
|
|
237
|
+
epilog=_INIT_HELP,
|
|
238
|
+
formatter_class=_HelpFormatter,
|
|
239
|
+
)
|
|
240
|
+
_add_repo_context_arguments(init_parser, suppress_default=True)
|
|
241
|
+
init_parser.add_argument(
|
|
242
|
+
"--host",
|
|
243
|
+
choices=("auto", "claude", "none"),
|
|
244
|
+
default="auto",
|
|
245
|
+
help="Host integration mode. Defaults to auto.",
|
|
246
|
+
)
|
|
247
|
+
init_parser.add_argument(
|
|
248
|
+
"--no-claude",
|
|
249
|
+
action="store_true",
|
|
250
|
+
help="Disable Claude integration even if this repo looks Claude-managed.",
|
|
251
|
+
)
|
|
252
|
+
init_parser.add_argument(
|
|
253
|
+
"--skip-model-download",
|
|
254
|
+
action="store_true",
|
|
255
|
+
help="Skip embedding model prewarm during init.",
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
create_parser = subparsers.add_parser(
|
|
259
|
+
"create",
|
|
260
|
+
help="Create one Shellbrain entry from explicit evidence.",
|
|
261
|
+
description="Create one durable Shellbrain entry from explicit evidence references.",
|
|
262
|
+
epilog=_CREATE_HELP,
|
|
263
|
+
formatter_class=_HelpFormatter,
|
|
264
|
+
)
|
|
265
|
+
_add_repo_context_arguments(create_parser, suppress_default=True)
|
|
266
|
+
_add_payload_arguments(create_parser)
|
|
267
|
+
|
|
268
|
+
read_parser = subparsers.add_parser(
|
|
269
|
+
"read",
|
|
270
|
+
help="Read Shellbrain context without mutating state.",
|
|
271
|
+
description="Retrieve Shellbrain context relevant to one repo-scoped question.",
|
|
272
|
+
epilog=_READ_HELP,
|
|
273
|
+
formatter_class=_HelpFormatter,
|
|
274
|
+
)
|
|
275
|
+
_add_repo_context_arguments(read_parser, suppress_default=True)
|
|
276
|
+
_add_payload_arguments(read_parser)
|
|
277
|
+
|
|
278
|
+
events_parser = subparsers.add_parser(
|
|
279
|
+
"events",
|
|
280
|
+
help="Inspect recent host transcript events.",
|
|
281
|
+
description="Return recent episode events from the newest repo-matching host session.",
|
|
282
|
+
epilog=_EVENTS_HELP,
|
|
283
|
+
formatter_class=_HelpFormatter,
|
|
284
|
+
)
|
|
285
|
+
_add_repo_context_arguments(events_parser, suppress_default=True)
|
|
286
|
+
_add_payload_arguments(events_parser)
|
|
287
|
+
|
|
288
|
+
update_parser = subparsers.add_parser(
|
|
289
|
+
"update",
|
|
290
|
+
help="Update one existing Shellbrain entry from explicit evidence.",
|
|
291
|
+
description="Apply one evidence-backed update to an existing memory.",
|
|
292
|
+
epilog=_UPDATE_HELP,
|
|
293
|
+
formatter_class=_HelpFormatter,
|
|
294
|
+
)
|
|
295
|
+
_add_repo_context_arguments(update_parser, suppress_default=True)
|
|
296
|
+
_add_payload_arguments(update_parser)
|
|
297
|
+
|
|
298
|
+
admin_parser = subparsers.add_parser(
|
|
299
|
+
"admin",
|
|
300
|
+
help="Administrative bootstrap commands.",
|
|
301
|
+
description="Administrative commands for database bootstrap and maintenance.",
|
|
302
|
+
epilog=_ADMIN_HELP,
|
|
303
|
+
formatter_class=_HelpFormatter,
|
|
304
|
+
)
|
|
305
|
+
admin_subparsers = admin_parser.add_subparsers(dest="admin_command", required=True, metavar="admin-command")
|
|
306
|
+
admin_subparsers.add_parser(
|
|
307
|
+
"migrate",
|
|
308
|
+
help="Apply packaged schema migrations to the configured database.",
|
|
309
|
+
description="Apply packaged Alembic migrations to the database referenced by SHELLBRAIN_DB_ADMIN_DSN.",
|
|
310
|
+
epilog=_MIGRATE_HELP,
|
|
311
|
+
formatter_class=_HelpFormatter,
|
|
312
|
+
)
|
|
313
|
+
backup_parser = admin_subparsers.add_parser(
|
|
314
|
+
"backup",
|
|
315
|
+
help="Create, list, verify, and restore Shellbrain logical backups.",
|
|
316
|
+
description="Create, list, verify, and restore Shellbrain logical backups.",
|
|
317
|
+
epilog=_BACKUP_HELP,
|
|
318
|
+
formatter_class=_HelpFormatter,
|
|
319
|
+
)
|
|
320
|
+
backup_subparsers = backup_parser.add_subparsers(dest="backup_command", required=True, metavar="backup-command")
|
|
321
|
+
backup_subparsers.add_parser("create", help="Create one logical backup for the configured database.")
|
|
322
|
+
backup_subparsers.add_parser("list", help="List available backup manifests.")
|
|
323
|
+
verify_parser = backup_subparsers.add_parser("verify", help="Verify one backup artifact, defaulting to the newest.")
|
|
324
|
+
verify_parser.add_argument("--backup-id", help="Optional backup id to verify. Defaults to the newest backup.")
|
|
325
|
+
restore_parser = backup_subparsers.add_parser("restore", help="Restore one backup into a fresh scratch database.")
|
|
326
|
+
restore_parser.add_argument("--target-db", required=True, help="Name of the scratch restore database to create.")
|
|
327
|
+
restore_parser.add_argument("--backup-id", help="Optional backup id to restore. Defaults to the newest backup.")
|
|
328
|
+
admin_subparsers.add_parser(
|
|
329
|
+
"doctor",
|
|
330
|
+
help="Print one Shellbrain safety report for DB role, instance mode, and backups.",
|
|
331
|
+
description="Print one Shellbrain safety report for DB role, instance mode, and backups.",
|
|
332
|
+
epilog=_DOCTOR_HELP,
|
|
333
|
+
formatter_class=_HelpFormatter,
|
|
334
|
+
)
|
|
335
|
+
admin_subparsers.choices["doctor"].add_argument(
|
|
336
|
+
"--repo-root",
|
|
337
|
+
help="Optional repo root for repo registration and Claude integration diagnostics.",
|
|
338
|
+
)
|
|
339
|
+
install_hook_parser = admin_subparsers.add_parser(
|
|
340
|
+
"install-claude-hook",
|
|
341
|
+
help="Install the repo-local Claude hook used for trusted caller identity.",
|
|
342
|
+
description="Install or update the repo-local Claude Code SessionStart hook used by Shellbrain.",
|
|
343
|
+
epilog=_INSTALL_CLAUDE_HOOK_HELP,
|
|
344
|
+
formatter_class=_HelpFormatter,
|
|
345
|
+
)
|
|
346
|
+
install_hook_parser.add_argument("--repo-root", help="Target repository root. Defaults to the current working directory.")
|
|
347
|
+
|
|
348
|
+
session_state_parser = admin_subparsers.add_parser(
|
|
349
|
+
"session-state",
|
|
350
|
+
help="Inspect or clean repo-local per-caller Shellbrain session state.",
|
|
351
|
+
description="Inspect or clean repo-local per-caller Shellbrain session state.",
|
|
352
|
+
epilog=_SESSION_STATE_HELP,
|
|
353
|
+
formatter_class=_HelpFormatter,
|
|
354
|
+
)
|
|
355
|
+
session_state_parser.add_argument("--repo-root", help="Target repository root. Defaults to the current working directory.")
|
|
356
|
+
session_state_subparsers = session_state_parser.add_subparsers(
|
|
357
|
+
dest="session_state_command",
|
|
358
|
+
required=True,
|
|
359
|
+
metavar="session-state-command",
|
|
360
|
+
)
|
|
361
|
+
inspect_parser = session_state_subparsers.add_parser("inspect", help="Print one caller state as JSON.")
|
|
362
|
+
inspect_parser.add_argument("--caller-id", required=True)
|
|
363
|
+
clear_parser = session_state_subparsers.add_parser("clear", help="Delete one caller state.")
|
|
364
|
+
clear_parser.add_argument("--caller-id", required=True)
|
|
365
|
+
session_state_subparsers.add_parser("gc", help="Delete stale caller state files.")
|
|
366
|
+
return parser
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
370
|
+
"""Parse CLI arguments and dispatch to the requested operational or admin path."""
|
|
371
|
+
|
|
372
|
+
parser = build_parser()
|
|
373
|
+
args = parser.parse_args(argv)
|
|
374
|
+
|
|
375
|
+
if args.command == "init":
|
|
376
|
+
try:
|
|
377
|
+
from app.periphery.admin.init import run_init
|
|
378
|
+
|
|
379
|
+
repo_root = _resolve_admin_repo_root(getattr(args, "repo_root", None))
|
|
380
|
+
except ValueError as exc:
|
|
381
|
+
parser.error(str(exc))
|
|
382
|
+
return 2
|
|
383
|
+
host_mode = "none" if getattr(args, "no_claude", False) else getattr(args, "host", "auto")
|
|
384
|
+
result = run_init(
|
|
385
|
+
repo_root=repo_root,
|
|
386
|
+
repo_id_override=getattr(args, "repo_id", None),
|
|
387
|
+
host_mode=host_mode,
|
|
388
|
+
skip_model_download=bool(getattr(args, "skip_model_download", False)),
|
|
389
|
+
)
|
|
390
|
+
print(f"Outcome: {result.outcome}")
|
|
391
|
+
for line in result.lines:
|
|
392
|
+
print(line)
|
|
393
|
+
return result.exit_code
|
|
394
|
+
|
|
395
|
+
if args.command == "admin":
|
|
396
|
+
return _run_admin_command(args)
|
|
397
|
+
|
|
398
|
+
try:
|
|
399
|
+
from app.core.entities.runtime_context import RuntimeContext
|
|
400
|
+
from app.periphery.identity.resolver import resolve_caller_identity
|
|
401
|
+
from app.periphery.telemetry import reset_operation_telemetry_context, set_operation_telemetry_context
|
|
402
|
+
|
|
403
|
+
repo_context = resolve_repo_context(
|
|
404
|
+
repo_root_arg=getattr(args, "repo_root", None),
|
|
405
|
+
repo_id_arg=getattr(args, "repo_id", None),
|
|
406
|
+
)
|
|
407
|
+
payload = _load_payload(getattr(args, "json_text", None), getattr(args, "json_file", None))
|
|
408
|
+
except ValueError as exc:
|
|
409
|
+
parser.error(str(exc))
|
|
410
|
+
return 2
|
|
411
|
+
|
|
412
|
+
caller_identity_resolution = resolve_caller_identity()
|
|
413
|
+
operation_context = RuntimeContext(
|
|
414
|
+
invocation_id=str(uuid4()),
|
|
415
|
+
repo_root=str(repo_context.repo_root),
|
|
416
|
+
no_sync=bool(getattr(args, "no_sync", False)),
|
|
417
|
+
caller_identity=caller_identity_resolution.caller_identity,
|
|
418
|
+
caller_identity_error=caller_identity_resolution.error,
|
|
419
|
+
)
|
|
420
|
+
token = set_operation_telemetry_context(operation_context)
|
|
421
|
+
try:
|
|
422
|
+
try:
|
|
423
|
+
_warn_or_fail_on_unsafe_app_role()
|
|
424
|
+
result = _dispatch_operation_command(args.command, payload, repo_context)
|
|
425
|
+
_print_operation_result(result)
|
|
426
|
+
if result.get("status") == "ok":
|
|
427
|
+
if getattr(args, "no_sync", False):
|
|
428
|
+
_update_operation_polling_status(
|
|
429
|
+
invocation_id=operation_context.invocation_id,
|
|
430
|
+
attempted=False,
|
|
431
|
+
started=False,
|
|
432
|
+
)
|
|
433
|
+
else:
|
|
434
|
+
started = bool(_maybe_start_sync(repo_context))
|
|
435
|
+
_update_operation_polling_status(
|
|
436
|
+
invocation_id=operation_context.invocation_id,
|
|
437
|
+
attempted=True,
|
|
438
|
+
started=started,
|
|
439
|
+
)
|
|
440
|
+
return 0
|
|
441
|
+
except (RuntimeError, ValueError) as exc:
|
|
442
|
+
print(str(exc), file=sys.stderr)
|
|
443
|
+
return 1
|
|
444
|
+
finally:
|
|
445
|
+
reset_operation_telemetry_context(token)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _add_repo_context_arguments(parser: argparse.ArgumentParser, *, suppress_default: bool = False) -> None:
|
|
449
|
+
"""Add shared repo-targeting and sync-control arguments to one parser."""
|
|
450
|
+
|
|
451
|
+
kwargs = {"default": argparse.SUPPRESS} if suppress_default else {}
|
|
452
|
+
parser.add_argument(
|
|
453
|
+
"--repo-root",
|
|
454
|
+
help="Target repository root. Defaults to the current working directory.",
|
|
455
|
+
**kwargs,
|
|
456
|
+
)
|
|
457
|
+
parser.add_argument(
|
|
458
|
+
"--repo-id",
|
|
459
|
+
help="Override the inferred repo identifier. Advanced: use when multiple remotes exist or you need a durable local override.",
|
|
460
|
+
**kwargs,
|
|
461
|
+
)
|
|
462
|
+
parser.add_argument(
|
|
463
|
+
"--no-sync",
|
|
464
|
+
action="store_true",
|
|
465
|
+
help=argparse.SUPPRESS,
|
|
466
|
+
**kwargs,
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _add_payload_arguments(parser: argparse.ArgumentParser) -> None:
|
|
471
|
+
"""Require one JSON payload source for an operational subcommand."""
|
|
472
|
+
|
|
473
|
+
payload_group = parser.add_mutually_exclusive_group(required=True)
|
|
474
|
+
payload_group.add_argument("--json", dest="json_text", help="Inline JSON payload.")
|
|
475
|
+
payload_group.add_argument("--json-file", dest="json_file", help="Path to a JSON payload file.")
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _dispatch_operation_command(command: str, payload: dict[str, Any], repo_context: RepoContext) -> dict[str, Any]:
|
|
479
|
+
"""Resolve runtime dependencies lazily and execute one operational command."""
|
|
480
|
+
|
|
481
|
+
from app.boot.create_policy import get_create_hydration_defaults
|
|
482
|
+
from app.boot.read_policy import get_read_hydration_defaults
|
|
483
|
+
from app.boot.use_cases import get_embedding_model, get_embedding_provider_factory, get_uow_factory
|
|
484
|
+
from app.periphery.cli.handlers import handle_create, handle_events, handle_read, handle_update
|
|
485
|
+
from app.periphery.telemetry import get_operation_telemetry_context
|
|
486
|
+
|
|
487
|
+
uow_factory = get_uow_factory()
|
|
488
|
+
if command == "create":
|
|
489
|
+
return handle_create(
|
|
490
|
+
payload,
|
|
491
|
+
uow_factory=uow_factory,
|
|
492
|
+
embedding_provider_factory=get_embedding_provider_factory(),
|
|
493
|
+
embedding_model=get_embedding_model(),
|
|
494
|
+
inferred_repo_id=repo_context.repo_id,
|
|
495
|
+
defaults=get_create_hydration_defaults(),
|
|
496
|
+
telemetry_context=get_operation_telemetry_context(),
|
|
497
|
+
repo_root=repo_context.repo_root,
|
|
498
|
+
)
|
|
499
|
+
if command == "read":
|
|
500
|
+
return handle_read(
|
|
501
|
+
payload,
|
|
502
|
+
uow_factory=uow_factory,
|
|
503
|
+
inferred_repo_id=repo_context.repo_id,
|
|
504
|
+
defaults=get_read_hydration_defaults(),
|
|
505
|
+
telemetry_context=get_operation_telemetry_context(),
|
|
506
|
+
repo_root=repo_context.repo_root,
|
|
507
|
+
)
|
|
508
|
+
if command == "update":
|
|
509
|
+
return handle_update(
|
|
510
|
+
payload,
|
|
511
|
+
uow_factory=uow_factory,
|
|
512
|
+
inferred_repo_id=repo_context.repo_id,
|
|
513
|
+
telemetry_context=get_operation_telemetry_context(),
|
|
514
|
+
repo_root=repo_context.repo_root,
|
|
515
|
+
)
|
|
516
|
+
if command == "events":
|
|
517
|
+
return handle_events(
|
|
518
|
+
payload,
|
|
519
|
+
uow_factory=uow_factory,
|
|
520
|
+
inferred_repo_id=repo_context.repo_id,
|
|
521
|
+
repo_root=repo_context.repo_root,
|
|
522
|
+
telemetry_context=get_operation_telemetry_context(),
|
|
523
|
+
)
|
|
524
|
+
raise ValueError(f"Unsupported command: {command}")
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _run_admin_command(args: argparse.Namespace) -> int:
|
|
528
|
+
"""Execute one admin command."""
|
|
529
|
+
|
|
530
|
+
if args.admin_command == "migrate":
|
|
531
|
+
from app.boot.migrations import upgrade_database
|
|
532
|
+
|
|
533
|
+
upgrade_database()
|
|
534
|
+
print("Applied shellbrain schema migrations to head.")
|
|
535
|
+
return 0
|
|
536
|
+
|
|
537
|
+
if args.admin_command == "backup":
|
|
538
|
+
from app.boot.db import get_optional_db_dsn
|
|
539
|
+
from app.boot.admin_db import get_admin_db_dsn, get_backup_dir, get_backup_mirror_dir
|
|
540
|
+
from app.periphery.admin.backup import create_backup, list_backups, verify_backup
|
|
541
|
+
from app.periphery.admin.machine_state import try_load_machine_config
|
|
542
|
+
from app.periphery.admin.restore import restore_backup
|
|
543
|
+
|
|
544
|
+
admin_dsn = get_admin_db_dsn()
|
|
545
|
+
backup_root = get_backup_dir()
|
|
546
|
+
mirror_root = get_backup_mirror_dir()
|
|
547
|
+
managed_backup_kwargs = _managed_backup_kwargs(*try_load_machine_config())
|
|
548
|
+
subcommand = getattr(args, "backup_command", None)
|
|
549
|
+
if subcommand == "create":
|
|
550
|
+
manifest = create_backup(
|
|
551
|
+
admin_dsn=admin_dsn,
|
|
552
|
+
backup_root=backup_root,
|
|
553
|
+
mirror_root=mirror_root,
|
|
554
|
+
**managed_backup_kwargs,
|
|
555
|
+
)
|
|
556
|
+
print(json.dumps(manifest.__dict__, indent=2, sort_keys=True))
|
|
557
|
+
return 0
|
|
558
|
+
if subcommand == "list":
|
|
559
|
+
print(json.dumps([manifest.__dict__ for manifest in list_backups(backup_root=backup_root)], indent=2, sort_keys=True))
|
|
560
|
+
return 0
|
|
561
|
+
if subcommand == "verify":
|
|
562
|
+
manifest = verify_backup(backup_root=backup_root, backup_id=args.backup_id)
|
|
563
|
+
print(json.dumps(manifest.__dict__, indent=2, sort_keys=True))
|
|
564
|
+
return 0
|
|
565
|
+
if subcommand == "restore":
|
|
566
|
+
manifest = restore_backup(
|
|
567
|
+
admin_dsn=admin_dsn,
|
|
568
|
+
backup_root=backup_root,
|
|
569
|
+
target_db=args.target_db,
|
|
570
|
+
app_dsn=get_optional_db_dsn(),
|
|
571
|
+
backup_id=args.backup_id,
|
|
572
|
+
**_managed_restore_kwargs(managed_backup_kwargs),
|
|
573
|
+
)
|
|
574
|
+
print(json.dumps({"restored_backup_id": manifest.backup_id, "target_db": args.target_db}, indent=2, sort_keys=True))
|
|
575
|
+
return 0
|
|
576
|
+
|
|
577
|
+
if args.admin_command == "doctor":
|
|
578
|
+
from app.boot.admin_db import get_backup_dir, get_optional_admin_db_dsn
|
|
579
|
+
from app.boot.db import get_optional_db_dsn
|
|
580
|
+
from app.periphery.admin.doctor import build_doctor_report
|
|
581
|
+
|
|
582
|
+
report = build_doctor_report(
|
|
583
|
+
app_dsn=get_optional_db_dsn(),
|
|
584
|
+
admin_dsn=get_optional_admin_db_dsn(),
|
|
585
|
+
backup_root=get_backup_dir(),
|
|
586
|
+
repo_root=_resolve_admin_repo_root(getattr(args, "repo_root", None)),
|
|
587
|
+
)
|
|
588
|
+
print(json.dumps(report, indent=2, sort_keys=True))
|
|
589
|
+
return 0
|
|
590
|
+
|
|
591
|
+
repo_root = _resolve_admin_repo_root(getattr(args, "repo_root", None))
|
|
592
|
+
if args.admin_command == "install-claude-hook":
|
|
593
|
+
from app.periphery.identity.claude_hook_install import install_claude_hook
|
|
594
|
+
|
|
595
|
+
settings_path = install_claude_hook(repo_root=repo_root)
|
|
596
|
+
print(f"Installed Claude hook at {settings_path}")
|
|
597
|
+
return 0
|
|
598
|
+
|
|
599
|
+
if args.admin_command == "session-state":
|
|
600
|
+
from app.periphery.session_state.file_store import FileSessionStateStore
|
|
601
|
+
|
|
602
|
+
store = FileSessionStateStore()
|
|
603
|
+
subcommand = getattr(args, "session_state_command", None)
|
|
604
|
+
if subcommand == "inspect":
|
|
605
|
+
state = store.load(repo_root=repo_root, caller_id=args.caller_id)
|
|
606
|
+
print(json.dumps(None if state is None else state.__dict__, indent=2, sort_keys=True))
|
|
607
|
+
return 0
|
|
608
|
+
if subcommand == "clear":
|
|
609
|
+
store.delete(repo_root=repo_root, caller_id=args.caller_id)
|
|
610
|
+
print(f"Cleared session state for {args.caller_id}")
|
|
611
|
+
return 0
|
|
612
|
+
if subcommand == "gc":
|
|
613
|
+
from datetime import datetime, timedelta, timezone
|
|
614
|
+
|
|
615
|
+
deleted = store.gc(
|
|
616
|
+
repo_root=repo_root,
|
|
617
|
+
older_than_iso=(datetime.now(timezone.utc) - timedelta(days=7)).isoformat(),
|
|
618
|
+
)
|
|
619
|
+
print(json.dumps({"deleted": deleted}, indent=2, sort_keys=True))
|
|
620
|
+
return 0
|
|
621
|
+
raise ValueError(f"Unsupported admin command: {args.admin_command}")
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _print_operation_result(result: dict[str, Any]) -> None:
|
|
625
|
+
"""Render one operation result as JSON for agent consumption."""
|
|
626
|
+
|
|
627
|
+
from app.periphery.cli.presenter_json import render
|
|
628
|
+
|
|
629
|
+
print(render(result))
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _maybe_start_sync(repo_context: RepoContext) -> bool:
|
|
633
|
+
"""Best-effort startup for repo-local episode sync after a successful command."""
|
|
634
|
+
|
|
635
|
+
try:
|
|
636
|
+
from app.periphery.episodes.launcher import ensure_episode_sync_started
|
|
637
|
+
|
|
638
|
+
return bool(ensure_episode_sync_started(repo_id=repo_context.repo_id, repo_root=repo_context.repo_root))
|
|
639
|
+
except Exception:
|
|
640
|
+
return False
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _update_operation_polling_status(*, invocation_id: str, attempted: bool, started: bool) -> None:
|
|
644
|
+
"""Patch poller-start telemetry flags without affecting the visible command result."""
|
|
645
|
+
|
|
646
|
+
try:
|
|
647
|
+
from app.boot.use_cases import get_uow_factory
|
|
648
|
+
|
|
649
|
+
with get_uow_factory()() as uow:
|
|
650
|
+
uow.telemetry.update_operation_polling(
|
|
651
|
+
invocation_id,
|
|
652
|
+
attempted=attempted,
|
|
653
|
+
started=started,
|
|
654
|
+
)
|
|
655
|
+
except Exception:
|
|
656
|
+
return
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _resolve_admin_repo_root(repo_root_arg: str | None) -> Path:
|
|
660
|
+
"""Resolve one admin repo root without inferring repo_id."""
|
|
661
|
+
|
|
662
|
+
repo_root = Path(repo_root_arg).expanduser().resolve() if repo_root_arg else Path.cwd().resolve()
|
|
663
|
+
if not repo_root.exists():
|
|
664
|
+
raise ValueError(f"repo_root does not exist: {repo_root}")
|
|
665
|
+
if not repo_root.is_dir():
|
|
666
|
+
raise ValueError(f"repo_root must be a directory: {repo_root}")
|
|
667
|
+
return repo_root
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _managed_backup_kwargs(machine_config, machine_error: str | None) -> dict[str, Any]:
|
|
671
|
+
"""Return managed-container backup kwargs when machine config is active and readable."""
|
|
672
|
+
|
|
673
|
+
if machine_error is not None or machine_config is None or machine_config.runtime_mode != "managed_local":
|
|
674
|
+
return {}
|
|
675
|
+
return {
|
|
676
|
+
"container_name": machine_config.managed.container_name,
|
|
677
|
+
"container_db_name": machine_config.managed.db_name,
|
|
678
|
+
"container_admin_user": machine_config.managed.admin_user,
|
|
679
|
+
"container_admin_password": machine_config.managed.admin_password,
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _managed_restore_kwargs(managed_backup_kwargs: dict[str, Any]) -> dict[str, Any]:
|
|
684
|
+
"""Trim backup kwargs down to the subset restore understands."""
|
|
685
|
+
|
|
686
|
+
return {
|
|
687
|
+
key: value
|
|
688
|
+
for key, value in managed_backup_kwargs.items()
|
|
689
|
+
if key in {"container_name", "container_admin_user", "container_admin_password"}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _warn_or_fail_on_unsafe_app_role() -> None:
|
|
694
|
+
"""Emit one warning, or fail in strict mode, when the app DSN is overprivileged."""
|
|
695
|
+
|
|
696
|
+
from app.boot.admin_db import should_fail_on_unsafe_app_role
|
|
697
|
+
from app.boot.db import get_db_dsn
|
|
698
|
+
from app.periphery.admin.instance_guard import inspect_role_safety
|
|
699
|
+
|
|
700
|
+
warnings = inspect_role_safety(get_db_dsn())
|
|
701
|
+
if not warnings:
|
|
702
|
+
return
|
|
703
|
+
message = "Unsafe Shellbrain app-role configuration:\n- " + "\n- ".join(warnings)
|
|
704
|
+
if should_fail_on_unsafe_app_role():
|
|
705
|
+
raise ValueError(message)
|
|
706
|
+
print(message, file=sys.stderr)
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
if __name__ == "__main__":
|
|
710
|
+
raise SystemExit(main(sys.argv[1:]))
|