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,338 @@
1
+ import os
2
+ import shlex
3
+ import subprocess
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Final
7
+
8
+ import click
9
+
10
+ from erk.cli.commands.prepare_cwd_recovery import generate_recovery_script
11
+ from erk.cli.shell_utils import (
12
+ STALE_SCRIPT_MAX_AGE_SECONDS,
13
+ cleanup_stale_scripts,
14
+ )
15
+ from erk.cli.uvx_detection import get_uvx_warning_message, is_running_via_uvx
16
+ from erk.core.context import create_context
17
+ from erk_shared.debug import debug_log
18
+ from erk_shared.output.output import user_confirm, user_output
19
+
20
+ PASSTHROUGH_MARKER: Final[str] = "__ERK_PASSTHROUGH__"
21
+ PASSTHROUGH_COMMANDS: Final[set[str]] = {"sync"}
22
+
23
+ # Global flags that should be stripped from args before command matching
24
+ # These are top-level flags that don't affect which command is being invoked
25
+ GLOBAL_FLAGS: Final[set[str]] = {"--debug", "--dry-run", "--verbose", "-v"}
26
+
27
+ # Commands that require shell integration (directory switching)
28
+ # Maps command names (as received from shell) to CLI command paths (for subprocess)
29
+ # Keys are what the shell handler receives, values are what gets passed to subprocess
30
+ SHELL_INTEGRATION_COMMANDS: Final[dict[str, list[str]]] = {
31
+ # Top-level commands (key matches CLI path)
32
+ "checkout": ["checkout"],
33
+ "co": ["checkout"], # Alias for checkout
34
+ "up": ["up"],
35
+ "down": ["down"],
36
+ "implement": ["implement"],
37
+ "impl": ["implement"], # Alias for implement
38
+ "land": ["land"], # Top-level land command
39
+ # Subcommands under pr
40
+ "pr checkout": ["pr", "checkout"],
41
+ "pr co": ["pr", "checkout"], # Alias for pr checkout
42
+ # Legacy top-level aliases (map to actual CLI paths)
43
+ "create": ["wt", "create"],
44
+ "consolidate": ["stack", "consolidate"],
45
+ # Subcommands under wt
46
+ "wt create": ["wt", "create"],
47
+ "wt checkout": ["wt", "checkout"],
48
+ "wt co": ["wt", "checkout"], # Alias for wt checkout
49
+ # Subcommands under stack
50
+ "stack consolidate": ["stack", "consolidate"],
51
+ # Subcommands under branch
52
+ "branch checkout": ["branch", "checkout"],
53
+ "branch co": ["branch", "checkout"],
54
+ "br checkout": ["branch", "checkout"],
55
+ "br co": ["branch", "checkout"],
56
+ "branch land": ["branch", "land"],
57
+ "br land": ["branch", "land"],
58
+ # Subcommands under slot
59
+ "slot checkout": ["slot", "checkout"],
60
+ "slot co": ["slot", "checkout"],
61
+ }
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class ShellIntegrationResult:
66
+ """Result returned by shell integration handlers."""
67
+
68
+ passthrough: bool
69
+ script: str | None
70
+ exit_code: int
71
+
72
+
73
+ def process_command_result(
74
+ exit_code: int,
75
+ stdout: str | None,
76
+ stderr: str | None,
77
+ command_name: str,
78
+ exception: BaseException | None = None,
79
+ ) -> ShellIntegrationResult:
80
+ """Process command result and determine shell integration behavior.
81
+
82
+ This function implements the core logic for deciding whether to use a script
83
+ or passthrough based on command output. It prioritizes script availability
84
+ over exit code to handle destructive commands that output scripts early.
85
+
86
+ Args:
87
+ exit_code: Command exit code
88
+ stdout: Command stdout (expected to be script path if successful)
89
+ stderr: Command stderr (error messages)
90
+ command_name: Name of the command (for user messages)
91
+ exception: Exception from CliRunner result (e.g., Click's MissingParameter)
92
+
93
+ Returns:
94
+ ShellIntegrationResult with passthrough, script, and exit_code
95
+ """
96
+ script_path = stdout.strip() if stdout else None
97
+
98
+ debug_log(f"Handler: Got script_path={script_path}, exit_code={exit_code}")
99
+
100
+ # Check if the script exists (only if we have a path)
101
+ script_exists = False
102
+ if script_path:
103
+ script_exists = Path(script_path).exists()
104
+ debug_log(f"Handler: Script exists? {script_exists}")
105
+
106
+ # If we have a valid script, use it even if command had errors.
107
+ # This handles destructive commands (like pr land) that output the script
108
+ # before failure. The shell can still navigate to the destination.
109
+ if script_path and script_exists:
110
+ # Forward stderr so user sees status messages even on success
111
+ # (e.g., "✓ Removed worktree", "✓ Deleted branch", etc.)
112
+ if stderr:
113
+ user_output(stderr, nl=False)
114
+ return ShellIntegrationResult(passthrough=False, script=script_path, exit_code=exit_code)
115
+
116
+ # No script available - if command failed, forward the error and don't passthrough.
117
+ # Passthrough would run the command again WITHOUT --script, which for commands
118
+ # like 'pr land' would show a misleading "requires shell integration" error
119
+ # instead of the actual failure reason.
120
+ if exit_code != 0:
121
+ if stderr:
122
+ user_output(stderr, nl=False)
123
+ elif exception is not None:
124
+ # Handle Click exceptions that don't go to stderr (e.g., MissingParameter)
125
+ # When using standalone_mode=False, Click stores usage errors in result.exception
126
+ # but leaves stderr empty, causing silent exits without this handling.
127
+ user_output(f"Error: {exception}")
128
+ return ShellIntegrationResult(passthrough=False, script=None, exit_code=exit_code)
129
+
130
+ # Forward stderr messages to user (only for successful commands)
131
+ if stderr:
132
+ user_output(stderr, nl=False)
133
+
134
+ # Note when command completed successfully but no directory change is needed
135
+ if script_path is None or not script_path:
136
+ user_output(f"Note: '{command_name}' completed (no directory change needed)")
137
+
138
+ return ShellIntegrationResult(passthrough=False, script=script_path, exit_code=exit_code)
139
+
140
+
141
+ def _invoke_hidden_command(command_name: str, args: tuple[str, ...]) -> ShellIntegrationResult:
142
+ """Invoke a command with --script flag for shell integration.
143
+
144
+ If args contain help flags or explicit --script, passthrough to regular command.
145
+ Otherwise, add --script flag and run as subprocess with live stderr streaming.
146
+
147
+ Uses subprocess.run instead of CliRunner to allow stderr (user messages)
148
+ to stream directly to the terminal in real-time, while capturing stdout
149
+ (the activation script path) for shell integration.
150
+ """
151
+ # Check if help flags, --script, --dry-run, or non-interactive flags are present
152
+ # These should pass through to avoid shell integration adding --script.
153
+ # --yolo and --no-interactive conflict with --script (mutually exclusive).
154
+ passthrough_flags = {"-h", "--help", "--script", "--dry-run", "--yolo", "--no-interactive"}
155
+ if passthrough_flags & set(args):
156
+ return ShellIntegrationResult(passthrough=True, script=None, exit_code=0)
157
+
158
+ cli_cmd_parts = SHELL_INTEGRATION_COMMANDS.get(command_name)
159
+ if cli_cmd_parts is None:
160
+ if command_name in PASSTHROUGH_COMMANDS:
161
+ return _build_passthrough_script(command_name, args)
162
+ return ShellIntegrationResult(passthrough=True, script=None, exit_code=0)
163
+
164
+ # Check for uvx invocation and warn (command is already confirmed in SHELL_INTEGRATION_COMMANDS)
165
+ if is_running_via_uvx():
166
+ user_output(click.style("Warning: ", fg="yellow") + get_uvx_warning_message(command_name))
167
+ user_output("") # Blank line for readability
168
+ if not user_confirm("Continue anyway?", default=False):
169
+ return ShellIntegrationResult(passthrough=False, script=None, exit_code=1)
170
+
171
+ # Clean up stale scripts before running (opportunistic cleanup)
172
+ cleanup_stale_scripts(max_age_seconds=STALE_SCRIPT_MAX_AGE_SECONDS)
173
+
174
+ # Build full command: erk <cli_cmd_parts> <args> --script
175
+ # cli_cmd_parts contains the actual CLI path (e.g., ["wt", "create"] for "create")
176
+ cmd = ["erk", *cli_cmd_parts, *args, "--script"]
177
+
178
+ debug_log(f"Handler: Running subprocess: {cmd}")
179
+
180
+ # Run subprocess with:
181
+ # - stdout captured (contains activation script path)
182
+ # - stderr passed through to terminal (live streaming of user messages)
183
+ result = subprocess.run(
184
+ cmd,
185
+ stdout=subprocess.PIPE, # Capture stdout for script path
186
+ stderr=None, # Let stderr pass through to terminal (live streaming)
187
+ text=True,
188
+ check=False, # Don't raise on non-zero exit
189
+ )
190
+
191
+ return process_command_result(
192
+ exit_code=result.returncode,
193
+ stdout=result.stdout,
194
+ stderr=None, # stderr already shown to user
195
+ command_name=command_name,
196
+ exception=None, # No exception from subprocess
197
+ )
198
+
199
+
200
+ def handle_shell_request(args: tuple[str, ...]) -> ShellIntegrationResult:
201
+ """Dispatch shell integration handling based on the original CLI invocation."""
202
+ if len(args) == 0:
203
+ return ShellIntegrationResult(passthrough=True, script=None, exit_code=0)
204
+
205
+ # Strip global flags from the beginning of args before command matching
206
+ # This ensures commands like "erk --debug pr land" are recognized correctly
207
+ args_list = list(args)
208
+ while args_list and args_list[0] in GLOBAL_FLAGS:
209
+ args_list.pop(0)
210
+
211
+ if len(args_list) == 0:
212
+ return ShellIntegrationResult(passthrough=True, script=None, exit_code=0)
213
+
214
+ # Try compound command first (e.g., "wt create", "stack consolidate")
215
+ if len(args_list) >= 2:
216
+ compound_name = f"{args_list[0]} {args_list[1]}"
217
+ if compound_name in SHELL_INTEGRATION_COMMANDS:
218
+ return _invoke_hidden_command(compound_name, tuple(args_list[2:]))
219
+
220
+ # Fall back to single command
221
+ command_name = args_list[0]
222
+ command_args = tuple(args_list[1:]) if len(args_list) > 1 else ()
223
+ return _invoke_hidden_command(command_name, command_args)
224
+
225
+
226
+ def _build_passthrough_script(command_name: str, args: tuple[str, ...]) -> ShellIntegrationResult:
227
+ """Create a passthrough script tailored for the caller's shell."""
228
+ shell_name = os.environ.get("ERK_SHELL", "bash").lower()
229
+ ctx = create_context(dry_run=False)
230
+ recovery_path = generate_recovery_script(ctx)
231
+
232
+ script_content = _render_passthrough_script(shell_name, command_name, args, recovery_path)
233
+ result = ctx.script_writer.write_activation_script(
234
+ script_content,
235
+ command_name=f"{command_name}-passthrough",
236
+ comment="generated by __shell passthrough handler",
237
+ )
238
+ return ShellIntegrationResult(passthrough=False, script=str(result.path), exit_code=0)
239
+
240
+
241
+ def _render_passthrough_script(
242
+ shell_name: str,
243
+ command_name: str,
244
+ args: tuple[str, ...],
245
+ recovery_path: Path | None,
246
+ ) -> str:
247
+ """Render shell-specific script that runs the command and performs recovery."""
248
+ if shell_name == "fish":
249
+ return _render_fish_passthrough(command_name, args, recovery_path)
250
+ return _render_posix_passthrough(command_name, args, recovery_path)
251
+
252
+
253
+ def _render_posix_passthrough(
254
+ command_name: str,
255
+ args: tuple[str, ...],
256
+ recovery_path: Path | None,
257
+ ) -> str:
258
+ quoted_args = " ".join(shlex.quote(part) for part in (command_name, *args))
259
+ recovery_literal = shlex.quote(str(recovery_path)) if recovery_path is not None else "''"
260
+ lines = [
261
+ f"command erk {quoted_args}",
262
+ "__erk_exit=$?",
263
+ f"__erk_recovery={recovery_literal}",
264
+ 'if [ -n "$__erk_recovery" ] && [ -f "$__erk_recovery" ]; then',
265
+ ' if [ ! -d "$PWD" ]; then',
266
+ ' . "$__erk_recovery"',
267
+ " fi",
268
+ ' if [ -z "$ERK_KEEP_SCRIPTS" ]; then',
269
+ ' rm -f "$__erk_recovery"',
270
+ " fi",
271
+ "fi",
272
+ "return $__erk_exit",
273
+ ]
274
+ return "\n".join(lines) + "\n"
275
+
276
+
277
+ def _quote_fish(arg: str) -> str:
278
+ if not arg:
279
+ return '""'
280
+
281
+ escape_map = {
282
+ "\\": "\\\\",
283
+ '"': '\\"',
284
+ "$": "\\$",
285
+ "`": "\\`",
286
+ "~": "\\~",
287
+ "*": "\\*",
288
+ "?": "\\?",
289
+ "{": "\\{",
290
+ "}": "\\}",
291
+ "[": "\\[",
292
+ "]": "\\]",
293
+ "(": "\\(",
294
+ ")": "\\)",
295
+ "<": "\\<",
296
+ ">": "\\>",
297
+ "|": "\\|",
298
+ ";": "\\;",
299
+ "&": "\\&",
300
+ }
301
+ escaped_parts: list[str] = []
302
+ for char in arg:
303
+ if char == "\n":
304
+ escaped_parts.append("\\n")
305
+ continue
306
+ if char == "\t":
307
+ escaped_parts.append("\\t")
308
+ continue
309
+ escaped_parts.append(escape_map.get(char, char))
310
+
311
+ escaped = "".join(escaped_parts)
312
+ return f'"{escaped}"'
313
+
314
+
315
+ def _render_fish_passthrough(
316
+ command_name: str,
317
+ args: tuple[str, ...],
318
+ recovery_path: Path | None,
319
+ ) -> str:
320
+ command_parts = " ".join(_quote_fish(part) for part in (command_name, *args))
321
+ recovery_literal = _quote_fish(str(recovery_path)) if recovery_path is not None else '""'
322
+ lines = [
323
+ f"command erk {command_parts}",
324
+ "set __erk_exit $status",
325
+ f"set __erk_recovery {recovery_literal}",
326
+ 'if test -n "$__erk_recovery"',
327
+ ' if test -f "$__erk_recovery"',
328
+ ' if not test -d "$PWD"',
329
+ ' source "$__erk_recovery"',
330
+ " end",
331
+ " if not set -q ERK_KEEP_SCRIPTS",
332
+ ' rm -f "$__erk_recovery"',
333
+ " end",
334
+ " end",
335
+ "end",
336
+ "return $__erk_exit",
337
+ ]
338
+ return "\n".join(lines) + "\n"
@@ -0,0 +1,32 @@
1
+ # Erk shell integration for zsh
2
+ # This function wraps the erk CLI to provide seamless worktree switching
3
+
4
+ erk() {
5
+ # Don't intercept if we're doing shell completion
6
+ [ -n "$_ERK_COMPLETE" ] && { command erk "$@"; return; }
7
+
8
+ local script_path exit_status
9
+ script_path=$(ERK_SHELL=zsh command erk __shell "$@")
10
+ exit_status=$?
11
+
12
+ # Passthrough mode: run the original command directly
13
+ [ "$script_path" = "__ERK_PASSTHROUGH__" ] && { command erk "$@"; return; }
14
+
15
+ # Source the script file if it exists, regardless of exit code.
16
+ # This matches Python handler logic: use script even if command had errors.
17
+ # The script contains important state changes (like cd to target dir).
18
+ if [ -n "$script_path" ] && [ -f "$script_path" ]; then
19
+ source "$script_path"
20
+ local source_exit=$?
21
+
22
+ # Clean up unless ERK_KEEP_SCRIPTS is set
23
+ if [ -z "$ERK_KEEP_SCRIPTS" ]; then
24
+ rm -f "$script_path"
25
+ fi
26
+
27
+ return $source_exit
28
+ fi
29
+
30
+ # Only return exit_status if no script was provided
31
+ [ $exit_status -ne 0 ] && return $exit_status
32
+ }
erk/cli/shell_utils.py ADDED
@@ -0,0 +1,171 @@
1
+ """Utilities for generating shell integration scripts."""
2
+
3
+ import os
4
+ import tempfile
5
+ import time
6
+ import uuid
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+
10
+ from erk.cli.activation import _render_logging_helper, render_activation_script
11
+ from erk_shared.debug import debug_log
12
+
13
+ STALE_SCRIPT_MAX_AGE_SECONDS = 3600
14
+
15
+
16
+ def render_cd_script(path: Path, *, comment: str, success_message: str) -> str:
17
+ """Generate shell script to change directory with feedback.
18
+
19
+ Args:
20
+ path: Target directory path to cd into.
21
+ comment: Shell comment describing the operation.
22
+ success_message: Message to echo after successful cd.
23
+
24
+ Returns:
25
+ Shell script that changes directory and shows success message.
26
+ """
27
+ path_str = str(path)
28
+ path_name = path.name
29
+ quoted_path = "'" + path_str.replace("'", "'\\''") + "'"
30
+ logging_helper = _render_logging_helper()
31
+ lines = [
32
+ f"# {comment}",
33
+ logging_helper,
34
+ f'__erk_log "->" "Switching to: {path_name}"',
35
+ f'__erk_log_verbose "->" "Directory: $(pwd) -> {path}"',
36
+ f"cd {quoted_path}",
37
+ f'echo "{success_message}"',
38
+ ]
39
+ return "\n".join(lines) + "\n"
40
+
41
+
42
+ def render_navigation_script(
43
+ target_path: Path,
44
+ repo_root: Path,
45
+ *,
46
+ comment: str,
47
+ success_message: str,
48
+ ) -> str:
49
+ """Generate navigation script that automatically chooses between simple cd or full activation.
50
+
51
+ This function determines whether the target is the root worktree or a non-root worktree
52
+ and generates the appropriate navigation script:
53
+
54
+ - Root worktree (target_path == repo_root): Simple cd script via render_cd_script()
55
+ - Only changes directory
56
+ - No venv activation needed (user manages their own environment)
57
+
58
+ - Non-root worktree (target_path != repo_root): Full activation script via
59
+ render_activation_script()
60
+ - Changes directory
61
+ - Creates/activates virtual environment
62
+ - Loads .env file
63
+ - Required for erk-managed worktrees
64
+
65
+ Args:
66
+ target_path: Directory to navigate to
67
+ repo_root: Repository root path (used to determine if target is root worktree)
68
+ comment: Shell comment describing the operation
69
+ success_message: Message to display after successful navigation
70
+
71
+ Returns:
72
+ Shell script that performs appropriate navigation based on worktree type
73
+
74
+ Example:
75
+ >>> # Navigate to root worktree (simple cd)
76
+ >>> script = render_navigation_script(
77
+ ... Path("/repo"),
78
+ ... Path("/repo"),
79
+ ... comment="return to root",
80
+ ... success_message="At root"
81
+ ... )
82
+ >>>
83
+ >>> # Navigate to non-root worktree (full activation)
84
+ >>> script = render_navigation_script(
85
+ ... Path("/repo/worktrees/feature"),
86
+ ... Path("/repo"),
87
+ ... comment="switch to feature",
88
+ ... success_message="Activated feature"
89
+ ... )
90
+ """
91
+ if target_path == repo_root:
92
+ return render_cd_script(
93
+ target_path,
94
+ comment=comment,
95
+ success_message=success_message,
96
+ )
97
+ return render_activation_script(
98
+ worktree_path=target_path,
99
+ target_subpath=None,
100
+ post_cd_commands=None,
101
+ final_message=f'echo "{success_message}"',
102
+ comment=comment,
103
+ )
104
+
105
+
106
+ def write_script_to_temp(
107
+ script_content: str,
108
+ *,
109
+ command_name: str,
110
+ comment: str | None = None,
111
+ ) -> Path:
112
+ """Write shell script to temp file with unique UUID.
113
+
114
+ Args:
115
+ script_content: The shell script to write
116
+ command_name: Command that generated this (e.g., 'sync', 'switch', 'create')
117
+ comment: Optional comment to include in script header
118
+
119
+ Returns:
120
+ Path to the temp file
121
+
122
+ Filename format: erk-{command}-{uuid}.sh
123
+ """
124
+ unique_id = uuid.uuid4().hex[:8] # 8 chars sufficient (4 billion combinations)
125
+ temp_dir = Path(tempfile.gettempdir())
126
+ temp_file = temp_dir / f"erk-{command_name}-{unique_id}.sh"
127
+
128
+ # Add metadata header
129
+ header = [
130
+ "#!/bin/bash",
131
+ f"# erk {command_name}",
132
+ f"# Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
133
+ f"# UUID: {unique_id}",
134
+ f"# User: {os.getenv('USER', 'unknown')}",
135
+ f"# Working dir: {Path.cwd()}",
136
+ ]
137
+
138
+ if comment:
139
+ header.append(f"# {comment}")
140
+
141
+ header.append("") # Blank line before script
142
+
143
+ full_content = "\n".join(header) + "\n" + script_content
144
+ temp_file.write_text(full_content, encoding="utf-8")
145
+
146
+ # Make executable for good measure
147
+ temp_file.chmod(0o755)
148
+
149
+ debug_log(f"write_script_to_temp: Created {temp_file}")
150
+ debug_log(f"write_script_to_temp: Content:\n{full_content}")
151
+
152
+ return temp_file
153
+
154
+
155
+ def cleanup_stale_scripts(*, max_age_seconds: int = STALE_SCRIPT_MAX_AGE_SECONDS) -> None:
156
+ """Remove erk temp scripts older than max_age_seconds.
157
+
158
+ Args:
159
+ max_age_seconds: Maximum age before cleanup (default 1 hour)
160
+ """
161
+ temp_dir = Path(tempfile.gettempdir())
162
+ cutoff = time.time() - max_age_seconds
163
+
164
+ for script_file in temp_dir.glob("erk-*.sh"):
165
+ if script_file.exists():
166
+ try:
167
+ if script_file.stat().st_mtime < cutoff:
168
+ script_file.unlink()
169
+ except (FileNotFoundError, PermissionError):
170
+ # Scripts may disappear between stat/unlink or be owned by another user.
171
+ continue
@@ -0,0 +1,92 @@
1
+ """Utilities for running subprocesses with better error reporting.
2
+
3
+ This module provides CLI-layer subprocess execution with user-friendly error output.
4
+
5
+ For integration layer subprocess calls (raises RuntimeError), use:
6
+ from erk_shared.subprocess_utils import run_subprocess_with_context
7
+
8
+ For CLI-layer subprocess calls (prints message, raises SystemExit), use:
9
+ from erk.cli.subprocess_utils import run_with_error_reporting (this module)
10
+ """
11
+
12
+ import subprocess
13
+ from collections.abc import Sequence
14
+ from pathlib import Path
15
+
16
+ from erk_shared.output.output import user_output
17
+
18
+
19
+ def run_with_error_reporting(
20
+ cmd: Sequence[str],
21
+ *,
22
+ cwd: Path | None = None,
23
+ error_prefix: str = "Command failed",
24
+ troubleshooting: list[str] | None = None,
25
+ show_output: bool = False,
26
+ ) -> subprocess.CompletedProcess[str]:
27
+ """Run subprocess command with user-friendly error reporting for CLI layer.
28
+
29
+ This function is designed for CLI commands that need to display error messages
30
+ directly to users and exit the program. For integration layer code that needs
31
+ to raise exceptions with context, use run_subprocess_with_context() instead.
32
+
33
+ Args:
34
+ cmd: Command to run as a list of strings
35
+ cwd: Working directory for the command
36
+ error_prefix: Prefix for error message
37
+ troubleshooting: Optional list of troubleshooting suggestions
38
+ show_output: If True, show stdout/stderr in real-time (default: False)
39
+
40
+ Returns:
41
+ CompletedProcess if successful
42
+
43
+ Raises:
44
+ SystemExit: If command fails (after displaying user-friendly error)
45
+
46
+ Example:
47
+ >>> run_with_error_reporting(
48
+ ... ["gh", "pr", "view", "123"],
49
+ ... cwd=repo_root,
50
+ ... error_prefix="Failed to view PR",
51
+ ... troubleshooting=["Ensure gh is installed", "Run 'gh auth login'"]
52
+ ... )
53
+
54
+ Notes:
55
+ - This is for CLI-layer code (commands that interact with users)
56
+ - For integration layer code, use run_subprocess_with_context() instead
57
+ - Uses check=False and manually handles errors for user-friendly output
58
+ - Displays stderr/stdout to user before raising SystemExit
59
+ - When show_output=True, output streams directly to terminal
60
+ """
61
+ result = subprocess.run(cmd, cwd=cwd, check=False, capture_output=not show_output, text=True)
62
+
63
+ if result.returncode != 0:
64
+ # When show_output=True, output already displayed, only show error context
65
+ if show_output:
66
+ message_parts = [
67
+ f"Error: {error_prefix}.\n",
68
+ f"Command: {' '.join(cmd)}",
69
+ f"Exit code: {result.returncode}\n",
70
+ ]
71
+ else:
72
+ error_msg = result.stderr.strip() if result.stderr else result.stdout.strip()
73
+
74
+ # Build error message
75
+ message_parts = [
76
+ f"Error: {error_prefix}.\n",
77
+ f"Command: {' '.join(cmd)}",
78
+ f"Exit code: {result.returncode}\n",
79
+ ]
80
+
81
+ if error_msg:
82
+ message_parts.append(f"Output:\n{error_msg}\n")
83
+
84
+ if troubleshooting:
85
+ message_parts.append("Troubleshooting:")
86
+ for tip in troubleshooting:
87
+ message_parts.append(f" • {tip}")
88
+
89
+ user_output("\n".join(message_parts))
90
+ raise SystemExit(1)
91
+
92
+ return result
@@ -0,0 +1,59 @@
1
+ """Detection for uvx (uv tool run) invocation.
2
+
3
+ This module detects when erk is running via 'uvx erk' or 'uv tool run erk',
4
+ which prevents shell integration from working properly.
5
+ """
6
+
7
+ import sys
8
+ from pathlib import Path
9
+
10
+
11
+ def is_running_via_uvx() -> bool:
12
+ """Detect if running via uvx/uv tool run (best effort, not officially supported).
13
+
14
+ Detection strategy:
15
+ 1. Check pyvenv.cfg for uv marker - if absent, not a uv-created venv
16
+ 2. Check if prefix path contains ephemeral cache markers (uvx uses cache, not tool dir)
17
+
18
+ Note: uvx environments are ephemeral (live in cache directory), while
19
+ `uv tool install` environments are persistent. We only want to detect uvx.
20
+
21
+ Returns:
22
+ True if running via uvx, False otherwise
23
+ """
24
+ prefix = Path(sys.prefix)
25
+
26
+ # Check pyvenv.cfg for uv marker
27
+ pyvenv_cfg = prefix / "pyvenv.cfg"
28
+ if pyvenv_cfg.exists():
29
+ content = pyvenv_cfg.read_text(encoding="utf-8")
30
+ if "uv = " not in content:
31
+ return False # Not a uv-created venv
32
+
33
+ # uvx environments are ephemeral (in cache), not persistent (in UV_TOOL_DIR)
34
+ prefix_str = str(prefix)
35
+ ephemeral_markers = (
36
+ "/uv/archive-v", # uvx ephemeral environments
37
+ "/.cache/uv/",
38
+ "/cache/uv/",
39
+ )
40
+ return any(marker in prefix_str for marker in ephemeral_markers)
41
+
42
+
43
+ def get_uvx_warning_message(command_name: str) -> str:
44
+ """Get the warning message to display when running via uvx.
45
+
46
+ Args:
47
+ command_name: The shell integration command being invoked (e.g., "checkout", "up")
48
+
49
+ Returns:
50
+ Multi-line warning message explaining the issue and fix
51
+ """
52
+ return f"""Running 'erk {command_name}' via uvx - this command requires shell integration
53
+
54
+ Shell integration commands need to change your shell's directory, which doesn't work
55
+ when running in uvx's isolated subprocess.
56
+
57
+ To fix this:
58
+ 1. Install erk in uv's tools: uv tool install erk
59
+ 2. Set up shell integration: erk init --shell"""
erk/core/__init__.py ADDED
File without changes