codealmanac 0.1.0.dev0__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 (192) hide show
  1. codealmanac/__init__.py +13 -0
  2. codealmanac/app.py +175 -0
  3. codealmanac/cli/__init__.py +1 -0
  4. codealmanac/cli/dispatch/__init__.py +0 -0
  5. codealmanac/cli/dispatch/admin.py +124 -0
  6. codealmanac/cli/dispatch/config.py +50 -0
  7. codealmanac/cli/dispatch/root.py +328 -0
  8. codealmanac/cli/main.py +28 -0
  9. codealmanac/cli/parser/__init__.py +0 -0
  10. codealmanac/cli/parser/admin.py +81 -0
  11. codealmanac/cli/parser/lifecycle.py +57 -0
  12. codealmanac/cli/parser/root.py +19 -0
  13. codealmanac/cli/parser/wiki.py +87 -0
  14. codealmanac/cli/render/__init__.py +0 -0
  15. codealmanac/cli/render/admin.py +191 -0
  16. codealmanac/cli/render/root.py +290 -0
  17. codealmanac/core/__init__.py +1 -0
  18. codealmanac/core/errors.py +45 -0
  19. codealmanac/core/models.py +14 -0
  20. codealmanac/core/paths.py +25 -0
  21. codealmanac/core/slug.py +7 -0
  22. codealmanac/core/text.py +5 -0
  23. codealmanac/database/__init__.py +15 -0
  24. codealmanac/database/sqlite.py +54 -0
  25. codealmanac/integrations/__init__.py +1 -0
  26. codealmanac/integrations/automation/__init__.py +3 -0
  27. codealmanac/integrations/automation/scheduler/__init__.py +5 -0
  28. codealmanac/integrations/automation/scheduler/launchd.py +163 -0
  29. codealmanac/integrations/command.py +56 -0
  30. codealmanac/integrations/harnesses/__init__.py +7 -0
  31. codealmanac/integrations/harnesses/claude/__init__.py +1 -0
  32. codealmanac/integrations/harnesses/claude/adapter.py +217 -0
  33. codealmanac/integrations/harnesses/codex/__init__.py +3 -0
  34. codealmanac/integrations/harnesses/codex/adapter.py +221 -0
  35. codealmanac/integrations/harnesses/git_status.py +49 -0
  36. codealmanac/integrations/sources/__init__.py +29 -0
  37. codealmanac/integrations/sources/filesystem/__init__.py +5 -0
  38. codealmanac/integrations/sources/filesystem/adapter.py +685 -0
  39. codealmanac/integrations/sources/filesystem/selection.py +209 -0
  40. codealmanac/integrations/sources/git/__init__.py +3 -0
  41. codealmanac/integrations/sources/git/adapter.py +132 -0
  42. codealmanac/integrations/sources/github/__init__.py +3 -0
  43. codealmanac/integrations/sources/github/adapter.py +413 -0
  44. codealmanac/integrations/sources/runtime.py +22 -0
  45. codealmanac/integrations/sources/transcripts/__init__.py +33 -0
  46. codealmanac/integrations/sources/transcripts/claude.py +61 -0
  47. codealmanac/integrations/sources/transcripts/codex.py +69 -0
  48. codealmanac/integrations/sources/transcripts/jsonl.py +84 -0
  49. codealmanac/integrations/sources/transcripts/runtime.py +387 -0
  50. codealmanac/integrations/sources/web/__init__.py +3 -0
  51. codealmanac/integrations/sources/web/adapter.py +303 -0
  52. codealmanac/integrations/updates/__init__.py +7 -0
  53. codealmanac/integrations/updates/package.py +85 -0
  54. codealmanac/integrations/workspaces/__init__.py +1 -0
  55. codealmanac/integrations/workspaces/git/__init__.py +3 -0
  56. codealmanac/integrations/workspaces/git/probe.py +128 -0
  57. codealmanac/manual/README.md +24 -0
  58. codealmanac/manual/__init__.py +19 -0
  59. codealmanac/manual/build.md +20 -0
  60. codealmanac/manual/evidence.md +23 -0
  61. codealmanac/manual/garden.md +20 -0
  62. codealmanac/manual/ingest.md +17 -0
  63. codealmanac/manual/library.py +84 -0
  64. codealmanac/manual/models.py +83 -0
  65. codealmanac/manual/pages.md +28 -0
  66. codealmanac/manual/requests.py +6 -0
  67. codealmanac/manual/sources.md +18 -0
  68. codealmanac/manual/style.md +19 -0
  69. codealmanac/prompts/__init__.py +5 -0
  70. codealmanac/prompts/base/notability.md +14 -0
  71. codealmanac/prompts/base/purpose.md +23 -0
  72. codealmanac/prompts/base/syntax.md +19 -0
  73. codealmanac/prompts/models.py +9 -0
  74. codealmanac/prompts/operations/garden.md +26 -0
  75. codealmanac/prompts/operations/ingest.md +18 -0
  76. codealmanac/prompts/renderer.py +24 -0
  77. codealmanac/prompts/requests.py +22 -0
  78. codealmanac/server/__init__.py +1 -0
  79. codealmanac/server/app.py +202 -0
  80. codealmanac/server/assets/__init__.py +1 -0
  81. codealmanac/server/assets/app.css +865 -0
  82. codealmanac/server/assets/app.js +3 -0
  83. codealmanac/server/assets/index.html +80 -0
  84. codealmanac/server/assets/viewer/api.js +30 -0
  85. codealmanac/server/assets/viewer/components.js +197 -0
  86. codealmanac/server/assets/viewer/main.js +126 -0
  87. codealmanac/server/assets/viewer/renderers.js +122 -0
  88. codealmanac/server/assets/viewer/routes.js +36 -0
  89. codealmanac/services/__init__.py +1 -0
  90. codealmanac/services/automation/__init__.py +3 -0
  91. codealmanac/services/automation/models.py +83 -0
  92. codealmanac/services/automation/ports.py +14 -0
  93. codealmanac/services/automation/requests.py +40 -0
  94. codealmanac/services/automation/service.py +294 -0
  95. codealmanac/services/config/__init__.py +17 -0
  96. codealmanac/services/config/models.py +61 -0
  97. codealmanac/services/config/requests.py +21 -0
  98. codealmanac/services/config/service.py +55 -0
  99. codealmanac/services/config/store.py +26 -0
  100. codealmanac/services/diagnostics/__init__.py +1 -0
  101. codealmanac/services/diagnostics/models.py +22 -0
  102. codealmanac/services/diagnostics/requests.py +8 -0
  103. codealmanac/services/diagnostics/service.py +283 -0
  104. codealmanac/services/harnesses/__init__.py +1 -0
  105. codealmanac/services/harnesses/models.py +104 -0
  106. codealmanac/services/harnesses/ports.py +18 -0
  107. codealmanac/services/harnesses/requests.py +19 -0
  108. codealmanac/services/harnesses/service.py +38 -0
  109. codealmanac/services/health/__init__.py +1 -0
  110. codealmanac/services/health/requests.py +8 -0
  111. codealmanac/services/health/service.py +20 -0
  112. codealmanac/services/index/__init__.py +1 -0
  113. codealmanac/services/index/models.py +135 -0
  114. codealmanac/services/index/requests.py +26 -0
  115. codealmanac/services/index/service.py +86 -0
  116. codealmanac/services/index/store.py +411 -0
  117. codealmanac/services/index/views.py +524 -0
  118. codealmanac/services/pages/__init__.py +1 -0
  119. codealmanac/services/pages/requests.py +17 -0
  120. codealmanac/services/pages/service.py +26 -0
  121. codealmanac/services/runs/__init__.py +1 -0
  122. codealmanac/services/runs/models.py +91 -0
  123. codealmanac/services/runs/requests.py +76 -0
  124. codealmanac/services/runs/service.py +86 -0
  125. codealmanac/services/runs/store.py +256 -0
  126. codealmanac/services/search/__init__.py +1 -0
  127. codealmanac/services/search/requests.py +23 -0
  128. codealmanac/services/search/service.py +31 -0
  129. codealmanac/services/sources/__init__.py +1 -0
  130. codealmanac/services/sources/models.py +126 -0
  131. codealmanac/services/sources/ports.py +30 -0
  132. codealmanac/services/sources/requests.py +76 -0
  133. codealmanac/services/sources/service.py +351 -0
  134. codealmanac/services/tagging/__init__.py +1 -0
  135. codealmanac/services/tagging/models.py +9 -0
  136. codealmanac/services/tagging/requests.py +35 -0
  137. codealmanac/services/tagging/service.py +43 -0
  138. codealmanac/services/topics/__init__.py +1 -0
  139. codealmanac/services/topics/models.py +36 -0
  140. codealmanac/services/topics/requests.py +115 -0
  141. codealmanac/services/topics/service.py +297 -0
  142. codealmanac/services/updates/__init__.py +4 -0
  143. codealmanac/services/updates/models.py +83 -0
  144. codealmanac/services/updates/ports.py +17 -0
  145. codealmanac/services/updates/requests.py +10 -0
  146. codealmanac/services/updates/service.py +113 -0
  147. codealmanac/services/viewer/__init__.py +1 -0
  148. codealmanac/services/viewer/models.py +80 -0
  149. codealmanac/services/viewer/renderer.py +89 -0
  150. codealmanac/services/viewer/requests.py +86 -0
  151. codealmanac/services/viewer/service.py +211 -0
  152. codealmanac/services/wiki/__init__.py +1 -0
  153. codealmanac/services/wiki/documents.py +83 -0
  154. codealmanac/services/wiki/frontmatter.py +94 -0
  155. codealmanac/services/wiki/frontmatter_rewrite.py +142 -0
  156. codealmanac/services/wiki/models.py +69 -0
  157. codealmanac/services/wiki/paths.py +42 -0
  158. codealmanac/services/wiki/service.py +57 -0
  159. codealmanac/services/wiki/templates.py +73 -0
  160. codealmanac/services/wiki/topics.py +266 -0
  161. codealmanac/services/wiki/wikilinks.py +58 -0
  162. codealmanac/services/workspaces/__init__.py +1 -0
  163. codealmanac/services/workspaces/models.py +124 -0
  164. codealmanac/services/workspaces/ports.py +9 -0
  165. codealmanac/services/workspaces/requests.py +82 -0
  166. codealmanac/services/workspaces/roots.py +74 -0
  167. codealmanac/services/workspaces/service.py +303 -0
  168. codealmanac/services/workspaces/store.py +127 -0
  169. codealmanac/workflows/__init__.py +1 -0
  170. codealmanac/workflows/build/__init__.py +1 -0
  171. codealmanac/workflows/build/models.py +8 -0
  172. codealmanac/workflows/build/service.py +45 -0
  173. codealmanac/workflows/garden/__init__.py +3 -0
  174. codealmanac/workflows/garden/models.py +30 -0
  175. codealmanac/workflows/garden/requests.py +22 -0
  176. codealmanac/workflows/garden/service.py +239 -0
  177. codealmanac/workflows/ingest/__init__.py +1 -0
  178. codealmanac/workflows/ingest/models.py +26 -0
  179. codealmanac/workflows/ingest/requests.py +39 -0
  180. codealmanac/workflows/ingest/service.py +302 -0
  181. codealmanac/workflows/lifecycle.py +197 -0
  182. codealmanac/workflows/sync/__init__.py +3 -0
  183. codealmanac/workflows/sync/models.py +157 -0
  184. codealmanac/workflows/sync/requests.py +63 -0
  185. codealmanac/workflows/sync/service.py +651 -0
  186. codealmanac/workflows/sync/store.py +51 -0
  187. codealmanac-0.1.0.dev0.dist-info/METADATA +248 -0
  188. codealmanac-0.1.0.dev0.dist-info/RECORD +192 -0
  189. codealmanac-0.1.0.dev0.dist-info/WHEEL +5 -0
  190. codealmanac-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  191. codealmanac-0.1.0.dev0.dist-info/licenses/LICENSE.md +201 -0
  192. codealmanac-0.1.0.dev0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,14 @@
