erk 0.4.5__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 (331) hide show
  1. erk/__init__.py +12 -0
  2. erk/__main__.py +6 -0
  3. erk/agent_docs/__init__.py +5 -0
  4. erk/agent_docs/models.py +123 -0
  5. erk/agent_docs/operations.py +666 -0
  6. erk/artifacts/__init__.py +5 -0
  7. erk/artifacts/artifact_health.py +623 -0
  8. erk/artifacts/detection.py +16 -0
  9. erk/artifacts/discovery.py +343 -0
  10. erk/artifacts/models.py +63 -0
  11. erk/artifacts/staleness.py +56 -0
  12. erk/artifacts/state.py +100 -0
  13. erk/artifacts/sync.py +624 -0
  14. erk/cli/__init__.py +0 -0
  15. erk/cli/activation.py +132 -0
  16. erk/cli/alias.py +53 -0
  17. erk/cli/cli.py +221 -0
  18. erk/cli/commands/__init__.py +0 -0
  19. erk/cli/commands/admin.py +153 -0
  20. erk/cli/commands/artifact/__init__.py +1 -0
  21. erk/cli/commands/artifact/check.py +260 -0
  22. erk/cli/commands/artifact/group.py +31 -0
  23. erk/cli/commands/artifact/list_cmd.py +89 -0
  24. erk/cli/commands/artifact/show.py +62 -0
  25. erk/cli/commands/artifact/sync_cmd.py +39 -0
  26. erk/cli/commands/branch/__init__.py +26 -0
  27. erk/cli/commands/branch/assign_cmd.py +152 -0
  28. erk/cli/commands/branch/checkout_cmd.py +357 -0
  29. erk/cli/commands/branch/create_cmd.py +161 -0
  30. erk/cli/commands/branch/list_cmd.py +82 -0
  31. erk/cli/commands/branch/unassign_cmd.py +197 -0
  32. erk/cli/commands/cc/__init__.py +15 -0
  33. erk/cli/commands/cc/jsonl_cmd.py +20 -0
  34. erk/cli/commands/cc/session/AGENTS.md +30 -0
  35. erk/cli/commands/cc/session/CLAUDE.md +1 -0
  36. erk/cli/commands/cc/session/__init__.py +15 -0
  37. erk/cli/commands/cc/session/list_cmd.py +167 -0
  38. erk/cli/commands/cc/session/show_cmd.py +175 -0
  39. erk/cli/commands/completion.py +89 -0
  40. erk/cli/commands/completions.py +165 -0
  41. erk/cli/commands/config.py +327 -0
  42. erk/cli/commands/docs/__init__.py +1 -0
  43. erk/cli/commands/docs/group.py +16 -0
  44. erk/cli/commands/docs/sync.py +121 -0
  45. erk/cli/commands/docs/validate.py +102 -0
  46. erk/cli/commands/doctor.py +243 -0
  47. erk/cli/commands/down.py +171 -0
  48. erk/cli/commands/exec/__init__.py +1 -0
  49. erk/cli/commands/exec/group.py +164 -0
  50. erk/cli/commands/exec/scripts/AGENTS.md +79 -0
  51. erk/cli/commands/exec/scripts/CLAUDE.md +1 -0
  52. erk/cli/commands/exec/scripts/__init__.py +5 -0
  53. erk/cli/commands/exec/scripts/add_reaction_to_comment.py +69 -0
  54. erk/cli/commands/exec/scripts/add_remote_execution_note.py +68 -0
  55. erk/cli/commands/exec/scripts/check_impl.py +152 -0
  56. erk/cli/commands/exec/scripts/ci_update_pr_body.py +294 -0
  57. erk/cli/commands/exec/scripts/create_extraction_branch.py +138 -0
  58. erk/cli/commands/exec/scripts/create_extraction_plan.py +242 -0
  59. erk/cli/commands/exec/scripts/create_issue_from_session.py +103 -0
  60. erk/cli/commands/exec/scripts/create_plan_from_context.py +103 -0
  61. erk/cli/commands/exec/scripts/create_worker_impl_from_issue.py +93 -0
  62. erk/cli/commands/exec/scripts/detect_trunk_branch.py +121 -0
  63. erk/cli/commands/exec/scripts/exit_plan_mode_hook.py +777 -0
  64. erk/cli/commands/exec/scripts/extract_latest_plan.py +49 -0
  65. erk/cli/commands/exec/scripts/extract_session_from_issue.py +150 -0
  66. erk/cli/commands/exec/scripts/find_project_dir.py +214 -0
  67. erk/cli/commands/exec/scripts/generate_pr_summary.py +112 -0
  68. erk/cli/commands/exec/scripts/get_closing_text.py +98 -0
  69. erk/cli/commands/exec/scripts/get_embedded_prompt.py +62 -0
  70. erk/cli/commands/exec/scripts/get_plan_metadata.py +95 -0
  71. erk/cli/commands/exec/scripts/get_pr_body_footer.py +70 -0
  72. erk/cli/commands/exec/scripts/get_pr_discussion_comments.py +149 -0
  73. erk/cli/commands/exec/scripts/get_pr_review_comments.py +155 -0
  74. erk/cli/commands/exec/scripts/impl_init.py +158 -0
  75. erk/cli/commands/exec/scripts/impl_signal.py +375 -0
  76. erk/cli/commands/exec/scripts/impl_verify.py +49 -0
  77. erk/cli/commands/exec/scripts/issue_title_to_filename.py +34 -0
  78. erk/cli/commands/exec/scripts/list_sessions.py +296 -0
  79. erk/cli/commands/exec/scripts/mark_impl_ended.py +188 -0
  80. erk/cli/commands/exec/scripts/mark_impl_started.py +188 -0
  81. erk/cli/commands/exec/scripts/marker.py +163 -0
  82. erk/cli/commands/exec/scripts/objective_save_to_issue.py +109 -0
  83. erk/cli/commands/exec/scripts/plan_save_to_issue.py +269 -0
  84. erk/cli/commands/exec/scripts/plan_update_issue.py +147 -0
  85. erk/cli/commands/exec/scripts/post_extraction_comment.py +237 -0
  86. erk/cli/commands/exec/scripts/post_or_update_pr_summary.py +133 -0
  87. erk/cli/commands/exec/scripts/post_pr_inline_comment.py +143 -0
  88. erk/cli/commands/exec/scripts/post_workflow_started_comment.py +168 -0
  89. erk/cli/commands/exec/scripts/preprocess_session.py +777 -0
  90. erk/cli/commands/exec/scripts/quick_submit.py +32 -0
  91. erk/cli/commands/exec/scripts/rebase_with_conflict_resolution.py +260 -0
  92. erk/cli/commands/exec/scripts/reply_to_discussion_comment.py +173 -0
  93. erk/cli/commands/exec/scripts/resolve_review_thread.py +170 -0
  94. erk/cli/commands/exec/scripts/session_id_injector_hook.py +52 -0
  95. erk/cli/commands/exec/scripts/setup_impl_from_issue.py +159 -0
  96. erk/cli/commands/exec/scripts/slot_objective.py +102 -0
  97. erk/cli/commands/exec/scripts/tripwires_reminder_hook.py +20 -0
  98. erk/cli/commands/exec/scripts/update_dispatch_info.py +116 -0
  99. erk/cli/commands/exec/scripts/user_prompt_hook.py +113 -0
  100. erk/cli/commands/exec/scripts/validate_plan_content.py +98 -0
  101. erk/cli/commands/exec/scripts/wrap_plan_in_metadata_block.py +34 -0
  102. erk/cli/commands/implement.py +695 -0
  103. erk/cli/commands/implement_shared.py +649 -0
  104. erk/cli/commands/info/__init__.py +14 -0
  105. erk/cli/commands/info/release_notes_cmd.py +128 -0
  106. erk/cli/commands/init.py +801 -0
  107. erk/cli/commands/land_cmd.py +690 -0
  108. erk/cli/commands/log_cmd.py +137 -0
  109. erk/cli/commands/md/__init__.py +5 -0
  110. erk/cli/commands/md/check.py +118 -0
  111. erk/cli/commands/md/group.py +14 -0
  112. erk/cli/commands/navigation_helpers.py +430 -0
  113. erk/cli/commands/objective/__init__.py +16 -0
  114. erk/cli/commands/objective/list_cmd.py +47 -0
  115. erk/cli/commands/objective_helpers.py +132 -0
  116. erk/cli/commands/plan/__init__.py +32 -0
  117. erk/cli/commands/plan/check_cmd.py +174 -0
  118. erk/cli/commands/plan/close_cmd.py +69 -0
  119. erk/cli/commands/plan/create_cmd.py +120 -0
  120. erk/cli/commands/plan/docs/__init__.py +18 -0
  121. erk/cli/commands/plan/docs/extract_cmd.py +53 -0
  122. erk/cli/commands/plan/docs/unextract_cmd.py +38 -0
  123. erk/cli/commands/plan/docs/unextracted_cmd.py +72 -0
  124. erk/cli/commands/plan/extraction/__init__.py +16 -0
  125. erk/cli/commands/plan/extraction/complete_cmd.py +101 -0
  126. erk/cli/commands/plan/extraction/create_raw_cmd.py +63 -0
  127. erk/cli/commands/plan/get.py +71 -0
  128. erk/cli/commands/plan/list_cmd.py +754 -0
  129. erk/cli/commands/plan/log_cmd.py +440 -0
  130. erk/cli/commands/plan/start_cmd.py +459 -0
  131. erk/cli/commands/planner/__init__.py +40 -0
  132. erk/cli/commands/planner/configure_cmd.py +73 -0
  133. erk/cli/commands/planner/connect_cmd.py +96 -0
  134. erk/cli/commands/planner/create_cmd.py +148 -0
  135. erk/cli/commands/planner/list_cmd.py +51 -0
  136. erk/cli/commands/planner/register_cmd.py +105 -0
  137. erk/cli/commands/planner/set_default_cmd.py +23 -0
  138. erk/cli/commands/planner/unregister_cmd.py +43 -0
  139. erk/cli/commands/pr/__init__.py +23 -0
  140. erk/cli/commands/pr/check_cmd.py +112 -0
  141. erk/cli/commands/pr/checkout_cmd.py +165 -0
  142. erk/cli/commands/pr/fix_conflicts_cmd.py +82 -0
  143. erk/cli/commands/pr/parse_pr_reference.py +10 -0
  144. erk/cli/commands/pr/submit_cmd.py +360 -0
  145. erk/cli/commands/pr/sync_cmd.py +181 -0
  146. erk/cli/commands/prepare_cwd_recovery.py +60 -0
  147. erk/cli/commands/project/__init__.py +16 -0
  148. erk/cli/commands/project/init_cmd.py +91 -0
  149. erk/cli/commands/run/__init__.py +17 -0
  150. erk/cli/commands/run/list_cmd.py +189 -0
  151. erk/cli/commands/run/logs_cmd.py +54 -0
  152. erk/cli/commands/run/shared.py +19 -0
  153. erk/cli/commands/shell_integration.py +29 -0
  154. erk/cli/commands/slot/__init__.py +23 -0
  155. erk/cli/commands/slot/check_cmd.py +277 -0
  156. erk/cli/commands/slot/common.py +314 -0
  157. erk/cli/commands/slot/init_pool_cmd.py +157 -0
  158. erk/cli/commands/slot/list_cmd.py +228 -0
  159. erk/cli/commands/slot/repair_cmd.py +190 -0
  160. erk/cli/commands/stack/__init__.py +23 -0
  161. erk/cli/commands/stack/consolidate_cmd.py +470 -0
  162. erk/cli/commands/stack/list_cmd.py +79 -0
  163. erk/cli/commands/stack/move_cmd.py +309 -0
  164. erk/cli/commands/stack/split_old/README.md +64 -0
  165. erk/cli/commands/stack/split_old/__init__.py +5 -0
  166. erk/cli/commands/stack/split_old/command.py +233 -0
  167. erk/cli/commands/stack/split_old/display.py +116 -0
  168. erk/cli/commands/stack/split_old/plan.py +216 -0
  169. erk/cli/commands/status.py +58 -0
  170. erk/cli/commands/submit.py +768 -0
  171. erk/cli/commands/up.py +154 -0
  172. erk/cli/commands/upgrade.py +82 -0
  173. erk/cli/commands/wt/__init__.py +29 -0
  174. erk/cli/commands/wt/checkout_cmd.py +110 -0
  175. erk/cli/commands/wt/create_cmd.py +998 -0
  176. erk/cli/commands/wt/current_cmd.py +35 -0
  177. erk/cli/commands/wt/delete_cmd.py +573 -0
  178. erk/cli/commands/wt/list_cmd.py +332 -0
  179. erk/cli/commands/wt/rename_cmd.py +66 -0
  180. erk/cli/config.py +242 -0
  181. erk/cli/constants.py +29 -0
  182. erk/cli/core.py +65 -0
  183. erk/cli/debug.py +9 -0
  184. erk/cli/ensure-conversion-tasks.md +288 -0
  185. erk/cli/ensure.py +628 -0
  186. erk/cli/github_parsing.py +96 -0
  187. erk/cli/graphite.py +81 -0
  188. erk/cli/graphite_command.py +80 -0
  189. erk/cli/help_formatter.py +345 -0
  190. erk/cli/output.py +361 -0
  191. erk/cli/presets/dagster.toml +12 -0
  192. erk/cli/presets/generic.toml +12 -0
  193. erk/cli/prompt_hooks_templates/README.md +68 -0
  194. erk/cli/script_output.py +32 -0
  195. erk/cli/shell_integration/bash_wrapper.sh +32 -0
  196. erk/cli/shell_integration/fish_wrapper.fish +39 -0
  197. erk/cli/shell_integration/handler.py +338 -0
  198. erk/cli/shell_integration/zsh_wrapper.sh +32 -0
  199. erk/cli/shell_utils.py +171 -0
  200. erk/cli/subprocess_utils.py +92 -0
  201. erk/cli/uvx_detection.py +59 -0
  202. erk/core/__init__.py +0 -0
  203. erk/core/claude_executor.py +511 -0
  204. erk/core/claude_settings.py +317 -0
  205. erk/core/command_log.py +406 -0
  206. erk/core/commit_message_generator.py +234 -0
  207. erk/core/completion.py +10 -0
  208. erk/core/consolidation_utils.py +177 -0
  209. erk/core/context.py +570 -0
  210. erk/core/display/__init__.py +4 -0
  211. erk/core/display/abc.py +24 -0
  212. erk/core/display/real.py +30 -0
  213. erk/core/display_utils.py +526 -0
  214. erk/core/file_utils.py +87 -0
  215. erk/core/health_checks.py +1315 -0
  216. erk/core/health_checks_dogfooder/__init__.py +85 -0
  217. erk/core/health_checks_dogfooder/deprecated_dot_agent_config.py +64 -0
  218. erk/core/health_checks_dogfooder/legacy_claude_docs.py +69 -0
  219. erk/core/health_checks_dogfooder/legacy_config_locations.py +122 -0
  220. erk/core/health_checks_dogfooder/legacy_erk_docs_agent.py +61 -0
  221. erk/core/health_checks_dogfooder/legacy_erk_kits_folder.py +60 -0
  222. erk/core/health_checks_dogfooder/legacy_hook_settings.py +104 -0
  223. erk/core/health_checks_dogfooder/legacy_kit_yaml.py +78 -0
  224. erk/core/health_checks_dogfooder/legacy_kits_toml.py +43 -0
  225. erk/core/health_checks_dogfooder/outdated_erk_skill.py +43 -0
  226. erk/core/implementation_queue/__init__.py +1 -0
  227. erk/core/implementation_queue/github/__init__.py +8 -0
  228. erk/core/implementation_queue/github/abc.py +7 -0
  229. erk/core/implementation_queue/github/noop.py +38 -0
  230. erk/core/implementation_queue/github/printing.py +43 -0
  231. erk/core/implementation_queue/github/real.py +119 -0
  232. erk/core/init_utils.py +227 -0
  233. erk/core/output_filter.py +338 -0
  234. erk/core/plan_store/__init__.py +6 -0
  235. erk/core/planner/__init__.py +1 -0
  236. erk/core/planner/registry_abc.py +8 -0
  237. erk/core/planner/registry_fake.py +129 -0
  238. erk/core/planner/registry_real.py +195 -0
  239. erk/core/planner/types.py +7 -0
  240. erk/core/pr_utils.py +30 -0
  241. erk/core/release_notes.py +263 -0
  242. erk/core/repo_discovery.py +126 -0
  243. erk/core/script_writer.py +41 -0
  244. erk/core/services/__init__.py +1 -0
  245. erk/core/services/plan_list_service.py +94 -0
  246. erk/core/shell.py +51 -0
  247. erk/core/user_feedback.py +11 -0
  248. erk/core/version_check.py +55 -0
  249. erk/core/workflow_display.py +75 -0
  250. erk/core/worktree_pool.py +190 -0
  251. erk/core/worktree_utils.py +300 -0
  252. erk/data/CHANGELOG.md +438 -0
  253. erk/data/__init__.py +1 -0
  254. erk/data/claude/agents/devrun.md +180 -0
  255. erk/data/claude/commands/erk/__init__.py +0 -0
  256. erk/data/claude/commands/erk/create-extraction-plan.md +360 -0
  257. erk/data/claude/commands/erk/fix-conflicts.md +25 -0
  258. erk/data/claude/commands/erk/git-pr-push.md +345 -0
  259. erk/data/claude/commands/erk/implement-stacked-plan.md +96 -0
  260. erk/data/claude/commands/erk/land.md +193 -0
  261. erk/data/claude/commands/erk/objective-create.md +370 -0
  262. erk/data/claude/commands/erk/objective-list.md +34 -0
  263. erk/data/claude/commands/erk/objective-next-plan.md +220 -0
  264. erk/data/claude/commands/erk/objective-update-with-landed-pr.md +216 -0
  265. erk/data/claude/commands/erk/plan-implement.md +202 -0
  266. erk/data/claude/commands/erk/plan-save.md +45 -0
  267. erk/data/claude/commands/erk/plan-submit.md +39 -0
  268. erk/data/claude/commands/erk/pr-address.md +367 -0
  269. erk/data/claude/commands/erk/pr-submit.md +58 -0
  270. erk/data/claude/skills/dignified-python/SKILL.md +48 -0
  271. erk/data/claude/skills/dignified-python/cli-patterns.md +155 -0
  272. erk/data/claude/skills/dignified-python/dignified-python-core.md +1190 -0
  273. erk/data/claude/skills/dignified-python/subprocess.md +99 -0
  274. erk/data/claude/skills/dignified-python/versions/python-3.10.md +517 -0
  275. erk/data/claude/skills/dignified-python/versions/python-3.11.md +536 -0
  276. erk/data/claude/skills/dignified-python/versions/python-3.12.md +662 -0
  277. erk/data/claude/skills/dignified-python/versions/python-3.13.md +653 -0
  278. erk/data/claude/skills/erk-diff-analysis/SKILL.md +27 -0
  279. erk/data/claude/skills/erk-diff-analysis/references/commit-message-prompt.md +78 -0
  280. erk/data/claude/skills/learned-docs/SKILL.md +362 -0
  281. erk/data/github/actions/setup-claude-erk/action.yml +11 -0
  282. erk/data/github/prompts/dignified-python-review.md +125 -0
  283. erk/data/github/workflows/dignified-python-review.yml +61 -0
  284. erk/data/github/workflows/erk-impl.yml +251 -0
  285. erk/hooks/__init__.py +1 -0
  286. erk/hooks/decorators.py +319 -0
  287. erk/status/__init__.py +8 -0
  288. erk/status/collectors/__init__.py +9 -0
  289. erk/status/collectors/base.py +52 -0
  290. erk/status/collectors/git.py +76 -0
  291. erk/status/collectors/github.py +81 -0
  292. erk/status/collectors/graphite.py +80 -0
  293. erk/status/collectors/impl.py +145 -0
  294. erk/status/models/__init__.py +4 -0
  295. erk/status/models/status_data.py +404 -0
  296. erk/status/orchestrator.py +169 -0
  297. erk/status/renderers/__init__.py +5 -0
  298. erk/status/renderers/simple.py +322 -0
  299. erk/tui/AGENTS.md +193 -0
  300. erk/tui/CLAUDE.md +1 -0
  301. erk/tui/__init__.py +1 -0
  302. erk/tui/app.py +1404 -0
  303. erk/tui/commands/__init__.py +1 -0
  304. erk/tui/commands/executor.py +66 -0
  305. erk/tui/commands/provider.py +165 -0
  306. erk/tui/commands/real_executor.py +63 -0
  307. erk/tui/commands/registry.py +121 -0
  308. erk/tui/commands/types.py +36 -0
  309. erk/tui/data/__init__.py +1 -0
  310. erk/tui/data/provider.py +492 -0
  311. erk/tui/data/types.py +104 -0
  312. erk/tui/filtering/__init__.py +1 -0
  313. erk/tui/filtering/logic.py +43 -0
  314. erk/tui/filtering/types.py +55 -0
  315. erk/tui/jsonl_viewer/__init__.py +1 -0
  316. erk/tui/jsonl_viewer/app.py +61 -0
  317. erk/tui/jsonl_viewer/models.py +208 -0
  318. erk/tui/jsonl_viewer/widgets.py +204 -0
  319. erk/tui/sorting/__init__.py +6 -0
  320. erk/tui/sorting/logic.py +55 -0
  321. erk/tui/sorting/types.py +68 -0
  322. erk/tui/styles/dash.tcss +95 -0
  323. erk/tui/widgets/__init__.py +1 -0
  324. erk/tui/widgets/command_output.py +112 -0
  325. erk/tui/widgets/plan_table.py +276 -0
  326. erk/tui/widgets/status_bar.py +116 -0
  327. erk-0.4.5.dist-info/METADATA +376 -0
  328. erk-0.4.5.dist-info/RECORD +331 -0
  329. erk-0.4.5.dist-info/WHEEL +4 -0
  330. erk-0.4.5.dist-info/entry_points.txt +2 -0
  331. erk-0.4.5.dist-info/licenses/LICENSE.md +3 -0
