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.
Files changed (165) hide show
  1. app/__init__.py +1 -0
  2. app/__main__.py +7 -0
  3. app/boot/__init__.py +1 -0
  4. app/boot/admin_db.py +88 -0
  5. app/boot/config.py +14 -0
  6. app/boot/create_policy.py +52 -0
  7. app/boot/db.py +70 -0
  8. app/boot/embeddings.py +55 -0
  9. app/boot/home.py +45 -0
  10. app/boot/migrations.py +61 -0
  11. app/boot/read_policy.py +179 -0
  12. app/boot/repos.py +15 -0
  13. app/boot/retrieval.py +3 -0
  14. app/boot/thresholds.py +19 -0
  15. app/boot/update_policy.py +34 -0
  16. app/boot/use_cases.py +22 -0
  17. app/config/__init__.py +1 -0
  18. app/config/defaults/create_policy.yaml +7 -0
  19. app/config/defaults/read_policy.yaml +25 -0
  20. app/config/defaults/runtime.yaml +10 -0
  21. app/config/defaults/thresholds.yaml +3 -0
  22. app/config/defaults/update_policy.yaml +5 -0
  23. app/config/loader.py +58 -0
  24. app/core/__init__.py +1 -0
  25. app/core/contracts/__init__.py +1 -0
  26. app/core/contracts/errors.py +29 -0
  27. app/core/contracts/requests.py +211 -0
  28. app/core/contracts/responses.py +15 -0
  29. app/core/entities/__init__.py +1 -0
  30. app/core/entities/associations.py +58 -0
  31. app/core/entities/episodes.py +66 -0
  32. app/core/entities/evidence.py +29 -0
  33. app/core/entities/facts.py +30 -0
  34. app/core/entities/guidance.py +47 -0
  35. app/core/entities/identity.py +48 -0
  36. app/core/entities/memory.py +34 -0
  37. app/core/entities/runtime_context.py +19 -0
  38. app/core/entities/session_state.py +31 -0
  39. app/core/entities/telemetry.py +152 -0
  40. app/core/entities/utility.py +14 -0
  41. app/core/interfaces/__init__.py +1 -0
  42. app/core/interfaces/clock.py +12 -0
  43. app/core/interfaces/config.py +28 -0
  44. app/core/interfaces/embeddings.py +12 -0
  45. app/core/interfaces/idgen.py +11 -0
  46. app/core/interfaces/repos.py +279 -0
  47. app/core/interfaces/retrieval.py +20 -0
  48. app/core/interfaces/session_state_store.py +33 -0
  49. app/core/interfaces/unit_of_work.py +50 -0
  50. app/core/policies/__init__.py +1 -0
  51. app/core/policies/_shared/__init__.py +1 -0
  52. app/core/policies/_shared/executor.py +132 -0
  53. app/core/policies/_shared/side_effects.py +9 -0
  54. app/core/policies/create_policy/__init__.py +1 -0
  55. app/core/policies/create_policy/pipeline.py +96 -0
  56. app/core/policies/read_policy/__init__.py +1 -0
  57. app/core/policies/read_policy/bm25.py +114 -0
  58. app/core/policies/read_policy/context_pack_builder.py +140 -0
  59. app/core/policies/read_policy/expansion.py +132 -0
  60. app/core/policies/read_policy/fusion_rrf.py +34 -0
  61. app/core/policies/read_policy/lexical_query.py +101 -0
  62. app/core/policies/read_policy/pipeline.py +93 -0
  63. app/core/policies/read_policy/scenario_lift.py +11 -0
  64. app/core/policies/read_policy/scoring.py +61 -0
  65. app/core/policies/read_policy/seed_retrieval.py +54 -0
  66. app/core/policies/read_policy/utility_prior.py +11 -0
  67. app/core/policies/update_policy/__init__.py +1 -0
  68. app/core/policies/update_policy/pipeline.py +80 -0
  69. app/core/use_cases/__init__.py +1 -0
  70. app/core/use_cases/build_guidance.py +85 -0
  71. app/core/use_cases/create_memory.py +26 -0
  72. app/core/use_cases/manage_session_state.py +159 -0
  73. app/core/use_cases/read_memory.py +21 -0
  74. app/core/use_cases/record_episode_sync_telemetry.py +19 -0
  75. app/core/use_cases/record_operation_telemetry.py +32 -0
  76. app/core/use_cases/sync_episode.py +162 -0
  77. app/core/use_cases/update_memory.py +40 -0
  78. app/migrations/__init__.py +1 -0
  79. app/migrations/env.py +65 -0
  80. app/migrations/versions/20260226_0001_initial_schema.py +232 -0
  81. app/migrations/versions/20260312_0002_add_hard_invariants.py +60 -0
  82. app/migrations/versions/20260312_0003_drop_create_confidence.py +40 -0
  83. app/migrations/versions/20260313_0004_episode_sync_hardening.py +71 -0
  84. app/migrations/versions/20260313_0005_evidence_episode_event_refs.py +45 -0
  85. app/migrations/versions/20260318_0006_usage_telemetry_schema.py +175 -0
  86. app/migrations/versions/20260319_0007_identity_session_guidance.py +49 -0
  87. app/migrations/versions/20260320_0008_instance_metadata_and_backup_safety.py +31 -0
  88. app/migrations/versions/__init__.py +1 -0
  89. app/periphery/__init__.py +1 -0
  90. app/periphery/admin/__init__.py +1 -0
  91. app/periphery/admin/backup.py +360 -0
  92. app/periphery/admin/destructive_guard.py +32 -0
  93. app/periphery/admin/doctor.py +192 -0
  94. app/periphery/admin/init.py +996 -0
  95. app/periphery/admin/instance_guard.py +211 -0
  96. app/periphery/admin/machine_state.py +354 -0
  97. app/periphery/admin/privileges.py +42 -0
  98. app/periphery/admin/repo_state.py +266 -0
  99. app/periphery/admin/restore.py +30 -0
  100. app/periphery/cli/__init__.py +1 -0
  101. app/periphery/cli/handlers.py +830 -0
  102. app/periphery/cli/hydration.py +119 -0
  103. app/periphery/cli/main.py +710 -0
  104. app/periphery/cli/presenter_json.py +10 -0
  105. app/periphery/cli/schema_validation.py +201 -0
  106. app/periphery/db/__init__.py +1 -0
  107. app/periphery/db/engine.py +10 -0
  108. app/periphery/db/models/__init__.py +1 -0
  109. app/periphery/db/models/associations.py +55 -0
  110. app/periphery/db/models/episodes.py +55 -0
  111. app/periphery/db/models/evidence.py +19 -0
  112. app/periphery/db/models/experiences.py +33 -0
  113. app/periphery/db/models/instance_metadata.py +17 -0
  114. app/periphery/db/models/memories.py +39 -0
  115. app/periphery/db/models/metadata.py +6 -0
  116. app/periphery/db/models/registry.py +18 -0
  117. app/periphery/db/models/telemetry.py +174 -0
  118. app/periphery/db/models/utility.py +19 -0
  119. app/periphery/db/models/views.py +154 -0
  120. app/periphery/db/repos/__init__.py +1 -0
  121. app/periphery/db/repos/relational/__init__.py +1 -0
  122. app/periphery/db/repos/relational/associations_repo.py +117 -0
  123. app/periphery/db/repos/relational/episodes_repo.py +188 -0
  124. app/periphery/db/repos/relational/evidence_repo.py +82 -0
  125. app/periphery/db/repos/relational/experiences_repo.py +41 -0
  126. app/periphery/db/repos/relational/memories_repo.py +99 -0
  127. app/periphery/db/repos/relational/read_policy_repo.py +202 -0
  128. app/periphery/db/repos/relational/telemetry_repo.py +161 -0
  129. app/periphery/db/repos/relational/utility_repo.py +30 -0
  130. app/periphery/db/repos/semantic/__init__.py +1 -0
  131. app/periphery/db/repos/semantic/keyword_retrieval_repo.py +63 -0
  132. app/periphery/db/repos/semantic/semantic_retrieval_repo.py +111 -0
  133. app/periphery/db/session.py +10 -0
  134. app/periphery/db/uow.py +75 -0
  135. app/periphery/embeddings/__init__.py +1 -0
  136. app/periphery/embeddings/local_provider.py +35 -0
  137. app/periphery/embeddings/query_vector_search.py +18 -0
  138. app/periphery/episodes/__init__.py +1 -0
  139. app/periphery/episodes/claude_code.py +387 -0
  140. app/periphery/episodes/codex.py +423 -0
  141. app/periphery/episodes/launcher.py +66 -0
  142. app/periphery/episodes/normalization.py +31 -0
  143. app/periphery/episodes/poller.py +299 -0
  144. app/periphery/episodes/source_discovery.py +66 -0
  145. app/periphery/episodes/tool_filter.py +165 -0
  146. app/periphery/identity/__init__.py +1 -0
  147. app/periphery/identity/claude_hook_install.py +67 -0
  148. app/periphery/identity/claude_runtime.py +83 -0
  149. app/periphery/identity/codex_runtime.py +32 -0
  150. app/periphery/identity/compatibility.py +38 -0
  151. app/periphery/identity/resolver.py +163 -0
  152. app/periphery/session_state/__init__.py +1 -0
  153. app/periphery/session_state/file_store.py +100 -0
  154. app/periphery/telemetry/__init__.py +33 -0
  155. app/periphery/telemetry/operation_summary.py +299 -0
  156. app/periphery/telemetry/session_selection.py +156 -0
  157. app/periphery/telemetry/sync_summary.py +65 -0
  158. app/periphery/validation/__init__.py +1 -0
  159. app/periphery/validation/integrity_validation.py +253 -0
  160. app/periphery/validation/semantic_validation.py +94 -0
  161. shellbrain-0.1.0.dist-info/METADATA +130 -0
  162. shellbrain-0.1.0.dist-info/RECORD +165 -0
  163. shellbrain-0.1.0.dist-info/WHEEL +5 -0
  164. shellbrain-0.1.0.dist-info/entry_points.txt +2 -0
  165. 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:]))