1
+ from typing import Protocol
2
+
3
+ from codealmanac.services.automation.models import ScheduledJob, ScheduledJobStatus
4
+
5
+
6
+ class SchedulerAdapter(Protocol):
7
+ def install(self, job: ScheduledJob) -> ScheduledJobStatus:
8
+ """Install and activate one scheduled job."""
9
+
10
+ def uninstall(self, job: ScheduledJob) -> bool:
11
+ """Remove one scheduled job. Return true when a plist was removed."""
12
+
13
+ def status(self, job: ScheduledJob) -> ScheduledJobStatus:
14
+ """Read persisted scheduler state for one job."""
@@ -0,0 +1,40 @@
1
+ from datetime import timedelta
2
+ from pathlib import Path
3
+
4
+ from pydantic import field_validator
5
+
6
+ from codealmanac.core.models import CodeAlmanacModel
7
+ from codealmanac.services.automation.models import AutomationTask
8
+
9
+
10
+ class AutomationSelectionRequest(CodeAlmanacModel):
11
+ tasks: tuple[AutomationTask, ...] = ()
12
+ home: Path | None = None
13
+
14
+
15
+ class InstallAutomationRequest(AutomationSelectionRequest):
16
+ cwd: Path
17
+ every: timedelta | None = None
18
+ quiet: timedelta | None = None
19
+ garden_every: timedelta | None = None
20
+ garden_off: bool = False
21
+ env_path: str | None = None
22
+ python_executable: Path | None = None
23
+
24
+ @field_validator("every", "quiet", "garden_every")
25
+ @classmethod
26
+ def non_negative_duration(
27
+ cls,
28
+ value: timedelta | None,
29
+ ) -> timedelta | None:
30
+ if value is not None and value.total_seconds() < 0:
31
+ raise ValueError("automation duration must be non-negative")
32
+ return value
33
+
34
+
35
+ class UninstallAutomationRequest(AutomationSelectionRequest):
36
+ pass
37
+
38
+
39
+ class AutomationStatusRequest(AutomationSelectionRequest):
40
+ pass
@@ -0,0 +1,294 @@
1
+ import os
2
+ import sys
3
+ from collections.abc import Sequence
4
+ from dataclasses import dataclass
5
+ from datetime import timedelta
6
+ from pathlib import Path
7
+
8
+ from codealmanac.core.errors import ValidationFailed
9
+ from codealmanac.core.paths import home_dir, normalize_path, state_dir_for
10
+ from codealmanac.services.automation.models import (
11
+ AutomationInstallResult,
12
+ AutomationStatusReport,
13
+ AutomationTask,
14
+ AutomationUninstallResult,
15
+ AutomationWorkingDirectory,
16
+ EnvironmentVariable,
17
+ ScheduledJob,
18
+ ScheduledJobStatus,
19
+ )
20
+ from codealmanac.services.automation.ports import SchedulerAdapter
21
+ from codealmanac.services.automation.requests import (
22
+ AutomationSelectionRequest,
23
+ AutomationStatusRequest,
24
+ InstallAutomationRequest,
25
+ UninstallAutomationRequest,
26
+ )
27
+ from codealmanac.services.config.models import DEFAULT_SYNC_QUIET
28
+ from codealmanac.services.workspaces.service import WorkspacesService
29
+
30
+ DEFAULT_SYNC_INTERVAL = timedelta(hours=5)
31
+ DEFAULT_GARDEN_INTERVAL = timedelta(hours=4)
32
+ AUTOMATION_SYNC_CLAIM_OWNER = "codealmanac.automation.sync"
33
+ AUTOMATION_SYNC_PENDING_TIMEOUT = timedelta(hours=24)
34
+ AUTOMATION_SYNC_MAX_FAILED_ATTEMPTS = 3
35
+
36
+ SYNC_LABEL = "com.codealmanac.sync"
37
+ GARDEN_LABEL = "com.codealmanac.garden"
38
+
39
+ LAUNCHD_FALLBACK_PATHS = (
40
+ "/usr/local/bin",
41
+ "/opt/homebrew/bin",
42
+ "/usr/bin",
43
+ "/bin",
44
+ "/usr/sbin",
45
+ "/sbin",
46
+ )
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class AutomationTaskDefinition:
51
+ task: AutomationTask
52
+ label: str
53
+ default_interval: timedelta
54
+ stdout_log_name: str
55
+ stderr_log_name: str
56
+ working_directory: AutomationWorkingDirectory
57
+
58
+
59
+ DEFAULT_INSTALL_TASKS = (AutomationTask.SYNC, AutomationTask.GARDEN)
60
+ DEFAULT_STATUS_TASKS = (AutomationTask.SYNC, AutomationTask.GARDEN)
61
+
62
+
63
+ class AutomationService:
64
+ def __init__(
65
+ self,
66
+ workspaces: WorkspacesService,
67
+ scheduler: SchedulerAdapter,
68
+ ):
69
+ self.workspaces = workspaces
70
+ self.scheduler = scheduler
71
+
72
+ def install(self, request: InstallAutomationRequest) -> AutomationInstallResult:
73
+ explicit_tasks = len(request.tasks) > 0
74
+ tasks = selected_tasks(request.tasks, DEFAULT_INSTALL_TASKS)
75
+ if explicit_tasks and request.garden_off:
76
+ raise ValidationFailed(
77
+ "--garden-off can only be used with the default automation install"
78
+ )
79
+ if explicit_tasks and len(tasks) > 1 and request.every is not None:
80
+ raise ValidationFailed(
81
+ "--every can only target one explicit automation task at a time"
82
+ )
83
+ if request.quiet is not None and request.quiet.total_seconds() < 0:
84
+ raise ValidationFailed("quiet window must be zero or greater")
85
+
86
+ selected = tuple(
87
+ task
88
+ for task in tasks
89
+ if not (task == AutomationTask.GARDEN and request.garden_off)
90
+ )
91
+ jobs = tuple(
92
+ self._job_for_task(
93
+ task,
94
+ request,
95
+ explicit_tasks,
96
+ resolve_working_directory=True,
97
+ )
98
+ for task in selected
99
+ )
100
+ for job in jobs:
101
+ self.scheduler.install(job)
102
+
103
+ disabled: tuple[ScheduledJob, ...] = ()
104
+ if not explicit_tasks and request.garden_off:
105
+ garden = self._job_for_task(
106
+ AutomationTask.GARDEN,
107
+ request,
108
+ explicit_tasks,
109
+ resolve_working_directory=False,
110
+ )
111
+ self.scheduler.uninstall(garden)
112
+ disabled = (garden,)
113
+
114
+ return AutomationInstallResult(jobs=jobs, disabled=disabled)
115
+
116
+ def uninstall(
117
+ self,
118
+ request: UninstallAutomationRequest,
119
+ ) -> AutomationUninstallResult:
120
+ tasks = selected_tasks(request.tasks, DEFAULT_STATUS_TASKS)
121
+ removed: list[Path] = []
122
+ for task in tasks:
123
+ job = self._job_for_task(
124
+ task,
125
+ base_request(request),
126
+ explicit_tasks=True,
127
+ resolve_working_directory=False,
128
+ )
129
+ if self.scheduler.uninstall(job):
130
+ removed.append(job.plist_path)
131
+ return AutomationUninstallResult(tasks=tasks, removed=tuple(removed))
132
+
133
+ def status(self, request: AutomationStatusRequest) -> AutomationStatusReport:
134
+ tasks = selected_tasks(request.tasks, DEFAULT_STATUS_TASKS)
135
+ statuses: list[ScheduledJobStatus] = []
136
+ for task in tasks:
137
+ job = self._job_for_task(
138
+ task,
139
+ base_request(request),
140
+ explicit_tasks=True,
141
+ resolve_working_directory=False,
142
+ )
143
+ statuses.append(self.scheduler.status(job))
144
+ return AutomationStatusReport(statuses=tuple(statuses))
145
+
146
+ def _job_for_task(
147
+ self,
148
+ task: AutomationTask,
149
+ request: InstallAutomationRequest,
150
+ explicit_tasks: bool,
151
+ resolve_working_directory: bool,
152
+ ) -> ScheduledJob:
153
+ definition = task_definition(task)
154
+ home = normalize_path(request.home or home_dir())
155
+ logs_dir = state_dir_for(home) / "logs"
156
+ return ScheduledJob(
157
+ task=task,
158
+ label=definition.label,
159
+ plist_path=plist_path_for(task, home),
160
+ program_arguments=program_arguments_for(task, request),
161
+ interval=interval_for(task, request, explicit_tasks),
162
+ environment=(
163
+ EnvironmentVariable(
164
+ name="PATH",
165
+ value=launch_path(home, request.env_path),
166
+ ),
167
+ ),
168
+ stdout_path=logs_dir / definition.stdout_log_name,
169
+ stderr_path=logs_dir / definition.stderr_log_name,
170
+ working_directory=self._working_directory(definition, request.cwd)
171
+ if resolve_working_directory
172
+ else None,
173
+ )
174
+
175
+ def _working_directory(
176
+ self,
177
+ definition: AutomationTaskDefinition,
178
+ cwd: Path,
179
+ ) -> Path | None:
180
+ if definition.working_directory == AutomationWorkingDirectory.NONE:
181
+ return None
182
+ return self.workspaces.resolve(cwd).root_path
183
+
184
+
185
+ def base_request(
186
+ request: AutomationSelectionRequest,
187
+ ) -> InstallAutomationRequest:
188
+ return InstallAutomationRequest(
189
+ cwd=Path.cwd(),
190
+ tasks=request.tasks,
191
+ home=request.home,
192
+ )
193
+
194
+
195
+ def task_definition(task: AutomationTask) -> AutomationTaskDefinition:
196
+ if task == AutomationTask.SYNC:
197
+ return AutomationTaskDefinition(
198
+ task=AutomationTask.SYNC,
199
+ label=SYNC_LABEL,
200
+ default_interval=DEFAULT_SYNC_INTERVAL,
201
+ stdout_log_name="sync.out.log",
202
+ stderr_log_name="sync.err.log",
203
+ working_directory=AutomationWorkingDirectory.NONE,
204
+ )
205
+ return AutomationTaskDefinition(
206
+ task=AutomationTask.GARDEN,
207
+ label=GARDEN_LABEL,
208
+ default_interval=DEFAULT_GARDEN_INTERVAL,
209
+ stdout_log_name="garden.out.log",
210
+ stderr_log_name="garden.err.log",
211
+ working_directory=AutomationWorkingDirectory.CURRENT_WIKI,
212
+ )
213
+
214
+
215
+ def selected_tasks(
216
+ requested: Sequence[AutomationTask],
217
+ defaults: tuple[AutomationTask, ...],
218
+ ) -> tuple[AutomationTask, ...]:
219
+ tasks = tuple(requested) if len(requested) > 0 else defaults
220
+ selected: list[AutomationTask] = []
221
+ for task in tasks:
222
+ if task not in selected:
223
+ selected.append(task)
224
+ return tuple(selected)
225
+
226
+
227
+ def interval_for(
228
+ task: AutomationTask,
229
+ request: InstallAutomationRequest,
230
+ explicit_tasks: bool,
231
+ ) -> timedelta:
232
+ if task == AutomationTask.SYNC:
233
+ return request.every or DEFAULT_SYNC_INTERVAL
234
+ if request.garden_every is not None:
235
+ return request.garden_every
236
+ if explicit_tasks and request.every is not None:
237
+ return request.every
238
+ return DEFAULT_GARDEN_INTERVAL
239
+
240
+
241
+ def program_arguments_for(
242
+ task: AutomationTask,
243
+ request: InstallAutomationRequest,
244
+ ) -> tuple[str, ...]:
245
+ executable = request.python_executable or Path(sys.executable)
246
+ base = (str(executable), "-m", "codealmanac.cli.main")
247
+ if task == AutomationTask.SYNC:
248
+ quiet = request.quiet or DEFAULT_SYNC_QUIET
249
+ return (
250
+ *base,
251
+ "sync",
252
+ "--quiet",
253
+ duration_text(quiet),
254
+ "--claim-owner",
255
+ AUTOMATION_SYNC_CLAIM_OWNER,
256
+ "--pending-timeout",
257
+ duration_text(AUTOMATION_SYNC_PENDING_TIMEOUT),
258
+ "--max-failed-attempts",
259
+ str(AUTOMATION_SYNC_MAX_FAILED_ATTEMPTS),
260
+ )
261
+ return (*base, "garden")
262
+
263
+
264
+ def plist_path_for(task: AutomationTask, home: Path) -> Path:
265
+ definition = task_definition(task)
266
+ return home / "Library/LaunchAgents" / f"{definition.label}.plist"
267
+
268
+
269
+ def launch_path(home: Path, env_path: str | None) -> str:
270
+ values = [
271
+ item.strip()
272
+ for item in (env_path or os.environ.get("PATH", "")).split(":")
273
+ if item.strip()
274
+ ]
275
+ values.extend([str(home / ".local/bin"), str(home / ".bun/bin")])
276
+ values.extend(LAUNCHD_FALLBACK_PATHS)
277
+ return ":".join(unique(values))
278
+
279
+
280
+ def unique(values: Sequence[str]) -> tuple[str, ...]:
281
+ seen: list[str] = []
282
+ for value in values:
283
+ if value not in seen:
284
+ seen.append(value)
285
+ return tuple(seen)
286
+
287
+
288
+ def duration_text(value: timedelta) -> str:
289
+ seconds = int(value.total_seconds())
290
+ if seconds % 3600 == 0:
291
+ return f"{seconds // 3600}h"
292
+ if seconds % 60 == 0:
293
+ return f"{seconds // 60}m"
294
+ return f"{seconds}s"
@@ -0,0 +1,17 @@
1
+ from codealmanac.services.config.models import (
2
+ CodeAlmanacConfig,
3
+ HarnessConfig,
4
+ SyncConfig,
5
+ )
6
+ from codealmanac.services.config.requests import LoadConfigRequest
7
+ from codealmanac.services.config.service import ConfigService
8
+ from codealmanac.services.config.store import ConfigStore
9
+
10
+ __all__ = [
11
+ "CodeAlmanacConfig",
12
+ "ConfigService",
13
+ "ConfigStore",
14
+ "HarnessConfig",
15
+ "LoadConfigRequest",
16
+ "SyncConfig",
17
+ ]
@@ -0,0 +1,61 @@
1
+ from datetime import timedelta
2
+ from typing import Any
3
+
4
+ from humanfriendly import InvalidTimespan, parse_timespan
5
+ from pydantic import Field, field_validator
6
+ from pydantic_settings import BaseSettings, SettingsConfigDict
7
+
8
+ from codealmanac.core.models import CodeAlmanacModel
9
+ from codealmanac.services.harnesses.models import HarnessKind
10
+
11
+ DEFAULT_HARNESS = HarnessKind.CLAUDE
12
+ DEFAULT_SYNC_QUIET = timedelta(minutes=45)
13
+
14
+
15
+ class HarnessConfig(CodeAlmanacModel):
16
+ default: HarnessKind = DEFAULT_HARNESS
17
+
18
+
19
+ class SyncConfig(CodeAlmanacModel):
20
+ quiet: timedelta = DEFAULT_SYNC_QUIET
21
+
22
+ @field_validator("quiet", mode="before")
23
+ @classmethod
24
+ def parse_quiet(cls, value: Any) -> Any:
25
+ return parse_duration(value, "sync.quiet")
26
+
27
+ @field_validator("quiet")
28
+ @classmethod
29
+ def require_non_negative_quiet(cls, value: timedelta) -> timedelta:
30
+ if value.total_seconds() < 0:
31
+ raise ValueError("sync.quiet must be zero or greater")
32
+ return value
33
+
34
+
35
+ class CodeAlmanacConfig(BaseSettings):
36
+ model_config = SettingsConfigDict(frozen=True, extra="forbid")
37
+
38
+ harness: HarnessConfig = Field(default_factory=HarnessConfig)
39
+ sync: SyncConfig = Field(default_factory=SyncConfig)
40
+
41
+ @classmethod
42
+ def settings_customise_sources(
43
+ cls,
44
+ settings_cls,
45
+ init_settings,
46
+ env_settings,
47
+ dotenv_settings,
48
+ file_secret_settings,
49
+ ):
50
+ return (init_settings,)
51
+
52
+
53
+ def parse_duration(value: Any, label: str) -> Any:
54
+ if value is None or isinstance(value, timedelta):
55
+ return value
56
+ if not isinstance(value, str):
57
+ return value
58
+ try:
59
+ return timedelta(seconds=parse_timespan(value))
60
+ except InvalidTimespan as error:
61
+ raise ValueError(f"{label} must be a duration") from error
@@ -0,0 +1,21 @@
1
+ from pathlib import Path
2
+
3
+ from pydantic import Field, field_validator
4
+
5
+ from codealmanac.core.models import CodeAlmanacModel
6
+ from codealmanac.core.text import required_text
7
+
8
+
9
+ class LoadConfigRequest(CodeAlmanacModel):
10
+ cwd: Path
11
+ wiki: str | None = Field(
12
+ default=None,
13
+ description="None means use the nearest project config.",
14
+ )
15
+
16
+ @field_validator("wiki")
17
+ @classmethod
18
+ def require_wiki(cls, value: str | None) -> str | None:
19
+ if value is None:
20
+ return None
21
+ return required_text(value, "wiki selector")
@@ -0,0 +1,55 @@
1
+ from pathlib import Path
2
+
3
+ from codealmanac.core.errors import NotFoundError
4
+ from codealmanac.core.paths import normalize_path
5
+ from codealmanac.services.config.models import CodeAlmanacConfig
6
+ from codealmanac.services.config.requests import LoadConfigRequest
7
+ from codealmanac.services.config.store import ConfigStore
8
+ from codealmanac.services.workspaces.requests import SelectWorkspaceRequest
9
+ from codealmanac.services.workspaces.service import WorkspacesService
10
+
11
+ PROJECT_CONFIG_NAME = "config.toml"
12
+
13
+
14
+ class ConfigService:
15
+ def __init__(
16
+ self,
17
+ workspaces: WorkspacesService,
18
+ store: ConfigStore,
19
+ user_config_path: Path,
20
+ ):
21
+ self.workspaces = workspaces
22
+ self.store = store
23
+ self.user_config_path = user_config_path
24
+
25
+ def load(self, request: LoadConfigRequest) -> CodeAlmanacConfig:
26
+ project_config_path = self._project_config_path(request)
27
+ paths = config_source_paths(
28
+ user_config_path=normalize_path(self.user_config_path),
29
+ project_config_path=project_config_path,
30
+ )
31
+ return self.store.load(paths)
32
+
33
+ def _project_config_path(self, request: LoadConfigRequest) -> Path | None:
34
+ if request.wiki is not None:
35
+ workspace = self.workspaces.select(
36
+ SelectWorkspaceRequest(
37
+ selector=request.wiki,
38
+ base_path=request.cwd,
39
+ )
40
+ )
41
+ return workspace.almanac_path / PROJECT_CONFIG_NAME
42
+ try:
43
+ workspace = self.workspaces.resolve(request.cwd)
44
+ except (NotFoundError, OSError):
45
+ return None
46
+ return workspace.almanac_path / PROJECT_CONFIG_NAME
47
+
48
+
49
+ def config_source_paths(
50
+ user_config_path: Path,
51
+ project_config_path: Path | None,
52
+ ) -> tuple[Path, ...]:
53
+ if project_config_path is None:
54
+ return (user_config_path,)
55
+ return (project_config_path, user_config_path)
@@ -0,0 +1,26 @@
1
+ from pathlib import Path
2
+ from tomllib import TOMLDecodeError
3
+
4
+ from pydantic import ValidationError
5
+ from pydantic_settings import TomlConfigSettingsSource
6
+
7
+ from codealmanac.core.errors import ValidationFailed
8
+ from codealmanac.services.config.models import CodeAlmanacConfig
9
+
10
+
11
+ class ConfigStore:
12
+ def load(self, paths: tuple[Path, ...]) -> CodeAlmanacConfig:
13
+ try:
14
+ sources = tuple(
15
+ TomlConfigSettingsSource(
16
+ CodeAlmanacConfig,
17
+ toml_file=path,
18
+ deep_merge=True,
19
+ )
20
+ for path in paths
21
+ )
22
+ return CodeAlmanacConfig(_build_sources=(sources, {}))
23
+ except TOMLDecodeError as error:
24
+ raise ValidationFailed(f"invalid config TOML: {error}") from error
25
+ except ValidationError as error:
26
+ raise ValidationFailed(f"invalid config: {error}") from error
@@ -0,0 +1 @@
1
+ """Local diagnostics service."""
@@ -0,0 +1,22 @@
1
+ from enum import StrEnum
2
+
3
+ from codealmanac.core.models import CodeAlmanacModel
4
+
5
+
6
+ class DoctorStatus(StrEnum):
7
+ OK = "ok"
8
+ INFO = "info"
9
+ PROBLEM = "problem"
10
+
11
+
12
+ class DoctorCheck(CodeAlmanacModel):
13
+ key: str
14
+ status: DoctorStatus
15
+ message: str
16
+ fix: str | None = None
17
+
18
+
19
+ class DoctorReport(CodeAlmanacModel):
20
+ version: str
21
+ install: tuple[DoctorCheck, ...]
22
+ wiki: tuple[DoctorCheck, ...]
@@ -0,0 +1,8 @@
1
+ from pathlib import Path
2
+
3
+ from codealmanac.core.models import CodeAlmanacModel
4
+
5
+
6
+ class DoctorRequest(CodeAlmanacModel):
7
+ cwd: Path
8
+ wiki: str | None = None