erk/cli/ensure.py ADDED
@@ -0,0 +1,628 @@
1
+ """CLI error handling utilities with styled output.
2
+
3
+ This module provides the Ensure class for asserting invariants in CLI commands
4
+ with consistent, user-friendly error messages. All errors use red "Error:" prefix
5
+ for visual consistency.
6
+
7
+ Domain-Specific Methods:
8
+ - Git state validations (branch checks, worktree existence, clean state)
9
+ - Configuration validations (required fields, format checks)
10
+ - Argument validations (count, type, range)
11
+ - File/path validations (readable, writable, not hidden)
12
+ - String/collection validations (non-empty, non-null)
13
+ - External tool validations (gh CLI installed)
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import shutil
19
+ from pathlib import Path
20
+ from typing import TYPE_CHECKING, Any, TypeVar
21
+
22
+ import click
23
+
24
+ from erk_shared.gateway.graphite.disabled import GraphiteDisabled, GraphiteDisabledError
25
+ from erk_shared.github.types import PRDetails
26
+ from erk_shared.non_ideal_state import (
27
+ BranchDetectionFailed,
28
+ GitHubAPIFailed,
29
+ NonIdealState,
30
+ NoPRForBranch,
31
+ PRNotFoundError,
32
+ SessionNotFound,
33
+ )
34
+ from erk_shared.output.output import user_output
35
+
36
+ if TYPE_CHECKING:
37
+ from erk.core.context import ErkContext
38
+
39
+ T = TypeVar("T")
40
+
41
+
42
+ class Ensure:
43
+ """Helper class for asserting invariants with consistent error handling."""
44
+
45
+ @staticmethod
46
+ def invariant(condition: bool, error_message: str) -> None:
47
+ """Ensure condition is true, otherwise output styled error and exit.
48
+
49
+ Args:
50
+ condition: Boolean condition to check
51
+ error_message: Error message to display if condition is false.
52
+ "Error: " prefix will be added automatically in red.
53
+
54
+ Raises:
55
+ SystemExit: If condition is false (with exit code 1)
56
+ """
57
+ if not condition:
58
+ user_output(click.style("Error: ", fg="red") + error_message)
59
+ raise SystemExit(1)
60
+
61
+ @staticmethod
62
+ def truthy(value: T, error_message: str) -> T:
63
+ """Ensure value is truthy, otherwise output styled error and exit.
64
+
65
+ Args:
66
+ value: Value to check for truthiness
67
+ error_message: Error message to display if value is falsy.
68
+ "Error: " prefix will be added automatically in red.
69
+
70
+ Returns:
71
+ The value unchanged if truthy
72
+
73
+ Raises:
74
+ SystemExit: If value is falsy (with exit code 1)
75
+ """
76
+ if not value:
77
+ user_output(click.style("Error: ", fg="red") + error_message)
78
+ raise SystemExit(1)
79
+ return value
80
+
81
+ @staticmethod
82
+ def not_none(value: T | None, error_message: str) -> T:
83
+ """Ensure value is not None, otherwise output styled error and exit.
84
+
85
+ This method provides type narrowing: it takes `T | None` and returns `T`,
86
+ allowing the type checker to understand the value cannot be None after
87
+ this call.
88
+
89
+ Args:
90
+ value: Value to check for None
91
+ error_message: Error message to display if value is None.
92
+ "Error: " prefix will be added automatically in red.
93
+
94
+ Returns:
95
+ The value unchanged if not None (with narrowed type T)
96
+
97
+ Raises:
98
+ SystemExit: If value is None (with exit code 1)
99
+
100
+ Example:
101
+ >>> # Type narrowing in action
102
+ >>> path: Path | None = get_worktree_path()
103
+ >>> safe_path: Path = Ensure.not_none(path, "Worktree path not found")
104
+ >>> # safe_path is now guaranteed to be Path, not Path | None
105
+ """
106
+ if value is None:
107
+ user_output(click.style("Error: ", fg="red") + error_message)
108
+ raise SystemExit(1)
109
+ return value
110
+
111
+ @staticmethod
112
+ def path_exists(
113
+ ctx: ErkContext,
114
+ path: Path,
115
+ error_message: str | None = None,
116
+ ) -> None:
117
+ """Ensure path exists, otherwise output styled error and exit.
118
+
119
+ This method is designed for validating git-managed paths (worktrees, repos).
120
+ It checks path existence before any operations that would fail on missing paths.
121
+
122
+ Supports both real filesystem paths and sentinel paths used in tests by using
123
+ ctx.git.path_exists, which works with both real paths and test sentinels.
124
+
125
+ Args:
126
+ ctx: Application context with git integration for path checking
127
+ path: Path to check for existence
128
+ error_message: Optional custom error message. If not provided,
129
+ uses default "Path not found: {path}".
130
+ "Error: " prefix will be added automatically in red.
131
+
132
+ Raises:
133
+ SystemExit: If path does not exist (with exit code 1)
134
+
135
+ Example:
136
+ >>> # Basic usage with default error message
137
+ >>> Ensure.path_exists(ctx, config_path)
138
+ >>>
139
+ >>> # With custom error message
140
+ >>> Ensure.path_exists(ctx, wt_path, f"Worktree not found: {wt_path}")
141
+ """
142
+ if not ctx.git.path_exists(path):
143
+ if error_message is None:
144
+ error_message = f"Path not found: {path}"
145
+ user_output(click.style("Error: ", fg="red") + error_message)
146
+ raise SystemExit(1)
147
+
148
+ @staticmethod
149
+ def not_empty(value: str | list | dict | None, error_message: str) -> None:
150
+ """Ensure value is not empty (non-empty string, list, dict), otherwise exit.
151
+
152
+ Args:
153
+ value: Value to check for emptiness
154
+ error_message: Error message to display if value is empty.
155
+ "Error: " prefix will be added automatically in red.
156
+
157
+ Raises:
158
+ SystemExit: If value is None, empty string, empty list, or empty dict
159
+
160
+ Example:
161
+ >>> Ensure.not_empty(name, "Worktree name cannot be empty")
162
+ >>> Ensure.not_empty(args, "No arguments provided - specify at least one branch")
163
+ """
164
+ if not value:
165
+ user_output(click.style("Error: ", fg="red") + error_message)
166
+ raise SystemExit(1)
167
+
168
+ @staticmethod
169
+ def git_worktree_exists(ctx: ErkContext, wt_path: Path, name: str | None = None) -> None:
170
+ """Ensure worktree exists at path, otherwise output styled error and exit.
171
+
172
+ Args:
173
+ ctx: Application context with git integration
174
+ wt_path: Path where worktree should exist
175
+ name: Optional worktree name for friendlier error message
176
+
177
+ Raises:
178
+ SystemExit: If worktree does not exist
179
+
180
+ Example:
181
+ >>> Ensure.git_worktree_exists(ctx, wt_path, "feature-123")
182
+ >>> Ensure.git_worktree_exists(ctx, wt_path) # Uses path in error
183
+ """
184
+ if name:
185
+ error_message = f"Worktree '{name}' does not exist"
186
+ else:
187
+ error_message = f"Worktree not found: {wt_path}"
188
+ Ensure.path_exists(ctx, wt_path, error_message)
189
+
190
+ @staticmethod
191
+ def git_branch_exists(ctx: ErkContext, repo_root: Path, branch: str) -> None:
192
+ """Ensure git branch exists, otherwise output styled error and exit.
193
+
194
+ Args:
195
+ ctx: Application context with git integration
196
+ repo_root: Repository root path
197
+ branch: Branch name to check
198
+
199
+ Raises:
200
+ SystemExit: If branch does not exist
201
+
202
+ Example:
203
+ >>> Ensure.git_branch_exists(ctx, repo.root, "feature-branch")
204
+ """
205
+ local_branches = ctx.git.list_local_branches(repo_root)
206
+ if branch not in local_branches:
207
+ user_output(
208
+ click.style("Error: ", fg="red")
209
+ + f"Branch '{branch}' does not exist - Create it first or check the name"
210
+ )
211
+ raise SystemExit(1)
212
+
213
+ @staticmethod
214
+ def in_git_worktree(ctx: ErkContext, current_path: Path | None) -> None:
215
+ """Ensure currently in a git worktree, otherwise output styled error and exit.
216
+
217
+ Args:
218
+ ctx: Application context (for error handling)
219
+ current_path: Path to check (typically ctx.cwd or result of get_worktree_path)
220
+
221
+ Raises:
222
+ SystemExit: If not in a git worktree
223
+
224
+ Example:
225
+ >>> current_wt = ctx.git.get_worktree_path(repo.root, ctx.cwd)
226
+ >>> Ensure.in_git_worktree(ctx, current_wt)
227
+ """
228
+ if current_path is None:
229
+ user_output(
230
+ click.style("Error: ", fg="red")
231
+ + "Not in a git worktree - Run this command from within a worktree directory"
232
+ )
233
+ raise SystemExit(1)
234
+
235
+ @staticmethod
236
+ def argument_count(
237
+ args: tuple[Any, ...] | list[Any],
238
+ expected: int,
239
+ error_message: str | None = None,
240
+ ) -> None:
241
+ """Ensure argument count matches expected, otherwise output styled error and exit.
242
+
243
+ Args:
244
+ args: Arguments tuple or list to check
245
+ expected: Expected number of arguments
246
+ error_message: Optional custom error message
247
+
248
+ Raises:
249
+ SystemExit: If argument count does not match expected
250
+
251
+ Example:
252
+ >>> Ensure.argument_count(args, 1, "Expected exactly 1 branch name")
253
+ >>> Ensure.argument_count(args, 0, "This command takes no arguments")
254
+ """
255
+ if len(args) != expected:
256
+ if error_message is None:
257
+ if expected == 0:
258
+ error_message = f"Expected no arguments, got {len(args)}"
259
+ elif expected == 1:
260
+ error_message = f"Expected 1 argument, got {len(args)}"
261
+ else:
262
+ error_message = f"Expected {expected} arguments, got {len(args)}"
263
+ user_output(click.style("Error: ", fg="red") + error_message)
264
+ raise SystemExit(1)
265
+
266
+ @staticmethod
267
+ def config_field_set(
268
+ config: Any,
269
+ field_name: str,
270
+ error_message: str | None = None,
271
+ ) -> None:
272
+ """Ensure configuration field is set, otherwise output styled error and exit.
273
+
274
+ Args:
275
+ config: Configuration object (must have __getattr__ or __getitem__)
276
+ field_name: Name of the field to check
277
+ error_message: Optional custom error message
278
+
279
+ Raises:
280
+ SystemExit: If field is not set (None or missing)
281
+
282
+ Example:
283
+ >>> Ensure.config_field_set(
284
+ ... ctx.local_config,
285
+ ... "github_token",
286
+ ... "GitHub token not configured - Run 'erk config set github_token <token>'"
287
+ ... )
288
+ """
289
+ try:
290
+ value = getattr(config, field_name, None)
291
+ except AttributeError:
292
+ try:
293
+ value = config[field_name] if hasattr(config, "__getitem__") else None
294
+ except (KeyError, TypeError):
295
+ value = None
296
+
297
+ if value is None:
298
+ if error_message is None:
299
+ error_message = (
300
+ f"Required configuration '{field_name}' not set - "
301
+ f"Run 'erk config set {field_name} <value>'"
302
+ )
303
+ user_output(click.style("Error: ", fg="red") + error_message)
304
+ raise SystemExit(1)
305
+
306
+ @staticmethod
307
+ def path_is_dir(ctx: ErkContext, path: Path, error_message: str | None = None) -> None:
308
+ """Ensure path exists and is a directory, otherwise output styled error and exit.
309
+
310
+ Args:
311
+ ctx: Application context with git integration
312
+ path: Path to check
313
+ error_message: Optional custom error message
314
+
315
+ Raises:
316
+ SystemExit: If path doesn't exist or is not a directory
317
+
318
+ Example:
319
+ >>> Ensure.path_is_dir(ctx, repo.worktrees_dir, "Worktrees directory not found")
320
+ """
321
+ Ensure.path_exists(ctx, path, error_message)
322
+ if not path.is_dir():
323
+ if error_message is None:
324
+ error_message = f"Path is not a directory: {path}"
325
+ user_output(click.style("Error: ", fg="red") + error_message)
326
+ raise SystemExit(1)
327
+
328
+ @staticmethod
329
+ def path_not_exists(ctx: ErkContext, path: Path, error_message: str) -> None:
330
+ """Ensure path does NOT exist, otherwise output styled error and exit.
331
+
332
+ Inverse of path_exists - used when creating new resources that must not collide.
333
+
334
+ Args:
335
+ ctx: Application context with git integration
336
+ path: Path to check should not exist
337
+ error_message: Error message to display if path exists
338
+
339
+ Raises:
340
+ SystemExit: If path already exists
341
+
342
+ Example:
343
+ >>> Ensure.path_not_exists(
344
+ ... ctx,
345
+ ... new_path,
346
+ ... f"Destination already exists: {new_path} - "
347
+ ... f"Choose a different name or delete the existing path"
348
+ ... )
349
+ """
350
+ if ctx.git.path_exists(path):
351
+ user_output(click.style("Error: ", fg="red") + error_message)
352
+ raise SystemExit(1)
353
+
354
+ @staticmethod
355
+ def gh_installed() -> None:
356
+ """Ensure GitHub CLI (gh) is installed and available on PATH.
357
+
358
+ Uses shutil.which to check for gh availability, which is the LBYL
359
+ approach to validating external tool availability before use.
360
+
361
+ Raises:
362
+ SystemExit: If gh CLI is not found on PATH
363
+
364
+ Example:
365
+ >>> Ensure.gh_installed()
366
+ >>> # Now safe to call gh commands
367
+ >>> pr_info = ctx.github.get_pr_checkout_info(repo.root, pr_number)
368
+ """
369
+ if shutil.which("gh") is None:
370
+ user_output(
371
+ click.style("Error: ", fg="red")
372
+ + "GitHub CLI (gh) is not installed\n\n"
373
+ + "Install it from: https://cli.github.com/\n"
374
+ + "Then authenticate with: gh auth login"
375
+ )
376
+ raise SystemExit(1)
377
+
378
+ @staticmethod
379
+ def gt_installed() -> None:
380
+ """Ensure Graphite CLI (gt) is installed and available on PATH.
381
+
382
+ Uses shutil.which to check for gt availability, which is the LBYL
383
+ approach to validating external tool availability before use.
384
+
385
+ Raises:
386
+ SystemExit: If gt CLI is not found on PATH
387
+
388
+ Example:
389
+ >>> Ensure.gt_installed()
390
+ >>> # Now safe to call gt commands
391
+ >>> ctx.graphite.submit_stack(repo.root)
392
+ """
393
+ if shutil.which("gt") is None:
394
+ user_output(
395
+ click.style("Error: ", fg="red")
396
+ + "Graphite CLI (gt) is not installed\n\n"
397
+ + "Install it from: https://withgraphite.com/docs/getting-started\n"
398
+ + "Or use: npm install -g @withgraphite/graphite-cli"
399
+ )
400
+ raise SystemExit(1)
401
+
402
+ @staticmethod
403
+ def graphite_available(ctx: ErkContext) -> None:
404
+ """Ensure Graphite integration is available (enabled and installed).
405
+
406
+ Checks if ctx.graphite is a GraphiteDisabled sentinel, and if so,
407
+ outputs a helpful error message based on why Graphite is unavailable
408
+ (config disabled vs not installed).
409
+
410
+ This is the LBYL check for commands that require Graphite functionality.
411
+
412
+ Args:
413
+ ctx: Application context with graphite integration
414
+
415
+ Raises:
416
+ SystemExit: If Graphite is disabled or not installed
417
+
418
+ Example:
419
+ >>> Ensure.graphite_available(ctx)
420
+ >>> # Now safe to use Graphite operations
421
+ >>> ctx.graphite.get_parent_branch(ctx.git, repo.root, branch)
422
+ """
423
+ if isinstance(ctx.graphite, GraphiteDisabled):
424
+ error = GraphiteDisabledError(ctx.graphite.reason)
425
+ user_output(click.style("Error: ", fg="red") + str(error))
426
+ raise SystemExit(1)
427
+
428
+ @staticmethod
429
+ def claude_installed() -> None:
430
+ """Ensure Claude CLI is installed and available on PATH.
431
+
432
+ Uses shutil.which to check for claude availability, which is the LBYL
433
+ approach to validating external tool availability before use.
434
+
435
+ Raises:
436
+ SystemExit: If claude CLI is not found on PATH
437
+
438
+ Example:
439
+ >>> Ensure.claude_installed()
440
+ >>> # Now safe to call claude commands
441
+ >>> ctx.shell.run_claude_extraction_plan(cwd)
442
+ """
443
+ if shutil.which("claude") is None:
444
+ user_output(
445
+ click.style("Error: ", fg="red")
446
+ + "Claude CLI is not installed\n\n"
447
+ + "Install it from: https://claude.ai/download\n"
448
+ + "Or skip extraction with: erk pr land --no-extract"
449
+ )
450
+ raise SystemExit(1)
451
+
452
+ @staticmethod
453
+ def gt_authenticated(ctx: ErkContext) -> None:
454
+ """Ensure Graphite CLI (gt) is authenticated.
455
+
456
+ Uses LBYL pattern to check gt authentication status before operations
457
+ that require it (like gt submit).
458
+
459
+ Args:
460
+ ctx: Application context with graphite integration
461
+
462
+ Raises:
463
+ SystemExit: If gt is not authenticated
464
+
465
+ Example:
466
+ >>> Ensure.gt_authenticated(ctx)
467
+ >>> # Now safe to call gt submit
468
+ >>> ctx.graphite.submit_branch(repo.root, branch_name, quiet=True)
469
+ """
470
+ is_authenticated, username, _ = ctx.graphite.check_auth_status()
471
+
472
+ if not is_authenticated:
473
+ user_output(
474
+ click.style("Error: ", fg="red")
475
+ + "Graphite CLI (gt) is not authenticated\n\n"
476
+ + "Authenticate with: gt auth\n\n"
477
+ + "This is required before submitting branches or creating PRs."
478
+ )
479
+ raise SystemExit(1)
480
+
481
+ @staticmethod
482
+ def gh_authenticated(ctx: ErkContext) -> None:
483
+ """Ensure GitHub CLI (gh) is installed and authenticated.
484
+
485
+ Uses LBYL pattern to check gh installation and authentication status
486
+ before operations that require it. This is the canonical check for
487
+ GitHub CLI readiness - callers should use this single method rather
488
+ than calling gh_installed() separately.
489
+
490
+ Args:
491
+ ctx: Application context with github integration
492
+
493
+ Raises:
494
+ SystemExit: If gh is not installed or not authenticated
495
+
496
+ Example:
497
+ >>> Ensure.gh_authenticated(ctx)
498
+ >>> # Now safe to call gh commands
499
+ >>> pr_info = ctx.github.get_pr_status(repo.root, branch)
500
+ """
501
+ Ensure.gh_installed()
502
+ is_authenticated, username, _ = ctx.github.check_auth_status()
503
+
504
+ if not is_authenticated:
505
+ user_output(
506
+ click.style("Error: ", fg="red")
507
+ + "GitHub CLI (gh) is not authenticated\n\n"
508
+ + "Authenticate with: gh auth login\n\n"
509
+ + "This is required before submitting branches or creating PRs."
510
+ )
511
+ raise SystemExit(1)
512
+
513
+ @staticmethod
514
+ def ideal_state(result: T | NonIdealState) -> T:
515
+ """Ensure result is not a NonIdealState, otherwise exit with error.
516
+
517
+ This method provides type narrowing: it takes `T | NonIdealState` and
518
+ returns `T`, allowing the type checker to understand the value cannot
519
+ be a NonIdealState after this call.
520
+
521
+ Args:
522
+ result: Value that may be a NonIdealState
523
+
524
+ Returns:
525
+ The value unchanged if not NonIdealState (with narrowed type T)
526
+
527
+ Raises:
528
+ SystemExit: If result is NonIdealState (with exit code 1)
529
+
530
+ Example:
531
+ >>> from erk_shared.non_ideal_state import GitHubChecks
532
+ >>> branch = Ensure.ideal_state(GitHubChecks.branch(raw_branch))
533
+ >>> # branch is now guaranteed to be str, not str | BranchDetectionFailed
534
+ """
535
+ if isinstance(result, NonIdealState):
536
+ user_output(click.style("Error: ", fg="red") + result.message)
537
+ raise SystemExit(1)
538
+ return result
539
+
540
+ @staticmethod
541
+ def branch(result: str | BranchDetectionFailed) -> str:
542
+ """Ensure branch detection succeeded.
543
+
544
+ Args:
545
+ result: Branch name or BranchDetectionFailed
546
+
547
+ Returns:
548
+ The branch name
549
+
550
+ Raises:
551
+ SystemExit: If detection failed (with exit code 1)
552
+ """
553
+ if isinstance(result, BranchDetectionFailed):
554
+ user_output(click.style("Error: ", fg="red") + result.message)
555
+ raise SystemExit(1)
556
+ return result
557
+
558
+ @staticmethod
559
+ def pr(result: PRDetails | NoPRForBranch | PRNotFoundError) -> PRDetails:
560
+ """Ensure PR lookup succeeded.
561
+
562
+ Args:
563
+ result: PRDetails or a not-found error
564
+
565
+ Returns:
566
+ The PRDetails
567
+
568
+ Raises:
569
+ SystemExit: If PR not found (with exit code 1)
570
+ """
571
+ if isinstance(result, (NoPRForBranch, PRNotFoundError)):
572
+ user_output(click.style("Error: ", fg="red") + result.message)
573
+ raise SystemExit(1)
574
+ return result
575
+
576
+ @staticmethod
577
+ def comments(result: list | GitHubAPIFailed) -> list:
578
+ """Ensure comments fetch succeeded.
579
+
580
+ Args:
581
+ result: List of comments or GitHubAPIFailed
582
+
583
+ Returns:
584
+ The list of comments
585
+
586
+ Raises:
587
+ SystemExit: If API call failed (with exit code 1)
588
+ """
589
+ if isinstance(result, GitHubAPIFailed):
590
+ user_output(click.style("Error: ", fg="red") + result.message)
591
+ raise SystemExit(1)
592
+ return result
593
+
594
+ @staticmethod
595
+ def void_op(result: None | GitHubAPIFailed) -> None:
596
+ """Ensure void operation succeeded.
597
+
598
+ Args:
599
+ result: None (success) or GitHubAPIFailed
600
+
601
+ Returns:
602
+ None
603
+
604
+ Raises:
605
+ SystemExit: If API call failed (with exit code 1)
606
+ """
607
+ if isinstance(result, GitHubAPIFailed):
608
+ user_output(click.style("Error: ", fg="red") + result.message)
609
+ raise SystemExit(1)
610
+ return result
611
+
612
+ @staticmethod
613
+ def session(result: T | SessionNotFound) -> T:
614
+ """Ensure session lookup succeeded.
615
+
616
+ Args:
617
+ result: Session or SessionNotFound sentinel
618
+
619
+ Returns:
620
+ The Session
621
+
622
+ Raises:
623
+ SystemExit: If session not found (with exit code 1)
624
+ """
625
+ if isinstance(result, SessionNotFound):
626
+ user_output(click.style("Error: ", fg="red") + result.message)
627
+ raise SystemExit(1)
628
+ return result