erk 0.4.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (331) hide show
  1. erk/__init__.py +12 -0
  2. erk/__main__.py +6 -0
  3. erk/agent_docs/__init__.py +5 -0
  4. erk/agent_docs/models.py +123 -0
  5. erk/agent_docs/operations.py +666 -0
  6. erk/artifacts/__init__.py +5 -0
  7. erk/artifacts/artifact_health.py +623 -0
  8. erk/artifacts/detection.py +16 -0
  9. erk/artifacts/discovery.py +343 -0
  10. erk/artifacts/models.py +63 -0
  11. erk/artifacts/staleness.py +56 -0
  12. erk/artifacts/state.py +100 -0
  13. erk/artifacts/sync.py +624 -0
  14. erk/cli/__init__.py +0 -0
  15. erk/cli/activation.py +132 -0
  16. erk/cli/alias.py +53 -0
  17. erk/cli/cli.py +221 -0
  18. erk/cli/commands/__init__.py +0 -0
  19. erk/cli/commands/admin.py +153 -0
  20. erk/cli/commands/artifact/__init__.py +1 -0
  21. erk/cli/commands/artifact/check.py +260 -0
  22. erk/cli/commands/artifact/group.py +31 -0
  23. erk/cli/commands/artifact/list_cmd.py +89 -0
  24. erk/cli/commands/artifact/show.py +62 -0
  25. erk/cli/commands/artifact/sync_cmd.py +39 -0
  26. erk/cli/commands/branch/__init__.py +26 -0
  27. erk/cli/commands/branch/assign_cmd.py +152 -0
  28. erk/cli/commands/branch/checkout_cmd.py +357 -0
  29. erk/cli/commands/branch/create_cmd.py +161 -0
  30. erk/cli/commands/branch/list_cmd.py +82 -0
  31. erk/cli/commands/branch/unassign_cmd.py +197 -0
  32. erk/cli/commands/cc/__init__.py +15 -0
  33. erk/cli/commands/cc/jsonl_cmd.py +20 -0
  34. erk/cli/commands/cc/session/AGENTS.md +30 -0
  35. erk/cli/commands/cc/session/CLAUDE.md +1 -0
  36. erk/cli/commands/cc/session/__init__.py +15 -0
  37. erk/cli/commands/cc/session/list_cmd.py +167 -0
  38. erk/cli/commands/cc/session/show_cmd.py +175 -0
  39. erk/cli/commands/completion.py +89 -0
  40. erk/cli/commands/completions.py +165 -0
  41. erk/cli/commands/config.py +327 -0
  42. erk/cli/commands/docs/__init__.py +1 -0
  43. erk/cli/commands/docs/group.py +16 -0
  44. erk/cli/commands/docs/sync.py +121 -0
  45. erk/cli/commands/docs/validate.py +102 -0
  46. erk/cli/commands/doctor.py +243 -0
  47. erk/cli/commands/down.py +171 -0
  48. erk/cli/commands/exec/__init__.py +1 -0
  49. erk/cli/commands/exec/group.py +164 -0
  50. erk/cli/commands/exec/scripts/AGENTS.md +79 -0
  51. erk/cli/commands/exec/scripts/CLAUDE.md +1 -0
  52. erk/cli/commands/exec/scripts/__init__.py +5 -0
  53. erk/cli/commands/exec/scripts/add_reaction_to_comment.py +69 -0
  54. erk/cli/commands/exec/scripts/add_remote_execution_note.py +68 -0
  55. erk/cli/commands/exec/scripts/check_impl.py +152 -0
  56. erk/cli/commands/exec/scripts/ci_update_pr_body.py +294 -0
  57. erk/cli/commands/exec/scripts/create_extraction_branch.py +138 -0
  58. erk/cli/commands/exec/scripts/create_extraction_plan.py +242 -0
  59. erk/cli/commands/exec/scripts/create_issue_from_session.py +103 -0
  60. erk/cli/commands/exec/scripts/create_plan_from_context.py +103 -0
  61. erk/cli/commands/exec/scripts/create_worker_impl_from_issue.py +93 -0
  62. erk/cli/commands/exec/scripts/detect_trunk_branch.py +121 -0
  63. erk/cli/commands/exec/scripts/exit_plan_mode_hook.py +777 -0
  64. erk/cli/commands/exec/scripts/extract_latest_plan.py +49 -0
  65. erk/cli/commands/exec/scripts/extract_session_from_issue.py +150 -0
  66. erk/cli/commands/exec/scripts/find_project_dir.py +214 -0
  67. erk/cli/commands/exec/scripts/generate_pr_summary.py +112 -0
  68. erk/cli/commands/exec/scripts/get_closing_text.py +98 -0
  69. erk/cli/commands/exec/scripts/get_embedded_prompt.py +62 -0
  70. erk/cli/commands/exec/scripts/get_plan_metadata.py +95 -0
  71. erk/cli/commands/exec/scripts/get_pr_body_footer.py +70 -0
  72. erk/cli/commands/exec/scripts/get_pr_discussion_comments.py +149 -0
  73. erk/cli/commands/exec/scripts/get_pr_review_comments.py +155 -0
  74. erk/cli/commands/exec/scripts/impl_init.py +158 -0
  75. erk/cli/commands/exec/scripts/impl_signal.py +375 -0
  76. erk/cli/commands/exec/scripts/impl_verify.py +49 -0
  77. erk/cli/commands/exec/scripts/issue_title_to_filename.py +34 -0
  78. erk/cli/commands/exec/scripts/list_sessions.py +296 -0
  79. erk/cli/commands/exec/scripts/mark_impl_ended.py +188 -0
  80. erk/cli/commands/exec/scripts/mark_impl_started.py +188 -0
  81. erk/cli/commands/exec/scripts/marker.py +163 -0
  82. erk/cli/commands/exec/scripts/objective_save_to_issue.py +109 -0
  83. erk/cli/commands/exec/scripts/plan_save_to_issue.py +269 -0
  84. erk/cli/commands/exec/scripts/plan_update_issue.py +147 -0
  85. erk/cli/commands/exec/scripts/post_extraction_comment.py +237 -0
  86. erk/cli/commands/exec/scripts/post_or_update_pr_summary.py +133 -0
  87. erk/cli/commands/exec/scripts/post_pr_inline_comment.py +143 -0
  88. erk/cli/commands/exec/scripts/post_workflow_started_comment.py +168 -0
  89. erk/cli/commands/exec/scripts/preprocess_session.py +777 -0
  90. erk/cli/commands/exec/scripts/quick_submit.py +32 -0
  91. erk/cli/commands/exec/scripts/rebase_with_conflict_resolution.py +260 -0
  92. erk/cli/commands/exec/scripts/reply_to_discussion_comment.py +173 -0
  93. erk/cli/commands/exec/scripts/resolve_review_thread.py +170 -0
  94. erk/cli/commands/exec/scripts/session_id_injector_hook.py +52 -0
  95. erk/cli/commands/exec/scripts/setup_impl_from_issue.py +159 -0
  96. erk/cli/commands/exec/scripts/slot_objective.py +102 -0
  97. erk/cli/commands/exec/scripts/tripwires_reminder_hook.py +20 -0
  98. erk/cli/commands/exec/scripts/update_dispatch_info.py +116 -0
  99. erk/cli/commands/exec/scripts/user_prompt_hook.py +113 -0
  100. erk/cli/commands/exec/scripts/validate_plan_content.py +98 -0
  101. erk/cli/commands/exec/scripts/wrap_plan_in_metadata_block.py +34 -0
  102. erk/cli/commands/implement.py +695 -0
  103. erk/cli/commands/implement_shared.py +649 -0
  104. erk/cli/commands/info/__init__.py +14 -0
  105. erk/cli/commands/info/release_notes_cmd.py +128 -0
  106. erk/cli/commands/init.py +801 -0
  107. erk/cli/commands/land_cmd.py +690 -0
  108. erk/cli/commands/log_cmd.py +137 -0
  109. erk/cli/commands/md/__init__.py +5 -0
  110. erk/cli/commands/md/check.py +118 -0
  111. erk/cli/commands/md/group.py +14 -0
  112. erk/cli/commands/navigation_helpers.py +430 -0
  113. erk/cli/commands/objective/__init__.py +16 -0
  114. erk/cli/commands/objective/list_cmd.py +47 -0
  115. erk/cli/commands/objective_helpers.py +132 -0
  116. erk/cli/commands/plan/__init__.py +32 -0
  117. erk/cli/commands/plan/check_cmd.py +174 -0
  118. erk/cli/commands/plan/close_cmd.py +69 -0
  119. erk/cli/commands/plan/create_cmd.py +120 -0
  120. erk/cli/commands/plan/docs/__init__.py +18 -0
  121. erk/cli/commands/plan/docs/extract_cmd.py +53 -0
  122. erk/cli/commands/plan/docs/unextract_cmd.py +38 -0
  123. erk/cli/commands/plan/docs/unextracted_cmd.py +72 -0
  124. erk/cli/commands/plan/extraction/__init__.py +16 -0
  125. erk/cli/commands/plan/extraction/complete_cmd.py +101 -0
  126. erk/cli/commands/plan/extraction/create_raw_cmd.py +63 -0
  127. erk/cli/commands/plan/get.py +71 -0
  128. erk/cli/commands/plan/list_cmd.py +754 -0
  129. erk/cli/commands/plan/log_cmd.py +440 -0
  130. erk/cli/commands/plan/start_cmd.py +459 -0
  131. erk/cli/commands/planner/__init__.py +40 -0
  132. erk/cli/commands/planner/configure_cmd.py +73 -0
  133. erk/cli/commands/planner/connect_cmd.py +96 -0
  134. erk/cli/commands/planner/create_cmd.py +148 -0
  135. erk/cli/commands/planner/list_cmd.py +51 -0
  136. erk/cli/commands/planner/register_cmd.py +105 -0
  137. erk/cli/commands/planner/set_default_cmd.py +23 -0
  138. erk/cli/commands/planner/unregister_cmd.py +43 -0
  139. erk/cli/commands/pr/__init__.py +23 -0
  140. erk/cli/commands/pr/check_cmd.py +112 -0
  141. erk/cli/commands/pr/checkout_cmd.py +165 -0
  142. erk/cli/commands/pr/fix_conflicts_cmd.py +82 -0
  143. erk/cli/commands/pr/parse_pr_reference.py +10 -0
  144. erk/cli/commands/pr/submit_cmd.py +360 -0
  145. erk/cli/commands/pr/sync_cmd.py +181 -0
  146. erk/cli/commands/prepare_cwd_recovery.py +60 -0
  147. erk/cli/commands/project/__init__.py +16 -0
  148. erk/cli/commands/project/init_cmd.py +91 -0
  149. erk/cli/commands/run/__init__.py +17 -0
  150. erk/cli/commands/run/list_cmd.py +189 -0
  151. erk/cli/commands/run/logs_cmd.py +54 -0
  152. erk/cli/commands/run/shared.py +19 -0
  153. erk/cli/commands/shell_integration.py +29 -0
  154. erk/cli/commands/slot/__init__.py +23 -0
  155. erk/cli/commands/slot/check_cmd.py +277 -0
  156. erk/cli/commands/slot/common.py +314 -0
  157. erk/cli/commands/slot/init_pool_cmd.py +157 -0
  158. erk/cli/commands/slot/list_cmd.py +228 -0
  159. erk/cli/commands/slot/repair_cmd.py +190 -0
  160. erk/cli/commands/stack/__init__.py +23 -0
  161. erk/cli/commands/stack/consolidate_cmd.py +470 -0
  162. erk/cli/commands/stack/list_cmd.py +79 -0
  163. erk/cli/commands/stack/move_cmd.py +309 -0
  164. erk/cli/commands/stack/split_old/README.md +64 -0
  165. erk/cli/commands/stack/split_old/__init__.py +5 -0
  166. erk/cli/commands/stack/split_old/command.py +233 -0
  167. erk/cli/commands/stack/split_old/display.py +116 -0
  168. erk/cli/commands/stack/split_old/plan.py +216 -0
  169. erk/cli/commands/status.py +58 -0
  170. erk/cli/commands/submit.py +768 -0
  171. erk/cli/commands/up.py +154 -0
  172. erk/cli/commands/upgrade.py +82 -0
  173. erk/cli/commands/wt/__init__.py +29 -0
  174. erk/cli/commands/wt/checkout_cmd.py +110 -0
  175. erk/cli/commands/wt/create_cmd.py +998 -0
  176. erk/cli/commands/wt/current_cmd.py +35 -0
  177. erk/cli/commands/wt/delete_cmd.py +573 -0
  178. erk/cli/commands/wt/list_cmd.py +332 -0
  179. erk/cli/commands/wt/rename_cmd.py +66 -0
  180. erk/cli/config.py +242 -0
  181. erk/cli/constants.py +29 -0
  182. erk/cli/core.py +65 -0
  183. erk/cli/debug.py +9 -0
  184. erk/cli/ensure-conversion-tasks.md +288 -0
  185. erk/cli/ensure.py +628 -0
  186. erk/cli/github_parsing.py +96 -0
  187. erk/cli/graphite.py +81 -0
  188. erk/cli/graphite_command.py +80 -0
  189. erk/cli/help_formatter.py +345 -0
  190. erk/cli/output.py +361 -0
  191. erk/cli/presets/dagster.toml +12 -0
  192. erk/cli/presets/generic.toml +12 -0
  193. erk/cli/prompt_hooks_templates/README.md +68 -0
  194. erk/cli/script_output.py +32 -0
  195. erk/cli/shell_integration/bash_wrapper.sh +32 -0
  196. erk/cli/shell_integration/fish_wrapper.fish +39 -0
  197. erk/cli/shell_integration/handler.py +338 -0
  198. erk/cli/shell_integration/zsh_wrapper.sh +32 -0
  199. erk/cli/shell_utils.py +171 -0
  200. erk/cli/subprocess_utils.py +92 -0
  201. erk/cli/uvx_detection.py +59 -0
  202. erk/core/__init__.py +0 -0
  203. erk/core/claude_executor.py +511 -0
  204. erk/core/claude_settings.py +317 -0
  205. erk/core/command_log.py +406 -0
  206. erk/core/commit_message_generator.py +234 -0
  207. erk/core/completion.py +10 -0
  208. erk/core/consolidation_utils.py +177 -0
  209. erk/core/context.py +570 -0
  210. erk/core/display/__init__.py +4 -0
  211. erk/core/display/abc.py +24 -0
  212. erk/core/display/real.py +30 -0
  213. erk/core/display_utils.py +526 -0
  214. erk/core/file_utils.py +87 -0
  215. erk/core/health_checks.py +1315 -0
  216. erk/core/health_checks_dogfooder/__init__.py +85 -0
  217. erk/core/health_checks_dogfooder/deprecated_dot_agent_config.py +64 -0
  218. erk/core/health_checks_dogfooder/legacy_claude_docs.py +69 -0
  219. erk/core/health_checks_dogfooder/legacy_config_locations.py +122 -0
  220. erk/core/health_checks_dogfooder/legacy_erk_docs_agent.py +61 -0
  221. erk/core/health_checks_dogfooder/legacy_erk_kits_folder.py +60 -0
  222. erk/core/health_checks_dogfooder/legacy_hook_settings.py +104 -0
  223. erk/core/health_checks_dogfooder/legacy_kit_yaml.py +78 -0
  224. erk/core/health_checks_dogfooder/legacy_kits_toml.py +43 -0
  225. erk/core/health_checks_dogfooder/outdated_erk_skill.py +43 -0
  226. erk/core/implementation_queue/__init__.py +1 -0
  227. erk/core/implementation_queue/github/__init__.py +8 -0
  228. erk/core/implementation_queue/github/abc.py +7 -0
  229. erk/core/implementation_queue/github/noop.py +38 -0
  230. erk/core/implementation_queue/github/printing.py +43 -0
  231. erk/core/implementation_queue/github/real.py +119 -0
  232. erk/core/init_utils.py +227 -0
  233. erk/core/output_filter.py +338 -0
  234. erk/core/plan_store/__init__.py +6 -0
  235. erk/core/planner/__init__.py +1 -0
  236. erk/core/planner/registry_abc.py +8 -0
  237. erk/core/planner/registry_fake.py +129 -0
  238. erk/core/planner/registry_real.py +195 -0
  239. erk/core/planner/types.py +7 -0
  240. erk/core/pr_utils.py +30 -0
  241. erk/core/release_notes.py +263 -0
  242. erk/core/repo_discovery.py +126 -0
  243. erk/core/script_writer.py +41 -0
  244. erk/core/services/__init__.py +1 -0
  245. erk/core/services/plan_list_service.py +94 -0
  246. erk/core/shell.py +51 -0
  247. erk/core/user_feedback.py +11 -0
  248. erk/core/version_check.py +55 -0
  249. erk/core/workflow_display.py +75 -0
  250. erk/core/worktree_pool.py +190 -0
  251. erk/core/worktree_utils.py +300 -0
  252. erk/data/CHANGELOG.md +438 -0
  253. erk/data/__init__.py +1 -0
  254. erk/data/claude/agents/devrun.md +180 -0
  255. erk/data/claude/commands/erk/__init__.py +0 -0
  256. erk/data/claude/commands/erk/create-extraction-plan.md +360 -0
  257. erk/data/claude/commands/erk/fix-conflicts.md +25 -0
  258. erk/data/claude/commands/erk/git-pr-push.md +345 -0
  259. erk/data/claude/commands/erk/implement-stacked-plan.md +96 -0
  260. erk/data/claude/commands/erk/land.md +193 -0
  261. erk/data/claude/commands/erk/objective-create.md +370 -0
  262. erk/data/claude/commands/erk/objective-list.md +34 -0
  263. erk/data/claude/commands/erk/objective-next-plan.md +220 -0
  264. erk/data/claude/commands/erk/objective-update-with-landed-pr.md +216 -0
  265. erk/data/claude/commands/erk/plan-implement.md +202 -0
  266. erk/data/claude/commands/erk/plan-save.md +45 -0
  267. erk/data/claude/commands/erk/plan-submit.md +39 -0
  268. erk/data/claude/commands/erk/pr-address.md +367 -0
  269. erk/data/claude/commands/erk/pr-submit.md +58 -0
  270. erk/data/claude/skills/dignified-python/SKILL.md +48 -0
  271. erk/data/claude/skills/dignified-python/cli-patterns.md +155 -0
  272. erk/data/claude/skills/dignified-python/dignified-python-core.md +1190 -0
  273. erk/data/claude/skills/dignified-python/subprocess.md +99 -0
  274. erk/data/claude/skills/dignified-python/versions/python-3.10.md +517 -0
  275. erk/data/claude/skills/dignified-python/versions/python-3.11.md +536 -0
  276. erk/data/claude/skills/dignified-python/versions/python-3.12.md +662 -0
  277. erk/data/claude/skills/dignified-python/versions/python-3.13.md +653 -0
  278. erk/data/claude/skills/erk-diff-analysis/SKILL.md +27 -0
  279. erk/data/claude/skills/erk-diff-analysis/references/commit-message-prompt.md +78 -0
  280. erk/data/claude/skills/learned-docs/SKILL.md +362 -0
  281. erk/data/github/actions/setup-claude-erk/action.yml +11 -0
  282. erk/data/github/prompts/dignified-python-review.md +125 -0
  283. erk/data/github/workflows/dignified-python-review.yml +61 -0
  284. erk/data/github/workflows/erk-impl.yml +251 -0
  285. erk/hooks/__init__.py +1 -0
  286. erk/hooks/decorators.py +319 -0
  287. erk/status/__init__.py +8 -0
  288. erk/status/collectors/__init__.py +9 -0
  289. erk/status/collectors/base.py +52 -0
  290. erk/status/collectors/git.py +76 -0
  291. erk/status/collectors/github.py +81 -0
  292. erk/status/collectors/graphite.py +80 -0
  293. erk/status/collectors/impl.py +145 -0
  294. erk/status/models/__init__.py +4 -0
  295. erk/status/models/status_data.py +404 -0
  296. erk/status/orchestrator.py +169 -0
  297. erk/status/renderers/__init__.py +5 -0
  298. erk/status/renderers/simple.py +322 -0
  299. erk/tui/AGENTS.md +193 -0
  300. erk/tui/CLAUDE.md +1 -0
  301. erk/tui/__init__.py +1 -0
  302. erk/tui/app.py +1404 -0
  303. erk/tui/commands/__init__.py +1 -0
  304. erk/tui/commands/executor.py +66 -0
  305. erk/tui/commands/provider.py +165 -0
  306. erk/tui/commands/real_executor.py +63 -0
  307. erk/tui/commands/registry.py +121 -0
  308. erk/tui/commands/types.py +36 -0
  309. erk/tui/data/__init__.py +1 -0
  310. erk/tui/data/provider.py +492 -0
  311. erk/tui/data/types.py +104 -0
  312. erk/tui/filtering/__init__.py +1 -0
  313. erk/tui/filtering/logic.py +43 -0
  314. erk/tui/filtering/types.py +55 -0
  315. erk/tui/jsonl_viewer/__init__.py +1 -0
  316. erk/tui/jsonl_viewer/app.py +61 -0
  317. erk/tui/jsonl_viewer/models.py +208 -0
  318. erk/tui/jsonl_viewer/widgets.py +204 -0
  319. erk/tui/sorting/__init__.py +6 -0
  320. erk/tui/sorting/logic.py +55 -0
  321. erk/tui/sorting/types.py +68 -0
  322. erk/tui/styles/dash.tcss +95 -0
  323. erk/tui/widgets/__init__.py +1 -0
  324. erk/tui/widgets/command_output.py +112 -0
  325. erk/tui/widgets/plan_table.py +276 -0
  326. erk/tui/widgets/status_bar.py +116 -0
  327. erk-0.4.5.dist-info/METADATA +376 -0
  328. erk-0.4.5.dist-info/RECORD +331 -0
  329. erk-0.4.5.dist-info/WHEEL +4 -0
  330. erk-0.4.5.dist-info/entry_points.txt +2 -0
  331. erk-0.4.5.dist-info/licenses/LICENSE.md +3 -0
