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,43 @@
1
+ """Check for outdated erk skill that should be deleted.
2
+
3
+ This is a temporary check for early dogfooders. Delete this file once
4
+ all users have removed the outdated .claude/skills/erk/ directory.
5
+ """
6
+
7
+ from pathlib import Path
8
+
9
+ from erk.core.health_checks import CheckResult
10
+
11
+
12
+ def check_outdated_erk_skill(repo_root: Path) -> CheckResult:
13
+ """Check for outdated erk skill directory that should be deleted.
14
+
15
+ The erk skill at .claude/skills/erk/ is outdated and no longer maintained.
16
+ Users should delete this directory.
17
+
18
+ Args:
19
+ repo_root: Path to the repository root
20
+
21
+ Returns:
22
+ CheckResult indicating whether outdated skill was found
23
+ """
24
+ skill_dir = repo_root / ".claude" / "skills" / "erk"
25
+ if not skill_dir.exists():
26
+ return CheckResult(
27
+ name="outdated-erk-skill",
28
+ passed=True,
29
+ message="No outdated erk skill found",
30
+ )
31
+
32
+ # Found outdated skill - return failure with remediation
33
+ return CheckResult(
34
+ name="outdated-erk-skill",
35
+ passed=False,
36
+ message="Outdated erk skill found at .claude/skills/erk/",
37
+ details=(
38
+ "The erk skill is outdated and should be deleted.\n"
39
+ "\n"
40
+ "Remediation:\n"
41
+ " rm -rf .claude/skills/erk/"
42
+ ),
43
+ )
@@ -0,0 +1 @@
1
+ """Implementation queue core functionality."""
@@ -0,0 +1,8 @@
1
+ """GitHub integration for implementation queue operations.
2
+
3
+ Import from submodules:
4
+ - abc: GitHubAdmin
5
+ - real: RealGitHubAdmin
6
+ - noop: NoopGitHubAdmin
7
+ - printing: PrintingGitHubAdmin
8
+ """
@@ -0,0 +1,7 @@
1
+ """Abstract base class for GitHub Actions admin operations.
2
+
3
+ Re-exports from erk_shared for backwards compatibility.
4
+ """
5
+
6
+ from erk_shared.github_admin.abc import AuthStatus as AuthStatus
7
+ from erk_shared.github_admin.abc import GitHubAdmin as GitHubAdmin
@@ -0,0 +1,38 @@
1
+ """No-op wrapper for GitHub Actions admin operations."""
2
+
3
+ from typing import Any
4
+
5
+ from erk_shared.github.types import GitHubRepoLocation
6
+ from erk_shared.github_admin.abc import AuthStatus, GitHubAdmin
7
+
8
+
9
+ class NoopGitHubAdmin(GitHubAdmin):
10
+ """No-op wrapper for GitHub Actions admin operations.
11
+
12
+ Read operations are delegated to the wrapped implementation.
13
+ Write operations return without executing (no-op behavior).
14
+
15
+ This wrapper prevents destructive GitHub admin operations from executing
16
+ in dry-run mode, while still allowing read operations for validation.
17
+ """
18
+
19
+ def __init__(self, wrapped: GitHubAdmin) -> None:
20
+ """Initialize no-op wrapper with a real implementation.
21
+
22
+ Args:
23
+ wrapped: The real GitHubAdmin implementation to wrap
24
+ """
25
+ self._wrapped = wrapped
26
+
27
+ def get_workflow_permissions(self, location: GitHubRepoLocation) -> dict[str, Any]:
28
+ """Delegate read operation to wrapped implementation."""
29
+ return self._wrapped.get_workflow_permissions(location)
30
+
31
+ def set_workflow_pr_permissions(self, location: GitHubRepoLocation, enabled: bool) -> None:
32
+ """No-op for setting workflow permissions in dry-run mode."""
33
+ # Do nothing - prevents actual permission changes
34
+ pass
35
+
36
+ def check_auth_status(self) -> AuthStatus:
37
+ """Delegate read operation to wrapped implementation."""
38
+ return self._wrapped.check_auth_status()
@@ -0,0 +1,43 @@
1
+ """Printing wrapper for GitHub Actions admin operations."""
2
+
3
+ from typing import Any
4
+
5
+ from erk_shared.github.types import GitHubRepoLocation
6
+ from erk_shared.github_admin.abc import AuthStatus, GitHubAdmin
7
+ from erk_shared.printing.base import PrintingBase
8
+
9
+
10
+ class PrintingGitHubAdmin(PrintingBase, GitHubAdmin):
11
+ """Wrapper that prints operations before delegating to inner implementation.
12
+
13
+ This wrapper prints styled output for operations, then delegates to the
14
+ wrapped implementation (which could be Real or Noop).
15
+
16
+ Usage:
17
+ # For production
18
+ printing_admin = PrintingGitHubAdmin(real_admin, script_mode=False, dry_run=False)
19
+
20
+ # For dry-run
21
+ noop_inner = NoopGitHubAdmin(real_admin)
22
+ printing_admin = PrintingGitHubAdmin(noop_inner, script_mode=False, dry_run=True)
23
+ """
24
+
25
+ # Inherits __init__, _emit, and _format_command from PrintingBase
26
+
27
+ def get_workflow_permissions(self, location: GitHubRepoLocation) -> dict[str, Any]:
28
+ """Get workflow permissions (read-only, no printing)."""
29
+ return self._wrapped.get_workflow_permissions(location)
30
+
31
+ def set_workflow_pr_permissions(self, location: GitHubRepoLocation, enabled: bool) -> None:
32
+ """Set workflow PR permissions with printed output."""
33
+ self._emit(
34
+ self._format_command(
35
+ f"gh api --method PUT .../actions/permissions/workflow "
36
+ f"(can_approve_pull_request_reviews={str(enabled).lower()})"
37
+ )
38
+ )
39
+ self._wrapped.set_workflow_pr_permissions(location, enabled)
40
+
41
+ def check_auth_status(self) -> AuthStatus:
42
+ """Check auth status (read-only, no printing)."""
43
+ return self._wrapped.check_auth_status()
@@ -0,0 +1,119 @@
1
+ """Production implementation of GitHub Actions admin operations."""
2
+
3
+ import json
4
+ import subprocess
5
+ from typing import Any
6
+
7
+ from erk_shared.github.types import GitHubRepoLocation
8
+ from erk_shared.github_admin.abc import AuthStatus, GitHubAdmin
9
+ from erk_shared.subprocess_utils import run_subprocess_with_context
10
+
11
+
12
+ class RealGitHubAdmin(GitHubAdmin):
13
+ """Production implementation using gh CLI.
14
+
15
+ All GitHub Actions admin operations execute actual gh commands via subprocess.
16
+ """
17
+
18
+ def get_workflow_permissions(self, location: GitHubRepoLocation) -> dict[str, Any]:
19
+ """Get current workflow permissions using gh CLI.
20
+
21
+ Args:
22
+ location: GitHub repository location (local root + repo identity)
23
+
24
+ Returns:
25
+ Dict with keys:
26
+ - default_workflow_permissions: "read" or "write"
27
+ - can_approve_pull_request_reviews: bool
28
+
29
+ Raises:
30
+ RuntimeError: If gh CLI command fails
31
+ """
32
+ repo_id = location.repo_id
33
+ # GH-API-AUDIT: REST - GET actions/permissions/workflow
34
+ cmd = [
35
+ "gh",
36
+ "api",
37
+ "-H",
38
+ "Accept: application/vnd.github+json",
39
+ "-H",
40
+ "X-GitHub-Api-Version: 2022-11-28",
41
+ f"/repos/{repo_id.owner}/{repo_id.repo}/actions/permissions/workflow",
42
+ ]
43
+
44
+ result = run_subprocess_with_context(
45
+ cmd,
46
+ operation_context=f"get workflow permissions for {repo_id.owner}/{repo_id.repo}",
47
+ cwd=location.root,
48
+ )
49
+
50
+ return json.loads(result.stdout)
51
+
52
+ def set_workflow_pr_permissions(self, location: GitHubRepoLocation, enabled: bool) -> None:
53
+ """Enable/disable PR creation via workflow permissions API.
54
+
55
+ Args:
56
+ location: GitHub repository location (local root + repo identity)
57
+ enabled: True to enable PR creation, False to disable
58
+
59
+ Raises:
60
+ RuntimeError: If gh CLI command fails
61
+ """
62
+ # CRITICAL: Must set both fields together
63
+ # - default_workflow_permissions: Keep as "read" (workflows declare their own)
64
+ # - can_approve_pull_request_reviews: This enables PR creation
65
+ repo_id = location.repo_id
66
+ # GH-API-AUDIT: REST - PUT actions/permissions/workflow
67
+ cmd = [
68
+ "gh",
69
+ "api",
70
+ "--method",
71
+ "PUT",
72
+ "-H",
73
+ "Accept: application/vnd.github+json",
74
+ "-H",
75
+ "X-GitHub-Api-Version: 2022-11-28",
76
+ f"/repos/{repo_id.owner}/{repo_id.repo}/actions/permissions/workflow",
77
+ "-f",
78
+ "default_workflow_permissions=read",
79
+ "-F",
80
+ f"can_approve_pull_request_reviews={str(enabled).lower()}",
81
+ ]
82
+
83
+ run_subprocess_with_context(
84
+ cmd,
85
+ operation_context=f"set workflow PR permissions for {repo_id.owner}/{repo_id.repo}",
86
+ cwd=location.root,
87
+ )
88
+
89
+ def check_auth_status(self) -> AuthStatus:
90
+ """Check GitHub CLI authentication status using gh auth status."""
91
+ try:
92
+ # GH-API-AUDIT: REST - auth validation
93
+ result = subprocess.run(
94
+ ["gh", "auth", "status"],
95
+ capture_output=True,
96
+ text=True,
97
+ check=False,
98
+ timeout=10,
99
+ )
100
+ if result.returncode == 0:
101
+ # Parse output to find username
102
+ # Format: "✓ Logged in to github.com account username (keyring)"
103
+ output = result.stdout.strip() or result.stderr.strip()
104
+ username = None
105
+ for line in output.split("\n"):
106
+ if "Logged in to" in line and "account" in line:
107
+ # Extract username from "... account username (...)"
108
+ parts = line.split("account")
109
+ if len(parts) > 1:
110
+ username_part = parts[1].strip()
111
+ username = username_part.split()[0] if username_part else None
112
+ break
113
+ return AuthStatus(authenticated=True, username=username, error=None)
114
+ else:
115
+ return AuthStatus(authenticated=False, username=None, error=None)
116
+ except subprocess.TimeoutExpired:
117
+ return AuthStatus(authenticated=False, username=None, error="Auth check timed out")
118
+ except OSError as e:
119
+ return AuthStatus(authenticated=False, username=None, error=str(e))
erk/core/init_utils.py ADDED
@@ -0,0 +1,227 @@
1
+ """Pure business logic for init command operations.
2
+
3
+ This module contains testable functions for detecting project configuration,
4
+ discovering presets, and rendering templates, without I/O side effects.
5
+ """
6
+
7
+ import re
8
+ import tomllib
9
+ from pathlib import Path
10
+
11
+
12
+ def is_repo_erk_ified(repo_root: Path) -> bool:
13
+ """Check if a repository has been initialized with erk.
14
+
15
+ A repository is considered erk-ified if it has a .erk/config.toml file.
16
+
17
+ Args:
18
+ repo_root: Path to the repository root
19
+
20
+ Returns:
21
+ True if .erk/config.toml exists, False otherwise
22
+
23
+ Example:
24
+ >>> repo_root = Path("/path/to/repo")
25
+ >>> is_repo_erk_ified(repo_root)
26
+ False
27
+ """
28
+ config_path = repo_root / ".erk" / "config.toml"
29
+ return config_path.exists()
30
+
31
+
32
+ def detect_root_project_name(repo_root: Path) -> str | None:
33
+ """Return the declared project name at the repo root, if any.
34
+
35
+ Checks root `pyproject.toml`'s `[project].name`. If absent, tries to heuristically
36
+ extract from `setup.py` by matching `name="..."` or `name='...'`.
37
+
38
+ Args:
39
+ repo_root: Path to the repository root
40
+
41
+ Returns:
42
+ Project name if found, None otherwise
43
+
44
+ Example:
45
+ >>> repo_root = Path("/path/to/repo")
46
+ >>> # Assuming pyproject.toml exists with [project] name = "my-project"
47
+ >>> detect_root_project_name(repo_root)
48
+ 'my-project'
49
+ """
50
+ root_pyproject = repo_root / "pyproject.toml"
51
+ if root_pyproject.exists():
52
+ data = tomllib.loads(root_pyproject.read_text(encoding="utf-8"))
53
+ project = data.get("project") or {}
54
+ name = project.get("name")
55
+ if isinstance(name, str) and name:
56
+ return name
57
+
58
+ setup_py = repo_root / "setup.py"
59
+ if setup_py.exists():
60
+ text = setup_py.read_text(encoding="utf-8")
61
+ m = re.search(r"name\s*=\s*['\"]([^'\"]+)['\"]", text)
62
+ if m:
63
+ return m.group(1)
64
+
65
+ return None
66
+
67
+
68
+ def is_repo_named(repo_root: Path, expected_name: str) -> bool:
69
+ """Return True if the root project name matches `expected_name` (case-insensitive).
70
+
71
+ Args:
72
+ repo_root: Path to the repository root
73
+ expected_name: Expected project name to match
74
+
75
+ Returns:
76
+ True if project name matches (case-insensitive), False otherwise
77
+
78
+ Example:
79
+ >>> repo_root = Path("/path/to/repo")
80
+ >>> is_repo_named(repo_root, "dagster")
81
+ True
82
+ """
83
+ name = detect_root_project_name(repo_root)
84
+ return (name or "").lower() == expected_name.lower()
85
+
86
+
87
+ def discover_presets(presets_dir: Path) -> list[str]:
88
+ """Discover available preset names by scanning the presets directory.
89
+
90
+ Args:
91
+ presets_dir: Path to the directory containing preset files
92
+
93
+ Returns:
94
+ Sorted list of preset names (without .toml extension)
95
+
96
+ Example:
97
+ >>> presets_dir = Path("/path/to/erk/presets")
98
+ >>> discover_presets(presets_dir)
99
+ ['dagster', 'generic', 'python']
100
+ """
101
+ if not presets_dir.exists():
102
+ return []
103
+
104
+ return sorted(p.stem for p in presets_dir.glob("*.toml") if p.is_file())
105
+
106
+
107
+ def render_config_template(presets_dir: Path, preset: str | None) -> str:
108
+ """Return default config TOML content, optionally using a preset.
109
+
110
+ If preset is None, uses the "generic" preset by default.
111
+
112
+ Args:
113
+ presets_dir: Path to the directory containing preset files
114
+ preset: Name of the preset to use, or None for "generic"
115
+
116
+ Returns:
117
+ Content of the preset file as a string
118
+
119
+ Raises:
120
+ ValueError: If the specified preset file doesn't exist
121
+
122
+ Example:
123
+ >>> presets_dir = Path("/path/to/erk/presets")
124
+ >>> content = render_config_template(presets_dir, "dagster")
125
+ >>> "trunk_branch" in content
126
+ True
127
+ """
128
+ preset_name = preset if preset is not None else "generic"
129
+ preset_file = presets_dir / f"{preset_name}.toml"
130
+
131
+ if not preset_file.exists():
132
+ raise ValueError(f"Preset '{preset_name}' not found at {preset_file}")
133
+
134
+ return preset_file.read_text(encoding="utf-8")
135
+
136
+
137
+ def get_shell_wrapper_content(shell_integration_dir: Path, shell: str) -> str:
138
+ """Load the shell wrapper function for the given shell type.
139
+
140
+ Args:
141
+ shell_integration_dir: Path to the directory containing shell integration files
142
+ shell: Shell type (e.g., "zsh", "bash", "fish")
143
+
144
+ Returns:
145
+ Content of the shell wrapper file as a string
146
+
147
+ Raises:
148
+ ValueError: If the shell wrapper file doesn't exist for the given shell
149
+
150
+ Example:
151
+ >>> shell_dir = Path("/path/to/erk/shell_integration")
152
+ >>> content = get_shell_wrapper_content(shell_dir, "zsh")
153
+ >>> "function erk" in content
154
+ True
155
+ """
156
+ if shell == "fish":
157
+ wrapper_file = shell_integration_dir / "fish_wrapper.fish"
158
+ else:
159
+ wrapper_file = shell_integration_dir / f"{shell}_wrapper.sh"
160
+
161
+ if not wrapper_file.exists():
162
+ raise ValueError(f"Shell wrapper not found for {shell}")
163
+
164
+ return wrapper_file.read_text(encoding="utf-8")
165
+
166
+
167
+ # Marker string that identifies erk shell integration in RC files
168
+ ERK_SHELL_INTEGRATION_MARKER = "# Erk shell integration"
169
+
170
+
171
+ def has_shell_integration_in_rc(rc_path: Path) -> bool:
172
+ """Check if shell RC file contains erk shell integration.
173
+
174
+ Looks for the marker comment that erk adds when shell integration is configured.
175
+
176
+ Args:
177
+ rc_path: Path to the shell RC file (e.g., ~/.zshrc)
178
+
179
+ Returns:
180
+ True if the marker is found in the file, False otherwise
181
+ (also returns False if file doesn't exist)
182
+
183
+ Example:
184
+ >>> rc_path = Path.home() / ".zshrc"
185
+ >>> has_shell_integration_in_rc(rc_path)
186
+ False
187
+ """
188
+ if not rc_path.exists():
189
+ return False
190
+
191
+ content = rc_path.read_text(encoding="utf-8")
192
+ return ERK_SHELL_INTEGRATION_MARKER in content
193
+
194
+
195
+ def add_gitignore_entry(content: str, entry: str) -> str:
196
+ """Add an entry to gitignore content if not already present.
197
+
198
+ This is a pure function that returns the potentially modified content.
199
+ User confirmation should be handled by the caller.
200
+
201
+ Args:
202
+ content: Current gitignore content
203
+ entry: Entry to add (e.g., ".env")
204
+
205
+ Returns:
206
+ Updated gitignore content (original if entry already present)
207
+
208
+ Example:
209
+ >>> content = "*.pyc\\n"
210
+ >>> new_content = add_gitignore_entry(content, ".env")
211
+ >>> ".env" in new_content
212
+ True
213
+ >>> # Calling again should be idempotent
214
+ >>> newer_content = add_gitignore_entry(new_content, ".env")
215
+ >>> newer_content == new_content
216
+ True
217
+ """
218
+ # Entry already present
219
+ if entry in content:
220
+ return content
221
+
222
+ # Ensure trailing newline before adding
223
+ if not content.endswith("\n"):
224
+ content += "\n"
225
+
226
+ content += f"{entry}\n"
227
+ return content