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
@@ -0,0 +1,96 @@
1
+ """CLI-level wrappers for GitHub URL parsing with error handling.
2
+
3
+ This module provides CLI-friendly wrappers around the shared parsing functions
4
+ in erk_shared.github.parsing. These wrappers handle user input (not just URLs)
5
+ and raise SystemExit(1) with appropriate error messages for invalid input.
6
+
7
+ Two-layer architecture:
8
+ - erk_shared.github.parsing: Pure parsing functions, return None on failure (LBYL-friendly)
9
+ - src/erk/cli/github_parsing.py: CLI wrappers that raise SystemExit(1)
10
+ """
11
+
12
+ import click
13
+
14
+ from erk_shared.github.parsing import (
15
+ parse_issue_number_from_url,
16
+ parse_pr_number_from_url,
17
+ )
18
+ from erk_shared.output.output import user_output
19
+
20
+
21
+ def parse_issue_identifier(identifier: str) -> int:
22
+ """Parse issue number from plain number or GitHub issue URL.
23
+
24
+ This is a CLI-level function that handles user input.
25
+
26
+ Args:
27
+ identifier: Plain number ("42") or GitHub issue URL
28
+
29
+ Returns:
30
+ Issue number as int
31
+
32
+ Raises:
33
+ SystemExit: If identifier cannot be parsed
34
+
35
+ Examples:
36
+ >>> parse_issue_identifier("42")
37
+ 42
38
+ >>> parse_issue_identifier("https://github.com/owner/repo/issues/123")
39
+ 123
40
+ """
41
+ # Plain number (handles leading zeros like "0042" -> 42)
42
+ if identifier.isdigit():
43
+ return int(identifier)
44
+
45
+ # GitHub URL
46
+ issue_number = parse_issue_number_from_url(identifier)
47
+ if issue_number is not None:
48
+ return issue_number
49
+
50
+ user_output(
51
+ click.style("Error: ", fg="red")
52
+ + f"Invalid issue number or URL: {identifier}\n\n"
53
+ + "Expected formats:\n"
54
+ + " • Plain number: 123\n"
55
+ + " • GitHub URL: https://github.com/owner/repo/issues/456"
56
+ )
57
+ raise SystemExit(1)
58
+
59
+
60
+ def parse_pr_identifier(identifier: str) -> int:
61
+ """Parse PR number from plain number or GitHub PR URL.
62
+
63
+ This is a CLI-level function that handles user input.
64
+
65
+ Args:
66
+ identifier: Plain number ("42") or GitHub PR URL
67
+
68
+ Returns:
69
+ PR number as int
70
+
71
+ Raises:
72
+ SystemExit: If identifier cannot be parsed
73
+
74
+ Examples:
75
+ >>> parse_pr_identifier("42")
76
+ 42
77
+ >>> parse_pr_identifier("https://github.com/owner/repo/pull/123")
78
+ 123
79
+ """
80
+ # Plain number (handles leading zeros like "0042" -> 42)
81
+ if identifier.isdigit():
82
+ return int(identifier)
83
+
84
+ # Try strict github.com /pull/ URL only
85
+ pr_number = parse_pr_number_from_url(identifier)
86
+ if pr_number is not None:
87
+ return pr_number
88
+
89
+ user_output(
90
+ click.style("Error: ", fg="red")
91
+ + f"Invalid PR number or URL: {identifier}\n\n"
92
+ + "Expected formats:\n"
93
+ + " • Plain number: 123\n"
94
+ + " • GitHub URL: https://github.com/owner/repo/pull/456"
95
+ )
96
+ raise SystemExit(1)
erk/cli/graphite.py ADDED
@@ -0,0 +1,81 @@
1
+ """Graphite integration for erk.
2
+
3
+ Graphite (https://graphite.com) is a stacked git workflow tool that allows developers
4
+ to manage dependent branches in linear stacks. This module provides utility functions
5
+ for working with worktree stacks.
6
+
7
+ For comprehensive gt mental model and command reference, see:
8
+ .agent/GT_MENTAL_MODEL.md
9
+
10
+ ## What is Graphite?
11
+
12
+ Graphite organizes branches into "stacks" - linear chains of dependent branches built
13
+ on top of each other. For example:
14
+
15
+ main (trunk)
16
+ └─ feature/phase-1
17
+ └─ feature/phase-2
18
+ └─ feature/phase-3
19
+
20
+ Each branch in the stack depends on its parent, making it easy to work on multiple
21
+ related changes while keeping them in separate PRs.
22
+
23
+ ## Graphite Abstraction
24
+
25
+ This module uses the Graphite abstraction to read Graphite cache data. Production
26
+ code should use ctx.graphite_ops methods directly instead of importing functions from
27
+ this module.
28
+
29
+ See erk.core.graphite_ops for the abstraction interface.
30
+ """
31
+
32
+ from pathlib import Path
33
+
34
+ from erk.core.context import ErkContext
35
+ from erk_shared.git.abc import WorktreeInfo
36
+
37
+
38
+ def find_worktrees_containing_branch(
39
+ ctx: ErkContext,
40
+ repo_root: Path,
41
+ worktrees: list[WorktreeInfo],
42
+ target_branch: str,
43
+ ) -> list[WorktreeInfo]:
44
+ """Find all worktrees that have target_branch checked out (exact match only).
45
+
46
+ Args:
47
+ ctx: Erk context with git operations
48
+ repo_root: Path to the repository root
49
+ worktrees: List of all worktrees from list_worktrees()
50
+ target_branch: Branch name to search for
51
+
52
+ Returns:
53
+ List of WorktreeInfo objects where target_branch is checked out.
54
+ Empty list if no worktrees have the branch checked out.
55
+
56
+ Algorithm:
57
+ 1. For each worktree:
58
+ a. Get the worktree's checked-out branch
59
+ b. Skip worktrees with detached HEAD (branch=None)
60
+ c. Check if worktree.branch == target_branch (exact string match)
61
+ d. If yes, add worktree to results
62
+ 2. Return all matching worktrees
63
+
64
+ Example:
65
+ >>> worktrees = ctx.git_ops.list_worktrees(repo.root)
66
+ >>> matching = find_worktrees_containing_branch(ctx, repo.root, worktrees, "feature-2")
67
+ >>> print([wt.path for wt in matching])
68
+ [Path("/path/to/work/feature-work")]
69
+ """
70
+ matching_worktrees: list[WorktreeInfo] = []
71
+
72
+ for wt in worktrees:
73
+ # Skip worktrees with detached HEAD
74
+ if wt.branch is None:
75
+ continue
76
+
77
+ # Check if target_branch is exactly checked out in this worktree
78
+ if wt.branch == target_branch:
79
+ matching_worktrees.append(wt)
80
+
81
+ return matching_worktrees
@@ -0,0 +1,80 @@
1
+ """Custom Click command classes that require Graphite integration.
2
+
3
+ This module provides declarative command classes that automatically:
4
+ 1. Check Graphite availability before command execution
5
+ 2. Are hidden from help output when Graphite is unavailable
6
+
7
+ Usage:
8
+ @click.command("list", cls=GraphiteCommand)
9
+ def list_stack(ctx: ErkContext) -> None:
10
+ # No need for Ensure.graphite_available(ctx) - handled by GraphiteCommand
11
+ ...
12
+
13
+ @click.command("up", cls=GraphiteCommandWithHiddenOptions)
14
+ @script_option
15
+ def up_cmd(ctx: ErkContext, script: bool) -> None:
16
+ # Combines Graphite check with hidden options support
17
+ ...
18
+
19
+ @click.group("stack", cls=GraphiteGroup)
20
+ def stack_group() -> None:
21
+ # Entire group hidden when Graphite unavailable
22
+ ...
23
+ """
24
+
25
+ from typing import Any
26
+
27
+ import click
28
+
29
+ from erk.cli.ensure import Ensure
30
+ from erk.cli.help_formatter import CommandWithHiddenOptions
31
+
32
+
33
+ class GraphiteCommand(click.Command):
34
+ """Command that requires Graphite integration.
35
+
36
+ Automatically checks Graphite availability before command execution.
37
+ When Graphite is unavailable, this command is hidden from help output
38
+ but can still be invoked directly (failing with a helpful error message).
39
+
40
+ Use this class for commands that depend on Graphite functionality
41
+ but don't need hidden options support.
42
+ """
43
+
44
+ def invoke(self, ctx: click.Context) -> Any:
45
+ """Invoke command after validating Graphite availability."""
46
+ if ctx.obj is not None:
47
+ Ensure.graphite_available(ctx.obj)
48
+ return super().invoke(ctx)
49
+
50
+
51
+ class GraphiteCommandWithHiddenOptions(CommandWithHiddenOptions):
52
+ """GraphiteCommand + hidden options support.
53
+
54
+ Combines the Graphite availability check from GraphiteCommand
55
+ with the hidden options formatting from CommandWithHiddenOptions.
56
+
57
+ Use this class for commands that:
58
+ 1. Require Graphite functionality
59
+ 2. Have hidden options (like --script)
60
+ """
61
+
62
+ def invoke(self, ctx: click.Context) -> Any:
63
+ """Invoke command after validating Graphite availability."""
64
+ if ctx.obj is not None:
65
+ Ensure.graphite_available(ctx.obj)
66
+ return super().invoke(ctx)
67
+
68
+
69
+ class GraphiteGroup(click.Group):
70
+ """Group that requires Graphite integration.
71
+
72
+ When used with cls=GraphiteGroup, the entire command group is hidden
73
+ from help output when Graphite is unavailable. Commands within the
74
+ group can still be invoked directly and will fail with helpful error
75
+ messages via their own GraphiteCommand classes.
76
+
77
+ The hiding logic is implemented in ErkCommandGroup.format_commands().
78
+ """
79
+
80
+ pass
@@ -0,0 +1,345 @@
1
+ """Custom Click help formatter for organized command display."""
2
+
3
+ import shutil
4
+ from collections.abc import Callable
5
+ from typing import Any, TypeVar, cast
6
+
7
+ import click
8
+
9
+ from erk.cli.alias import get_aliases
10
+ from erk_shared.gateway.erk_installation.real import RealErkInstallation
11
+ from erk_shared.gateway.graphite.disabled import GraphiteDisabled
12
+
13
+ F = TypeVar("F", bound=Callable[..., object])
14
+
15
+ # Type names for Graphite-requiring commands (checked by string to avoid circular imports)
16
+ _GRAPHITE_COMMAND_TYPES = frozenset(
17
+ {
18
+ "GraphiteCommand",
19
+ "GraphiteCommandWithHiddenOptions",
20
+ "GraphiteGroup",
21
+ }
22
+ )
23
+
24
+
25
+ def _requires_graphite(cmd: click.Command) -> bool:
26
+ """Check if a command requires Graphite integration.
27
+
28
+ Uses class name matching to avoid circular imports with graphite_command.py.
29
+ """
30
+ return type(cmd).__name__ in _GRAPHITE_COMMAND_TYPES
31
+
32
+
33
+ def _get_show_hidden_from_context(ctx: click.Context) -> bool:
34
+ """Check if hidden items should be shown based on config.
35
+
36
+ Checks ctx.obj.global_config if available (tests),
37
+ otherwise loads config from disk (direct CLI invocation).
38
+ """
39
+ if ctx.obj is not None:
40
+ config = getattr(ctx.obj, "global_config", None)
41
+ if config is not None:
42
+ return bool(getattr(config, "show_hidden_commands", False))
43
+ # Fallback to loading from disk
44
+ installation = RealErkInstallation()
45
+ if installation.config_exists():
46
+ return installation.load_config().show_hidden_commands
47
+ return False
48
+
49
+
50
+ def _set_param_hidden(param: click.Parameter, hidden: bool) -> None:
51
+ """Set hidden attribute on Click parameter.
52
+
53
+ Click's Option class has a 'hidden' attribute, but Parameter (the base class)
54
+ doesn't expose it in type stubs. We use cast(Any, ...) since we've already
55
+ verified via getattr that this parameter has the 'hidden' attribute.
56
+ """
57
+ cast(Any, param).hidden = hidden
58
+
59
+
60
+ def _set_ctx_show_hidden(ctx: click.Context, value: bool) -> None:
61
+ """Set show_hidden attribute on Click context.
62
+
63
+ Click's Context allows dynamic attributes at runtime (documented API behavior).
64
+ We use cast(Any, ...) to bypass type stubs that don't include dynamic attrs.
65
+ """
66
+ cast(Any, ctx).show_hidden = value
67
+
68
+
69
+ def _is_graphite_available(ctx: click.Context) -> bool:
70
+ """Check if Graphite is available for command visibility.
71
+
72
+ Checks ctx.obj.graphite if available (tests or after callback),
73
+ otherwise loads config from disk and checks gt binary (help before callback).
74
+ """
75
+ if ctx.obj is not None:
76
+ return not isinstance(ctx.obj.graphite, GraphiteDisabled)
77
+ # Fallback to loading from disk (for help before callback runs)
78
+ installation = RealErkInstallation()
79
+ if installation.config_exists():
80
+ config = installation.load_config()
81
+ if config.use_graphite:
82
+ # Config says use Graphite - check if gt is installed
83
+ return shutil.which("gt") is not None
84
+ return False
85
+
86
+
87
+ class CommandWithHiddenOptions(click.Command):
88
+ """Command that respects show_hidden_commands config for hidden options.
89
+
90
+ Use this class for any command with hidden options (like --script).
91
+ Hidden options are shown in a separate "Hidden Options" section when
92
+ show_hidden_commands is enabled in config.
93
+ """
94
+
95
+ def format_options(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
96
+ """Format options, showing hidden ones if config allows."""
97
+ show_hidden = _get_show_hidden_from_context(ctx)
98
+
99
+ opts = []
100
+ hidden_opts = []
101
+ for param in self.get_params(ctx):
102
+ # Use getattr since only Option has 'hidden', not all Parameter types
103
+ is_hidden = getattr(param, "hidden", False)
104
+
105
+ if is_hidden:
106
+ if show_hidden:
107
+ # Temporarily unhide to get help record (Click returns None for hidden)
108
+ _set_param_hidden(param, hidden=False)
109
+ rv = param.get_help_record(ctx)
110
+ _set_param_hidden(param, hidden=True)
111
+ if rv is not None:
112
+ hidden_opts.append(rv)
113
+ else:
114
+ rv = param.get_help_record(ctx)
115
+ if rv is not None:
116
+ opts.append(rv)
117
+
118
+ if opts:
119
+ with formatter.section("Options"):
120
+ formatter.write_dl(opts)
121
+
122
+ if hidden_opts:
123
+ with formatter.section("Hidden Options"):
124
+ formatter.write_dl(hidden_opts)
125
+
126
+
127
+ def script_option(fn: F) -> F:
128
+ """Decorator that adds --script option with proper settings.
129
+
130
+ Must be applied to a function decorated with @click.command(cls=CommandWithHiddenOptions).
131
+ The --script flag is hidden by default but visible when show_hidden_commands=True.
132
+
133
+ Example:
134
+ @click.command("up", cls=CommandWithHiddenOptions)
135
+ @script_option
136
+ def up_cmd(ctx: ErkContext, script: bool) -> None:
137
+ ...
138
+ """
139
+ return click.option(
140
+ "--script",
141
+ is_flag=True,
142
+ hidden=True,
143
+ help="Output shell script for integration. NOT a dry run.",
144
+ )(fn)
145
+
146
+
147
+ class ErkCommandGroup(click.Group):
148
+ """Click Group that organizes commands into logical sections in help output.
149
+
150
+ Commands are organized into sections based on their usage patterns:
151
+ - Core Navigation: Primary workflow commands
152
+ - Command Groups: Organized subcommands
153
+ - Quick Access: Backward compatibility aliases
154
+
155
+ Args:
156
+ grouped: If True, organize commands into sections. If False, show flat list.
157
+ """
158
+
159
+ def __init__(self, grouped: bool = True, **kwargs: object) -> None:
160
+ super().__init__(**cast(dict[str, Any], kwargs))
161
+ self.grouped = grouped
162
+
163
+ def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
164
+ """Format help output, setting show_hidden based on config first.
165
+
166
+ This hook runs after context creation but before format_commands,
167
+ allowing us to set ctx.show_hidden based on the global config.
168
+ """
169
+ # Set show_hidden based on config before formatting help
170
+ self._set_show_hidden_from_context(ctx)
171
+
172
+ # Call parent to format help (which will call format_commands)
173
+ super().format_help(ctx, formatter)
174
+
175
+ def _set_show_hidden_from_context(self, ctx: click.Context) -> None:
176
+ """Set ctx.show_hidden based on config.
177
+
178
+ Checks ctx.obj.global_config if available (tests),
179
+ otherwise loads config from disk (direct CLI invocation).
180
+ """
181
+ # If ctx.obj is provided (tests or already-created context), use its config
182
+ if ctx.obj is not None:
183
+ config = getattr(ctx.obj, "global_config", None)
184
+ if config is not None and getattr(config, "show_hidden_commands", False):
185
+ _set_ctx_show_hidden(ctx, value=True)
186
+ return
187
+
188
+ # Otherwise try to load config directly from disk
189
+ installation = RealErkInstallation()
190
+ if installation.config_exists():
191
+ config = installation.load_config()
192
+ if config.show_hidden_commands:
193
+ _set_ctx_show_hidden(ctx, value=True)
194
+
195
+ def format_commands(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
196
+ """Format commands into organized sections or flat list."""
197
+ show_hidden = getattr(ctx, "show_hidden", False)
198
+
199
+ # Check if Graphite is available (for hiding Graphite-dependent commands)
200
+ graphite_available = _is_graphite_available(ctx)
201
+
202
+ commands = []
203
+ hidden_commands = []
204
+ # Build alias map: alias_name -> primary_name
205
+ alias_map: dict[str, str] = {}
206
+
207
+ for subcommand in self.list_commands(ctx):
208
+ cmd = self.get_command(ctx, subcommand)
209
+ if cmd is None:
210
+ continue
211
+
212
+ # Build alias map from decorator-declared aliases
213
+ for alias_name in get_aliases(cmd):
214
+ alias_map[alias_name] = subcommand
215
+
216
+ # Commands are effectively hidden if:
217
+ # 1. They have hidden=True, OR
218
+ # 2. They require Graphite and Graphite is unavailable
219
+ effectively_hidden = cmd.hidden or (_requires_graphite(cmd) and not graphite_available)
220
+
221
+ if effectively_hidden:
222
+ if show_hidden:
223
+ hidden_commands.append((subcommand, cmd))
224
+ continue
225
+ commands.append((subcommand, cmd))
226
+
227
+ if not commands:
228
+ return
229
+
230
+ # Flat output mode - single "Commands:" section
231
+ if not self.grouped:
232
+ # Filter out aliases (they'll be shown with their primary command)
233
+ primary_commands = [(n, c) for n, c in commands if n not in alias_map]
234
+ with formatter.section("Commands"):
235
+ self._format_command_list(ctx, formatter, primary_commands)
236
+
237
+ if hidden_commands:
238
+ with formatter.section("Hidden"):
239
+ self._format_command_list(ctx, formatter, hidden_commands)
240
+ return
241
+
242
+ # Grouped output mode - organize into sections
243
+ # Define command organization (aliases now derived from decorator, not hardcoded)
244
+ top_level_commands = [
245
+ "checkout",
246
+ "dash",
247
+ "delete",
248
+ "doctor",
249
+ "down",
250
+ "implement",
251
+ "land",
252
+ "list",
253
+ "up",
254
+ "upgrade",
255
+ ]
256
+ command_groups = [
257
+ "admin",
258
+ "artifact",
259
+ "branch",
260
+ "cc",
261
+ "completion",
262
+ "config",
263
+ "docs",
264
+ "hook",
265
+ "info",
266
+ "md",
267
+ "objective",
268
+ "plan",
269
+ "planner",
270
+ "pr",
271
+ "project",
272
+ "run",
273
+ "slot",
274
+ "stack",
275
+ "wt",
276
+ ]
277
+ initialization = ["init"]
278
+
279
+ # Categorize commands
280
+ top_level_cmds = []
281
+ group_cmds = []
282
+ init_cmds = []
283
+ other_cmds = []
284
+
285
+ for name, cmd in commands:
286
+ # Skip aliases (they'll be shown with their primary command)
287
+ if name in alias_map:
288
+ continue
289
+
290
+ if name in top_level_commands:
291
+ top_level_cmds.append((name, cmd))
292
+ elif name in command_groups:
293
+ group_cmds.append((name, cmd))
294
+ elif name in initialization:
295
+ init_cmds.append((name, cmd))
296
+ else:
297
+ # Other commands
298
+ other_cmds.append((name, cmd))
299
+
300
+ # Format sections
301
+ if top_level_cmds:
302
+ with formatter.section("Top-Level Commands"):
303
+ self._format_command_list(ctx, formatter, top_level_cmds)
304
+
305
+ if group_cmds:
306
+ with formatter.section("Command Groups"):
307
+ self._format_command_list(ctx, formatter, group_cmds)
308
+
309
+ if init_cmds:
310
+ with formatter.section("Initialization"):
311
+ self._format_command_list(ctx, formatter, init_cmds)
312
+
313
+ if other_cmds:
314
+ with formatter.section("Other"):
315
+ self._format_command_list(ctx, formatter, other_cmds)
316
+
317
+ if hidden_commands:
318
+ with formatter.section("Hidden"):
319
+ self._format_command_list(ctx, formatter, hidden_commands)
320
+
321
+ def _format_command_list(
322
+ self,
323
+ ctx: click.Context,
324
+ formatter: click.HelpFormatter,
325
+ commands: list[tuple[str, click.Command]],
326
+ ) -> None:
327
+ """Format a list of commands with their help text.
328
+
329
+ Commands with aliases (declared via @alias decorator) are displayed
330
+ as 'checkout (co)'.
331
+ """
332
+ rows = []
333
+ for name, cmd in commands:
334
+ # Get aliases for this command and format display name
335
+ aliases = get_aliases(cmd)
336
+ if aliases:
337
+ display_name = f"{name} ({', '.join(aliases)})"
338
+ else:
339
+ display_name = name
340
+
341
+ help_text = cmd.get_short_help_str(limit=formatter.width)
342
+ rows.append((display_name, help_text))
343
+
344
+ if rows:
345
+ formatter.write_dl(rows)