erk/artifacts/sync.py ADDED
@@ -0,0 +1,624 @@
1
+ """Sync artifacts from erk package to project's .claude/ directory."""
2
+
3
+ import json
4
+ import shutil
5
+ from dataclasses import dataclass
6
+ from functools import cache
7
+ from pathlib import Path
8
+
9
+ from erk.artifacts.detection import is_in_erk_repo
10
+ from erk.artifacts.discovery import _compute_directory_hash, _compute_file_hash, _compute_hook_hash
11
+ from erk.artifacts.models import ArtifactFileState, ArtifactState
12
+ from erk.artifacts.state import save_artifact_state
13
+ from erk.core.claude_settings import (
14
+ ERK_EXIT_PLAN_HOOK_COMMAND,
15
+ ERK_USER_PROMPT_HOOK_COMMAND,
16
+ add_erk_hooks,
17
+ has_exit_plan_hook,
18
+ has_user_prompt_hook,
19
+ )
20
+ from erk.core.release_notes import get_current_version
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class SyncResult:
25
+ """Result of artifact sync operation."""
26
+
27
+ success: bool
28
+ artifacts_installed: int
29
+ message: str
30
+
31
+
32
+ @cache
33
+ def _get_erk_package_dir() -> Path:
34
+ """Get the erk package directory (where erk/__init__.py lives)."""
35
+ # __file__ is .../erk/artifacts/sync.py, so parent.parent is erk/
36
+ return Path(__file__).parent.parent
37
+
38
+
39
+ def _is_editable_install() -> bool:
40
+ """Check if erk is installed in editable mode.
41
+
42
+ Editable: erk package is in src/ layout (e.g., .../src/erk/)
43
+ Wheel: erk package is in site-packages (e.g., .../site-packages/erk/)
44
+ """
45
+ return "site-packages" not in str(_get_erk_package_dir().resolve())
46
+
47
+
48
+ @cache
49
+ def get_bundled_claude_dir() -> Path:
50
+ """Get path to bundled .claude/ directory in installed erk package.
51
+
52
+ For wheel installs: .claude/ is bundled as package data at erk/data/claude/
53
+ via pyproject.toml force-include.
54
+
55
+ For editable installs: .claude/ is at the erk repo root (no wheel is built,
56
+ so erk/data/ doesn't exist).
57
+ """
58
+ erk_package_dir = _get_erk_package_dir()
59
+
60
+ if _is_editable_install():
61
+ # Editable: erk package is at src/erk/, repo root is ../..
62
+ erk_repo_root = erk_package_dir.parent.parent
63
+ return erk_repo_root / ".claude"
64
+
65
+ # Wheel install: data is bundled at erk/data/claude/
66
+ return erk_package_dir / "data" / "claude"
67
+
68
+
69
+ @cache
70
+ def get_bundled_github_dir() -> Path:
71
+ """Get path to bundled .github/ directory in installed erk package.
72
+
73
+ For wheel installs: .github/ is bundled as package data at erk/data/github/
74
+ via pyproject.toml force-include.
75
+
76
+ For editable installs: .github/ is at the erk repo root.
77
+ """
78
+ erk_package_dir = _get_erk_package_dir()
79
+
80
+ if _is_editable_install():
81
+ # Editable: erk package is at src/erk/, repo root is ../..
82
+ erk_repo_root = erk_package_dir.parent.parent
83
+ return erk_repo_root / ".github"
84
+
85
+ # Wheel install: data is bundled at erk/data/github/
86
+ return erk_package_dir / "data" / "github"
87
+
88
+
89
+ def _copy_directory_contents(source_dir: Path, target_dir: Path) -> int:
90
+ """Copy directory contents recursively, returning count of files copied."""
91
+ if not source_dir.exists():
92
+ return 0
93
+
94
+ count = 0
95
+ for source_path in source_dir.rglob("*"):
96
+ if source_path.is_file():
97
+ relative = source_path.relative_to(source_dir)
98
+ target_path = target_dir / relative
99
+ target_path.parent.mkdir(parents=True, exist_ok=True)
100
+ shutil.copy2(source_path, target_path)
101
+ count += 1
102
+ return count
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class SyncedArtifact:
107
+ """Represents an artifact that was synced, with its computed hash."""
108
+
109
+ key: str # e.g. "skills/dignified-python", "commands/erk/plan-implement"
110
+ hash: str
111
+ file_count: int
112
+
113
+
114
+ def _sync_directory_artifacts(
115
+ source_dir: Path, target_dir: Path, names: frozenset[str], key_prefix: str
116
+ ) -> tuple[int, list[SyncedArtifact]]:
117
+ """Sync directory-based artifacts (skills) to project.
118
+
119
+ Args:
120
+ source_dir: Parent directory containing source artifacts (e.g., bundled/skills/)
121
+ target_dir: Parent directory for target artifacts (e.g., project/.claude/skills/)
122
+ names: Set of artifact names to sync (e.g., BUNDLED_SKILLS)
123
+ key_prefix: Prefix for artifact keys (e.g., "skills")
124
+
125
+ Returns tuple of (file_count, synced_artifacts).
126
+ """
127
+ if not source_dir.exists():
128
+ return 0, []
129
+
130
+ copied = 0
131
+ synced: list[SyncedArtifact] = []
132
+ for name in sorted(names):
133
+ source = source_dir / name
134
+ if source.exists():
135
+ target = target_dir / name
136
+ count = _copy_directory_contents(source, target)
137
+ copied += count
138
+ synced.append(
139
+ SyncedArtifact(
140
+ key=f"{key_prefix}/{name}",
141
+ hash=_compute_directory_hash(target),
142
+ file_count=count,
143
+ )
144
+ )
145
+ return copied, synced
146
+
147
+
148
+ def _sync_agent_artifacts(
149
+ source_dir: Path, target_dir: Path, names: frozenset[str]
150
+ ) -> tuple[int, list[SyncedArtifact]]:
151
+ """Sync agent artifacts to project (supports both directory-based and single-file).
152
+
153
+ Key format depends on structure:
154
+ - Directory: agents/{name} (like skills)
155
+ - Single-file: agents/{name}.md (like commands)
156
+
157
+ Returns tuple of (file_count, synced_artifacts).
158
+ """
159
+ if not source_dir.exists():
160
+ return 0, []
161
+
162
+ copied = 0
163
+ synced: list[SyncedArtifact] = []
164
+ for name in sorted(names):
165
+ source_dir_path = source_dir / name
166
+ source_file_path = source_dir / f"{name}.md"
167
+
168
+ # Directory-based takes precedence, then single-file
169
+ if source_dir_path.exists() and source_dir_path.is_dir():
170
+ target = target_dir / name
171
+ count = _copy_directory_contents(source_dir_path, target)
172
+ copied += count
173
+ synced.append(
174
+ SyncedArtifact(
175
+ key=f"agents/{name}",
176
+ hash=_compute_directory_hash(target),
177
+ file_count=count,
178
+ )
179
+ )
180
+ elif source_file_path.exists() and source_file_path.is_file():
181
+ target_dir.mkdir(parents=True, exist_ok=True)
182
+ target_file = target_dir / f"{name}.md"
183
+ shutil.copy2(source_file_path, target_file)
184
+ copied += 1
185
+ synced.append(
186
+ SyncedArtifact(
187
+ key=f"agents/{name}.md",
188
+ hash=_compute_file_hash(target_file),
189
+ file_count=1,
190
+ )
191
+ )
192
+ return copied, synced
193
+
194
+
195
+ def _sync_commands(
196
+ source_commands_dir: Path, target_commands_dir: Path
197
+ ) -> tuple[int, list[SyncedArtifact]]:
198
+ """Sync bundled commands to project. Only syncs erk namespace.
199
+
200
+ Returns tuple of (file_count, synced_artifacts).
201
+ Each command is tracked individually.
202
+ """
203
+ if not source_commands_dir.exists():
204
+ return 0, []
205
+
206
+ source = source_commands_dir / "erk"
207
+ if not source.exists():
208
+ return 0, []
209
+
210
+ target = target_commands_dir / "erk"
211
+ count = _copy_directory_contents(source, target)
212
+
213
+ # Track each command file individually
214
+ synced: list[SyncedArtifact] = []
215
+ if target.exists():
216
+ for cmd_file in target.glob("*.md"):
217
+ synced.append(
218
+ SyncedArtifact(
219
+ key=f"commands/erk/{cmd_file.name}",
220
+ hash=_compute_file_hash(cmd_file),
221
+ file_count=1,
222
+ )
223
+ )
224
+
225
+ return count, synced
226
+
227
+
228
+ def _sync_workflows(
229
+ bundled_github_dir: Path, target_workflows_dir: Path
230
+ ) -> tuple[int, list[SyncedArtifact]]:
231
+ """Sync erk-managed workflows to project's .github/workflows/ directory.
232
+
233
+ Only syncs files listed in BUNDLED_WORKFLOWS registry.
234
+ Returns tuple of (file_count, synced_artifacts).
235
+ """
236
+ # Inline import: artifact_health.py imports get_bundled_*_dir from this module
237
+ from erk.artifacts.artifact_health import BUNDLED_WORKFLOWS
238
+
239
+ source_workflows_dir = bundled_github_dir / "workflows"
240
+ if not source_workflows_dir.exists():
241
+ return 0, []
242
+
243
+ count = 0
244
+ synced: list[SyncedArtifact] = []
245
+ for workflow_name in sorted(BUNDLED_WORKFLOWS):
246
+ source_path = source_workflows_dir / workflow_name
247
+ if source_path.exists():
248
+ target_workflows_dir.mkdir(parents=True, exist_ok=True)
249
+ target_path = target_workflows_dir / workflow_name
250
+ shutil.copy2(source_path, target_path)
251
+ count += 1
252
+ synced.append(
253
+ SyncedArtifact(
254
+ key=f"workflows/{workflow_name}",
255
+ hash=_compute_file_hash(target_path),
256
+ file_count=1,
257
+ )
258
+ )
259
+ return count, synced
260
+
261
+
262
+ def _sync_actions(
263
+ bundled_github_dir: Path, target_actions_dir: Path
264
+ ) -> tuple[int, list[SyncedArtifact]]:
265
+ """Sync erk-managed actions to project's .github/actions/ directory.
266
+
267
+ Only syncs directories listed in BUNDLED_ACTIONS registry.
268
+ Returns tuple of (file_count, synced_artifacts).
269
+ """
270
+ # Inline import: artifact_health.py imports get_bundled_*_dir from this module
271
+ from erk.artifacts.artifact_health import BUNDLED_ACTIONS
272
+
273
+ source_actions_dir = bundled_github_dir / "actions"
274
+ if not source_actions_dir.exists():
275
+ return 0, []
276
+
277
+ count = 0
278
+ synced: list[SyncedArtifact] = []
279
+ for action_name in sorted(BUNDLED_ACTIONS):
280
+ source_path = source_actions_dir / action_name
281
+ if source_path.exists() and source_path.is_dir():
282
+ target_path = target_actions_dir / action_name
283
+ file_count = _copy_directory_contents(source_path, target_path)
284
+ count += file_count
285
+ synced.append(
286
+ SyncedArtifact(
287
+ key=f"actions/{action_name}",
288
+ hash=_compute_directory_hash(target_path),
289
+ file_count=file_count,
290
+ )
291
+ )
292
+ return count, synced
293
+
294
+
295
+ def _sync_hooks(target_claude_dir: Path) -> tuple[int, list[SyncedArtifact]]:
296
+ """Sync erk-managed hooks to project's .claude/settings.json.
297
+
298
+ Hooks are configuration entries, not files. This adds missing hooks
299
+ to settings.json using the existing add_erk_hooks() function.
300
+
301
+ Returns:
302
+ Tuple of (hooks_added, synced_artifacts)
303
+ """
304
+ settings_path = target_claude_dir / "settings.json"
305
+
306
+ # Read existing settings or start with empty
307
+ if settings_path.exists():
308
+ content = settings_path.read_text(encoding="utf-8")
309
+ settings = json.loads(content)
310
+ else:
311
+ settings = {}
312
+
313
+ # Count hooks before adding
314
+ had_user_prompt = has_user_prompt_hook(settings)
315
+ had_exit_plan = has_exit_plan_hook(settings)
316
+
317
+ # Add missing hooks
318
+ updated_settings = add_erk_hooks(settings)
319
+
320
+ # Write updated settings
321
+ target_claude_dir.mkdir(parents=True, exist_ok=True)
322
+ settings_path.write_text(json.dumps(updated_settings, indent=2), encoding="utf-8")
323
+
324
+ # Count how many hooks were newly added
325
+ added = 0
326
+ if not had_user_prompt:
327
+ added += 1
328
+ if not had_exit_plan:
329
+ added += 1
330
+
331
+ # ALWAYS record state for installed hooks (not just newly added)
332
+ # This ensures hooks from older erk versions get tracked in state.toml
333
+ synced: list[SyncedArtifact] = []
334
+ if has_user_prompt_hook(updated_settings):
335
+ synced.append(
336
+ SyncedArtifact(
337
+ key="hooks/user-prompt-hook",
338
+ hash=_compute_hook_hash(ERK_USER_PROMPT_HOOK_COMMAND),
339
+ file_count=1,
340
+ )
341
+ )
342
+ if has_exit_plan_hook(updated_settings):
343
+ synced.append(
344
+ SyncedArtifact(
345
+ key="hooks/exit-plan-mode-hook",
346
+ hash=_compute_hook_hash(ERK_EXIT_PLAN_HOOK_COMMAND),
347
+ file_count=1,
348
+ )
349
+ )
350
+ return added, synced
351
+
352
+
353
+ def _hash_directory_artifacts(
354
+ parent_dir: Path, names: frozenset[str], key_prefix: str
355
+ ) -> list[SyncedArtifact]:
356
+ """Compute hashes for directory-based artifacts without copying."""
357
+ if not parent_dir.exists():
358
+ return []
359
+
360
+ artifacts: list[SyncedArtifact] = []
361
+ for name in sorted(names):
362
+ artifact_dir = parent_dir / name
363
+ if artifact_dir.exists():
364
+ artifacts.append(
365
+ SyncedArtifact(
366
+ key=f"{key_prefix}/{name}",
367
+ hash=_compute_directory_hash(artifact_dir),
368
+ file_count=sum(1 for f in artifact_dir.rglob("*") if f.is_file()),
369
+ )
370
+ )
371
+ return artifacts
372
+
373
+
374
+ def _hash_agent_artifacts(agents_dir: Path, names: frozenset[str]) -> list[SyncedArtifact]:
375
+ """Compute hashes for agents (supports both directory-based and single-file).
376
+
377
+ Key format depends on structure:
378
+ - Directory: agents/{name} (like skills)
379
+ - Single-file: agents/{name}.md (like commands)
380
+ """
381
+ if not agents_dir.exists():
382
+ return []
383
+
384
+ artifacts: list[SyncedArtifact] = []
385
+ for name in sorted(names):
386
+ dir_path = agents_dir / name
387
+ file_path = agents_dir / f"{name}.md"
388
+
389
+ # Directory-based takes precedence, then single-file
390
+ if dir_path.exists() and dir_path.is_dir():
391
+ artifacts.append(
392
+ SyncedArtifact(
393
+ key=f"agents/{name}",
394
+ hash=_compute_directory_hash(dir_path),
395
+ file_count=sum(1 for f in dir_path.rglob("*") if f.is_file()),
396
+ )
397
+ )
398
+ elif file_path.exists() and file_path.is_file():
399
+ artifacts.append(
400
+ SyncedArtifact(
401
+ key=f"agents/{name}.md",
402
+ hash=_compute_file_hash(file_path),
403
+ file_count=1,
404
+ )
405
+ )
406
+ return artifacts
407
+
408
+
409
+ def _compute_source_artifact_state(project_dir: Path) -> list[SyncedArtifact]:
410
+ """Compute artifact state from source (for erk repo dogfooding).
411
+
412
+ Instead of copying files, just compute hashes from the source artifacts.
413
+ """
414
+ from erk.artifacts.artifact_health import (
415
+ BUNDLED_ACTIONS,
416
+ BUNDLED_AGENTS,
417
+ BUNDLED_SKILLS,
418
+ BUNDLED_WORKFLOWS,
419
+ )
420
+
421
+ bundled_claude_dir = get_bundled_claude_dir()
422
+ bundled_github_dir = get_bundled_github_dir()
423
+ artifacts: list[SyncedArtifact] = []
424
+
425
+ # Hash directory-based skills
426
+ skills_dir = bundled_claude_dir / "skills"
427
+ artifacts.extend(_hash_directory_artifacts(skills_dir, BUNDLED_SKILLS, "skills"))
428
+
429
+ # Hash agents (supports both directory-based and single-file)
430
+ artifacts.extend(_hash_agent_artifacts(bundled_claude_dir / "agents", BUNDLED_AGENTS))
431
+
432
+ # Hash commands from source
433
+ commands_dir = bundled_claude_dir / "commands" / "erk"
434
+ if commands_dir.exists():
435
+ for cmd_file in sorted(commands_dir.glob("*.md")):
436
+ artifacts.append(
437
+ SyncedArtifact(
438
+ key=f"commands/erk/{cmd_file.name}",
439
+ hash=_compute_file_hash(cmd_file),
440
+ file_count=1,
441
+ )
442
+ )
443
+
444
+ # Hash workflows from source
445
+ workflows_dir = bundled_github_dir / "workflows"
446
+ if workflows_dir.exists():
447
+ for workflow_name in sorted(BUNDLED_WORKFLOWS):
448
+ workflow_file = workflows_dir / workflow_name
449
+ if workflow_file.exists():
450
+ artifacts.append(
451
+ SyncedArtifact(
452
+ key=f"workflows/{workflow_name}",
453
+ hash=_compute_file_hash(workflow_file),
454
+ file_count=1,
455
+ )
456
+ )
457
+
458
+ # Hash actions from source
459
+ actions_dir = bundled_github_dir / "actions"
460
+ artifacts.extend(_hash_directory_artifacts(actions_dir, BUNDLED_ACTIONS, "actions"))
461
+
462
+ # Hash hooks (check if installed in settings.json)
463
+ settings_path = project_dir / ".claude" / "settings.json"
464
+ if settings_path.exists():
465
+ content = settings_path.read_text(encoding="utf-8")
466
+ settings = json.loads(content)
467
+ hook_checks = [
468
+ ("hooks/user-prompt-hook", has_user_prompt_hook, ERK_USER_PROMPT_HOOK_COMMAND),
469
+ ("hooks/exit-plan-mode-hook", has_exit_plan_hook, ERK_EXIT_PLAN_HOOK_COMMAND),
470
+ ]
471
+ for key, check_fn, command in hook_checks:
472
+ if check_fn(settings):
473
+ artifacts.append(
474
+ SyncedArtifact(key=key, hash=_compute_hook_hash(command), file_count=1)
475
+ )
476
+
477
+ return artifacts
478
+
479
+
480
+ def sync_dignified_review(project_dir: Path) -> SyncResult:
481
+ """Sync dignified-review feature artifacts to project.
482
+
483
+ Installs opt-in artifacts for the dignified-review workflow:
484
+ - dignified-python skill (.claude/skills/dignified-python/)
485
+ - dignified-python-review.yml workflow (.github/workflows/)
486
+ - dignified-python-review.md prompt (.github/prompts/)
487
+
488
+ Args:
489
+ project_dir: Project root directory
490
+
491
+ Returns:
492
+ SyncResult indicating success/failure and count of files installed.
493
+ """
494
+ bundled_claude_dir = get_bundled_claude_dir()
495
+ bundled_github_dir = get_bundled_github_dir()
496
+
497
+ target_claude_dir = project_dir / ".claude"
498
+ target_github_dir = project_dir / ".github"
499
+
500
+ total_copied = 0
501
+
502
+ # 1. Sync dignified-python skill
503
+ skill_src = bundled_claude_dir / "skills" / "dignified-python"
504
+ if skill_src.exists():
505
+ skill_dst = target_claude_dir / "skills" / "dignified-python"
506
+ count = _copy_directory_contents(skill_src, skill_dst)
507
+ total_copied += count
508
+
509
+ # 2. Sync dignified-python-review.yml workflow
510
+ workflow_src = bundled_github_dir / "workflows" / "dignified-python-review.yml"
511
+ if workflow_src.exists():
512
+ workflow_dst = target_github_dir / "workflows" / "dignified-python-review.yml"
513
+ workflow_dst.parent.mkdir(parents=True, exist_ok=True)
514
+ shutil.copy2(workflow_src, workflow_dst)
515
+ total_copied += 1
516
+
517
+ # 3. Sync dignified-python-review.md prompt
518
+ prompt_src = bundled_github_dir / "prompts" / "dignified-python-review.md"
519
+ if prompt_src.exists():
520
+ prompt_dst = target_github_dir / "prompts" / "dignified-python-review.md"
521
+ prompt_dst.parent.mkdir(parents=True, exist_ok=True)
522
+ shutil.copy2(prompt_src, prompt_dst)
523
+ total_copied += 1
524
+
525
+ return SyncResult(
526
+ success=True,
527
+ artifacts_installed=total_copied,
528
+ message=f"Installed dignified-review ({total_copied} files)",
529
+ )
530
+
531
+
532
+ def sync_artifacts(project_dir: Path, force: bool) -> SyncResult:
533
+ """Sync artifacts from erk package to project's .claude/ and .github/ directories.
534
+
535
+ When running in the erk repo itself, skips file copying but still computes
536
+ and saves state for dogfooding.
537
+ """
538
+ # Inline import: artifact_health.py imports get_bundled_*_dir from this module
539
+ from erk.artifacts.artifact_health import BUNDLED_AGENTS, BUNDLED_SKILLS
540
+
541
+ # In erk repo: skip copying but still save state for dogfooding
542
+ if is_in_erk_repo(project_dir):
543
+ all_synced = _compute_source_artifact_state(project_dir)
544
+ current_version = get_current_version()
545
+ files: dict[str, ArtifactFileState] = {}
546
+ for artifact in all_synced:
547
+ files[artifact.key] = ArtifactFileState(
548
+ version=current_version,
549
+ hash=artifact.hash,
550
+ )
551
+ save_artifact_state(project_dir, ArtifactState(version=current_version, files=files))
552
+ return SyncResult(
553
+ success=True,
554
+ artifacts_installed=0,
555
+ message="Development mode: state.toml updated (artifacts read from source)",
556
+ )
557
+
558
+ bundled_claude_dir = get_bundled_claude_dir()
559
+ if not bundled_claude_dir.exists():
560
+ return SyncResult(
561
+ success=False,
562
+ artifacts_installed=0,
563
+ message=f"Bundled .claude/ not found at {bundled_claude_dir}",
564
+ )
565
+
566
+ target_claude_dir = project_dir / ".claude"
567
+ target_claude_dir.mkdir(parents=True, exist_ok=True)
568
+
569
+ total_copied = 0
570
+ all_synced: list[SyncedArtifact] = []
571
+
572
+ # Sync directory-based skills
573
+ count, synced = _sync_directory_artifacts(
574
+ bundled_claude_dir / "skills", target_claude_dir / "skills", BUNDLED_SKILLS, "skills"
575
+ )
576
+ total_copied += count
577
+ all_synced.extend(synced)
578
+
579
+ # Sync agents (supports both directory-based and single-file)
580
+ count, synced = _sync_agent_artifacts(
581
+ bundled_claude_dir / "agents", target_claude_dir / "agents", BUNDLED_AGENTS
582
+ )
583
+ total_copied += count
584
+ all_synced.extend(synced)
585
+
586
+ count, synced = _sync_commands(bundled_claude_dir / "commands", target_claude_dir / "commands")
587
+ total_copied += count
588
+ all_synced.extend(synced)
589
+
590
+ # Sync workflows and actions from .github/
591
+ bundled_github_dir = get_bundled_github_dir()
592
+ if bundled_github_dir.exists():
593
+ target_workflows_dir = project_dir / ".github" / "workflows"
594
+ count, synced = _sync_workflows(bundled_github_dir, target_workflows_dir)
595
+ total_copied += count
596
+ all_synced.extend(synced)
597
+
598
+ target_actions_dir = project_dir / ".github" / "actions"
599
+ count, synced = _sync_actions(bundled_github_dir, target_actions_dir)
600
+ total_copied += count
601
+ all_synced.extend(synced)
602
+
603
+ # Sync hooks to settings.json
604
+ count, synced = _sync_hooks(target_claude_dir)
605
+ total_copied += count
606
+ all_synced.extend(synced)
607
+
608
+ # Build per-artifact state from synced artifacts
609
+ current_version = get_current_version()
610
+ files: dict[str, ArtifactFileState] = {}
611
+ for artifact in all_synced:
612
+ files[artifact.key] = ArtifactFileState(
613
+ version=current_version,
614
+ hash=artifact.hash,
615
+ )
616
+
617
+ # Save state with current version and per-artifact state
618
+ save_artifact_state(project_dir, ArtifactState(version=current_version, files=files))
619
+
620
+ return SyncResult(
621
+ success=True,
622
+ artifacts_installed=total_copied,
623
+ message=f"Synced {total_copied} artifact files",
624
+ )
erk/cli/__init__.py ADDED
File without changes