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,511 @@
1
+ """Claude CLI execution abstraction.
2
+
3
+ This module provides the RealClaudeExecutor implementation and re-exports
4
+ ABC and types from erk_shared.core for backward compatibility.
5
+ """
6
+
7
+ import json
8
+ import logging
9
+ import os
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ import threading
14
+ from collections.abc import Iterator
15
+ from pathlib import Path
16
+
17
+ # Re-export ABC and types from erk_shared.core for backward compatibility
18
+ from erk_shared.core.claude_executor import ClaudeEvent as ClaudeEvent
19
+ from erk_shared.core.claude_executor import ClaudeExecutor as ClaudeExecutor
20
+ from erk_shared.core.claude_executor import CommandResult as CommandResult
21
+ from erk_shared.core.claude_executor import ErrorEvent as ErrorEvent
22
+ from erk_shared.core.claude_executor import IssueNumberEvent as IssueNumberEvent
23
+ from erk_shared.core.claude_executor import NoOutputEvent as NoOutputEvent
24
+ from erk_shared.core.claude_executor import NoTurnsEvent as NoTurnsEvent
25
+ from erk_shared.core.claude_executor import PrNumberEvent as PrNumberEvent
26
+ from erk_shared.core.claude_executor import ProcessErrorEvent as ProcessErrorEvent
27
+ from erk_shared.core.claude_executor import PromptResult as PromptResult
28
+ from erk_shared.core.claude_executor import PrTitleEvent as PrTitleEvent
29
+ from erk_shared.core.claude_executor import PrUrlEvent as PrUrlEvent
30
+ from erk_shared.core.claude_executor import SpinnerUpdateEvent as SpinnerUpdateEvent
31
+ from erk_shared.core.claude_executor import TextEvent as TextEvent
32
+ from erk_shared.core.claude_executor import ToolEvent as ToolEvent
33
+
34
+ # Constants for process execution
35
+ PROCESS_TIMEOUT_SECONDS = 600 # 10 minutes
36
+ STDERR_JOIN_TIMEOUT = 5.0 # 5 seconds (increased from 1.0)
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+
41
+ class RealClaudeExecutor(ClaudeExecutor):
42
+ """Production implementation using subprocess and Claude CLI."""
43
+
44
+ def is_claude_available(self) -> bool:
45
+ """Check if Claude CLI is in PATH using shutil.which."""
46
+ return shutil.which("claude") is not None
47
+
48
+ def execute_command_streaming(
49
+ self,
50
+ command: str,
51
+ worktree_path: Path,
52
+ dangerous: bool,
53
+ verbose: bool = False,
54
+ debug: bool = False,
55
+ model: str | None = None,
56
+ ) -> Iterator[ClaudeEvent]:
57
+ """Execute Claude CLI command and yield typed events in real-time.
58
+
59
+ Implementation details:
60
+ - Uses subprocess.Popen() for streaming stdout line-by-line
61
+ - Passes --permission-mode acceptEdits, --output-format stream-json
62
+ - Optionally passes --dangerously-skip-permissions when dangerous=True
63
+ - Optionally passes --model when model is specified
64
+ - In verbose mode: streams output to terminal (no parsing, no events yielded)
65
+ - In filtered mode: parses stream-json and yields events in real-time
66
+ - In debug mode: emits additional debug information to stderr
67
+ """
68
+ cmd_args = [
69
+ "claude",
70
+ "--print",
71
+ "--verbose",
72
+ "--permission-mode",
73
+ "acceptEdits",
74
+ "--output-format",
75
+ "stream-json",
76
+ ]
77
+ if dangerous:
78
+ cmd_args.append("--dangerously-skip-permissions")
79
+ if model is not None:
80
+ cmd_args.extend(["--model", model])
81
+ cmd_args.append(command)
82
+
83
+ if verbose:
84
+ # Verbose mode - stream to terminal, no parsing, no events
85
+ result = subprocess.run(cmd_args, cwd=worktree_path, check=False)
86
+
87
+ if result.returncode != 0:
88
+ error_msg = f"Claude command {command} failed with exit code {result.returncode}"
89
+ yield ErrorEvent(message=error_msg)
90
+ return
91
+
92
+ # Filtered mode - streaming with real-time parsing
93
+ if debug:
94
+ print(f"[DEBUG executor] Starting Popen with args: {cmd_args}", file=sys.stderr)
95
+ print(f"[DEBUG executor] cwd: {worktree_path}", file=sys.stderr)
96
+ sys.stderr.flush()
97
+
98
+ # Handle Popen errors (e.g., claude not found, permission denied)
99
+ try:
100
+ process = subprocess.Popen(
101
+ cmd_args,
102
+ cwd=worktree_path,
103
+ stdout=subprocess.PIPE,
104
+ stderr=subprocess.PIPE,
105
+ text=True,
106
+ bufsize=1, # Line buffered
107
+ )
108
+ except OSError as e:
109
+ yield ProcessErrorEvent(
110
+ message=f"Failed to start Claude CLI: {e}\nCommand: {' '.join(cmd_args)}"
111
+ )
112
+ return
113
+
114
+ if debug:
115
+ print(f"[DEBUG executor] Popen started, pid={process.pid}", file=sys.stderr)
116
+ sys.stderr.flush()
117
+
118
+ stderr_output: list[str] = []
119
+
120
+ # Capture stderr in background thread
121
+ def capture_stderr() -> None:
122
+ if process.stderr:
123
+ for line in process.stderr:
124
+ stderr_output.append(line)
125
+
126
+ stderr_thread = threading.Thread(target=capture_stderr, daemon=True)
127
+ stderr_thread.start()
128
+
129
+ # Process stdout line by line in real-time
130
+ line_count = 0
131
+ if debug:
132
+ print("[DEBUG executor] Starting to read stdout...", file=sys.stderr)
133
+ sys.stderr.flush()
134
+ if process.stdout:
135
+ for line in process.stdout:
136
+ line_count += 1
137
+ if debug:
138
+ print(
139
+ f"[DEBUG executor] Line #{line_count}: {line[:100]!r}...", file=sys.stderr
140
+ )
141
+ sys.stderr.flush()
142
+ if not line.strip():
143
+ continue
144
+
145
+ # Try to parse as JSON
146
+ parsed = self._parse_stream_json_line(line, worktree_path, command)
147
+ if parsed is None:
148
+ if debug:
149
+ print(
150
+ f"[DEBUG executor] Line #{line_count} parsed to None", file=sys.stderr
151
+ )
152
+ sys.stderr.flush()
153
+ continue
154
+
155
+ if debug:
156
+ print(f"[DEBUG executor] Line #{line_count} parsed: {parsed}", file=sys.stderr)
157
+ sys.stderr.flush()
158
+
159
+ # Yield text content and extract metadata from it
160
+ text_content = parsed.get("text_content")
161
+ if text_content is not None and isinstance(text_content, str):
162
+ yield TextEvent(content=text_content)
163
+
164
+ # Also try to extract PR metadata from text (simpler than nested JSON)
165
+ from erk.core.output_filter import extract_pr_metadata_from_text
166
+
167
+ text_metadata = extract_pr_metadata_from_text(text_content)
168
+ text_pr_url = text_metadata.get("pr_url")
169
+ if text_pr_url is not None:
170
+ yield PrUrlEvent(url=str(text_pr_url))
171
+ text_pr_number = text_metadata.get("pr_number")
172
+ if text_pr_number is not None:
173
+ yield PrNumberEvent(number=int(text_pr_number))
174
+ text_pr_title = text_metadata.get("pr_title")
175
+ if text_pr_title is not None:
176
+ yield PrTitleEvent(title=str(text_pr_title))
177
+ text_issue_number = text_metadata.get("issue_number")
178
+ if text_issue_number is not None:
179
+ yield IssueNumberEvent(number=int(text_issue_number))
180
+
181
+ # Yield tool summaries
182
+ tool_summary = parsed.get("tool_summary")
183
+ if tool_summary is not None and isinstance(tool_summary, str):
184
+ yield ToolEvent(summary=tool_summary)
185
+
186
+ # Yield spinner updates
187
+ spinner_text = parsed.get("spinner_update")
188
+ if spinner_text is not None and isinstance(spinner_text, str):
189
+ yield SpinnerUpdateEvent(status=spinner_text)
190
+
191
+ # Yield PR URL
192
+ pr_url_value = parsed.get("pr_url")
193
+ if pr_url_value is not None:
194
+ yield PrUrlEvent(url=str(pr_url_value))
195
+
196
+ # Yield PR number
197
+ pr_number_value = parsed.get("pr_number")
198
+ if pr_number_value is not None:
199
+ yield PrNumberEvent(number=int(pr_number_value))
200
+
201
+ # Yield PR title
202
+ pr_title_value = parsed.get("pr_title")
203
+ if pr_title_value is not None:
204
+ yield PrTitleEvent(title=str(pr_title_value))
205
+
206
+ # Yield issue number
207
+ issue_number_value = parsed.get("issue_number")
208
+ if issue_number_value is not None:
209
+ yield IssueNumberEvent(number=int(issue_number_value))
210
+
211
+ # Detect zero-turn completions (hook blocking)
212
+ num_turns = parsed.get("num_turns")
213
+ if num_turns is not None and num_turns == 0:
214
+ diag = f"Claude command {command} completed without processing"
215
+ diag += "\n This usually means a hook blocked the command"
216
+ diag += "\n Run 'claude' directly to see hook error messages"
217
+ diag += f"\n Working directory: {worktree_path}"
218
+ yield NoTurnsEvent(diagnostic=diag)
219
+
220
+ if debug:
221
+ print(
222
+ f"[DEBUG executor] stdout reading complete, total lines: {line_count}",
223
+ file=sys.stderr,
224
+ )
225
+ sys.stderr.flush()
226
+
227
+ # Wait for process to complete with timeout
228
+ try:
229
+ returncode = process.wait(timeout=PROCESS_TIMEOUT_SECONDS)
230
+ except subprocess.TimeoutExpired:
231
+ process.kill()
232
+ process.wait()
233
+ timeout_minutes = PROCESS_TIMEOUT_SECONDS // 60
234
+ yield ProcessErrorEvent(
235
+ message=f"Claude command {command} timed out after {timeout_minutes} minutes"
236
+ )
237
+ return
238
+
239
+ # Wait for stderr thread to finish with increased timeout
240
+ stderr_thread.join(timeout=STDERR_JOIN_TIMEOUT)
241
+
242
+ # Detect no output condition - yield before checking exit code
243
+ if line_count == 0:
244
+ diag = f"Claude command {command} completed but produced no output"
245
+ diag += f"\n Exit code: {returncode}"
246
+ diag += f"\n Working directory: {worktree_path}"
247
+ if stderr_output:
248
+ diag += "\n Stderr:\n" + "".join(stderr_output)
249
+ yield NoOutputEvent(diagnostic=diag)
250
+
251
+ if returncode != 0:
252
+ yield ErrorEvent(message=f"Exit code {returncode}")
253
+ return
254
+
255
+ # Enhanced error messages for non-zero exit codes
256
+ if returncode != 0:
257
+ error_msg = f"Claude command {command} failed"
258
+ error_msg += f"\n Exit code: {returncode}"
259
+ error_msg += f"\n Lines processed: {line_count}"
260
+ if stderr_output:
261
+ error_msg += "\n Stderr:\n" + "".join(stderr_output).strip()
262
+ yield ErrorEvent(message=error_msg)
263
+
264
+ # Debug summary
265
+ if debug:
266
+ print("[DEBUG executor] === Summary ===", file=sys.stderr)
267
+ print(f"[DEBUG executor] Exit code: {returncode}", file=sys.stderr)
268
+ print(f"[DEBUG executor] Lines: {line_count}", file=sys.stderr)
269
+ if stderr_output:
270
+ print(f"[DEBUG executor] Stderr: {''.join(stderr_output)}", file=sys.stderr)
271
+ sys.stderr.flush()
272
+
273
+ def _parse_stream_json_line(
274
+ self, line: str, worktree_path: Path, command: str
275
+ ) -> dict[str, str | int | bool | None] | None:
276
+ """Parse a single stream-json line and extract relevant information.
277
+
278
+ Args:
279
+ line: JSON line from stream-json output
280
+ worktree_path: Path to worktree for relativizing paths
281
+ command: The slash command being executed
282
+
283
+ Returns:
284
+ Dict with text_content, tool_summary, spinner_update, pr_url, pr_number,
285
+ pr_title, and issue_number keys, or None if not JSON
286
+ """
287
+ # Import here to avoid circular dependency
288
+ from erk.core.output_filter import (
289
+ determine_spinner_status,
290
+ extract_pr_metadata,
291
+ extract_text_content,
292
+ summarize_tool_use,
293
+ )
294
+
295
+ if not line.strip():
296
+ return None
297
+
298
+ # Parse JSON safely - JSON parsing requires exception handling
299
+ data: dict | None = None
300
+ if line.strip():
301
+ try:
302
+ parsed = json.loads(line)
303
+ if isinstance(parsed, dict):
304
+ data = parsed
305
+ except json.JSONDecodeError:
306
+ return None
307
+
308
+ if data is None:
309
+ return None
310
+
311
+ result: dict[str, str | int | bool | None] = {
312
+ "text_content": None,
313
+ "tool_summary": None,
314
+ "spinner_update": None,
315
+ "pr_url": None,
316
+ "pr_number": None,
317
+ "pr_title": None,
318
+ "issue_number": None,
319
+ "num_turns": None,
320
+ "is_error": None,
321
+ "result_text": None,
322
+ }
323
+
324
+ # stream-json format uses "type": "assistant" with nested "message" object
325
+ # (not "type": "assistant_message" with content at top level)
326
+ msg_type = data.get("type")
327
+ message = data.get("message", {})
328
+ if not isinstance(message, dict):
329
+ message = {}
330
+
331
+ # Extract text from assistant messages
332
+ if msg_type == "assistant":
333
+ text = extract_text_content(message)
334
+ if text:
335
+ result["text_content"] = text
336
+
337
+ # Extract tool summaries and spinner updates
338
+ content = message.get("content", [])
339
+ if isinstance(content, list):
340
+ for item in content:
341
+ if isinstance(item, dict) and item.get("type") == "tool_use":
342
+ summary = summarize_tool_use(item, worktree_path)
343
+ if summary:
344
+ result["tool_summary"] = summary
345
+
346
+ # Generate spinner update for all tools (even suppressible ones)
347
+ spinner_text = determine_spinner_status(item, command, worktree_path)
348
+ result["spinner_update"] = spinner_text
349
+ break
350
+
351
+ # Extract PR metadata from tool results
352
+ if msg_type == "user":
353
+ content = message.get("content", [])
354
+ if isinstance(content, list):
355
+ for item in content:
356
+ if isinstance(item, dict) and item.get("type") == "tool_result":
357
+ tool_content = item.get("content")
358
+ # Handle both string and list formats
359
+ # String format: raw JSON string
360
+ # List format: [{"type": "text", "text": "..."}]
361
+ content_str: str | None = None
362
+ if isinstance(tool_content, str):
363
+ content_str = tool_content
364
+ elif isinstance(tool_content, list):
365
+ # Extract text from list of content items
366
+ for content_item in tool_content:
367
+ is_text_item = (
368
+ isinstance(content_item, dict)
369
+ and content_item.get("type") == "text"
370
+ )
371
+ if is_text_item:
372
+ text = content_item.get("text")
373
+ if isinstance(text, str):
374
+ content_str = text
375
+ break
376
+ if content_str is not None:
377
+ pr_metadata = extract_pr_metadata(content_str)
378
+ if pr_metadata.get("pr_url"):
379
+ result["pr_url"] = pr_metadata["pr_url"]
380
+ result["pr_number"] = pr_metadata["pr_number"]
381
+ result["pr_title"] = pr_metadata["pr_title"]
382
+ result["issue_number"] = pr_metadata.get("issue_number")
383
+ break
384
+
385
+ # Parse type: result messages for num_turns (hook blocking detection)
386
+ if msg_type == "result":
387
+ num_turns = data.get("num_turns")
388
+ if num_turns is not None:
389
+ result["num_turns"] = num_turns
390
+ result["is_error"] = data.get("is_error", False)
391
+ result_text = data.get("result")
392
+ if result_text is not None:
393
+ result["result_text"] = result_text
394
+
395
+ return result
396
+
397
+ def execute_interactive(
398
+ self,
399
+ worktree_path: Path,
400
+ dangerous: bool,
401
+ command: str,
402
+ target_subpath: Path | None,
403
+ model: str | None = None,
404
+ ) -> None:
405
+ """Execute Claude CLI in interactive mode by replacing current process.
406
+
407
+ Implementation details:
408
+ - Verifies Claude CLI is available
409
+ - Changes to worktree directory (and to subpath if provided)
410
+ - Builds command arguments with the specified command
411
+ - Replaces current process using os.execvp
412
+
413
+ Note:
414
+ This function never returns - the process is replaced by Claude CLI.
415
+
416
+ The target_subpath is trusted to exist because it was computed from
417
+ the source worktree's directory structure. Since the new worktree
418
+ shares git history with the source, the path should exist.
419
+ """
420
+ # Verify Claude is available
421
+ if not self.is_claude_available():
422
+ raise RuntimeError("Claude CLI not found\nInstall from: https://claude.com/download")
423
+
424
+ # Change to worktree directory (optionally to subpath)
425
+ # Trust the computed subpath exists - it was derived from the source worktree
426
+ # which has the same git history. If it doesn't exist, os.chdir will raise
427
+ # FileNotFoundError which is the appropriate error.
428
+ if target_subpath is not None:
429
+ target_dir = worktree_path / target_subpath
430
+ os.chdir(target_dir)
431
+ else:
432
+ os.chdir(worktree_path)
433
+
434
+ # Build command arguments
435
+ cmd_args = ["claude", "--permission-mode", "acceptEdits"]
436
+ if dangerous:
437
+ cmd_args.append("--dangerously-skip-permissions")
438
+ if model is not None:
439
+ cmd_args.extend(["--model", model])
440
+ # Only append command if non-empty (allows launching Claude for planning)
441
+ if command:
442
+ cmd_args.append(command)
443
+
444
+ # Redirect stdin/stdout/stderr to /dev/tty only if they are not already TTYs.
445
+ # This ensures Claude gets terminal access when running as subprocess with
446
+ # captured stdout (e.g., shell integration), while avoiding unnecessary
447
+ # redirection when already running in a terminal (which can break tools
448
+ # like Bun that expect specific TTY capabilities).
449
+ if not (os.isatty(1) and os.isatty(2)):
450
+ try:
451
+ tty_fd = os.open("/dev/tty", os.O_RDWR)
452
+ os.dup2(tty_fd, 0) # stdin
453
+ os.dup2(tty_fd, 1) # stdout
454
+ os.dup2(tty_fd, 2) # stderr
455
+ os.close(tty_fd)
456
+ except OSError:
457
+ logger.debug(
458
+ "Unable to redirect stdin/stdout/stderr to /dev/tty; "
459
+ "falling back to inherited descriptors"
460
+ )
461
+
462
+ # Replace current process with Claude
463
+ os.execvp("claude", cmd_args)
464
+ # Never returns - process is replaced
465
+
466
+ def execute_prompt(
467
+ self,
468
+ prompt: str,
469
+ *,
470
+ model: str = "haiku",
471
+ tools: list[str] | None = None,
472
+ cwd: Path | None = None,
473
+ ) -> PromptResult:
474
+ """Execute a single prompt and return the result.
475
+
476
+ Implementation details:
477
+ - Uses subprocess.run with --print and --output-format text
478
+ - Returns PromptResult with success status and output
479
+ """
480
+ cmd = [
481
+ "claude",
482
+ "--print",
483
+ "--output-format",
484
+ "text",
485
+ "--model",
486
+ model,
487
+ ]
488
+ if tools is not None:
489
+ cmd.extend(["--allowedTools", ",".join(tools)])
490
+ cmd.append(prompt)
491
+
492
+ result = subprocess.run(
493
+ cmd,
494
+ capture_output=True,
495
+ text=True,
496
+ cwd=cwd,
497
+ check=False,
498
+ )
499
+
500
+ if result.returncode != 0:
501
+ return PromptResult(
502
+ success=False,
503
+ output="",
504
+ error=result.stderr.strip() if result.stderr else f"Exit code {result.returncode}",
505
+ )
506
+
507
+ return PromptResult(
508
+ success=True,
509
+ output=result.stdout.strip(),
510
+ error=None,
511
+ )