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,317 @@
1
+ """Pure functions for Claude Code settings management.
2
+
3
+ This module provides functions to read and modify Claude Code settings,
4
+ specifically for managing permissions in the repo's .claude/settings.json.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ from collections import defaultdict
10
+ from collections.abc import Mapping
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ # The permission pattern that allows Claude to run erk commands without prompting
16
+ ERK_PERMISSION = "Bash(erk:*)"
17
+
18
+ # Hook commands for erk integration
19
+ ERK_USER_PROMPT_HOOK_COMMAND = "ERK_HOOK_ID=user-prompt-hook erk exec user-prompt-hook"
20
+ ERK_EXIT_PLAN_HOOK_COMMAND = "ERK_HOOK_ID=exit-plan-mode-hook erk exec exit-plan-mode-hook"
21
+
22
+ # Statusline command - can be overridden via ERK_STATUSLINE_COMMAND env var for dev mode
23
+ ERK_STATUSLINE_COMMAND = "uvx erk-statusline"
24
+
25
+
26
+ def get_erk_statusline_command() -> str:
27
+ """Get the statusline command, checking env var for dev mode override.
28
+
29
+ Returns:
30
+ ERK_STATUSLINE_COMMAND env var if set, otherwise "uvx erk-statusline".
31
+ """
32
+ return os.environ.get("ERK_STATUSLINE_COMMAND", ERK_STATUSLINE_COMMAND)
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class NoBackupCreated:
37
+ """Sentinel indicating no backup was created (file didn't exist)."""
38
+
39
+
40
+ def get_repo_claude_settings_path(repo_root: Path) -> Path:
41
+ """Return the path to the repo's Claude settings file.
42
+
43
+ Args:
44
+ repo_root: Path to the repository root
45
+
46
+ Returns:
47
+ Path to {repo_root}/.claude/settings.json
48
+ """
49
+ return repo_root / ".claude" / "settings.json"
50
+
51
+
52
+ def has_erk_permission(settings: dict) -> bool:
53
+ """Check if erk permission is configured in Claude settings.
54
+
55
+ Args:
56
+ settings: Parsed Claude settings dictionary
57
+
58
+ Returns:
59
+ True if Bash(erk:*) permission exists in permissions.allow list
60
+ """
61
+ permissions = settings.get("permissions", {})
62
+ allow_list = permissions.get("allow", [])
63
+ return ERK_PERMISSION in allow_list
64
+
65
+
66
+ def has_user_prompt_hook(settings: Mapping[str, Any]) -> bool:
67
+ """Check if erk UserPromptSubmit hook is configured.
68
+
69
+ Args:
70
+ settings: Parsed Claude settings dictionary
71
+
72
+ Returns:
73
+ True if the erk UserPromptSubmit hook is configured
74
+ """
75
+ hooks = settings.get("hooks", {})
76
+ user_prompt_hooks = hooks.get("UserPromptSubmit", [])
77
+ for entry in user_prompt_hooks:
78
+ for hook in entry.get("hooks", []):
79
+ if hook.get("command") == ERK_USER_PROMPT_HOOK_COMMAND:
80
+ return True
81
+ return False
82
+
83
+
84
+ def has_exit_plan_hook(settings: Mapping[str, Any]) -> bool:
85
+ """Check if erk ExitPlanMode hook is configured.
86
+
87
+ Args:
88
+ settings: Parsed Claude settings dictionary
89
+
90
+ Returns:
91
+ True if the erk ExitPlanMode PreToolUse hook is configured
92
+ """
93
+ hooks = settings.get("hooks", {})
94
+ pre_tool_hooks = hooks.get("PreToolUse", [])
95
+ for entry in pre_tool_hooks:
96
+ if entry.get("matcher") == "ExitPlanMode":
97
+ for hook in entry.get("hooks", []):
98
+ if hook.get("command") == ERK_EXIT_PLAN_HOOK_COMMAND:
99
+ return True
100
+ return False
101
+
102
+
103
+ def add_erk_hooks(settings: Mapping[str, Any]) -> dict[str, Any]:
104
+ """Return a new settings dict with erk hooks added.
105
+
106
+ This is a pure function that doesn't modify the input.
107
+ Adds missing hooks while preserving existing settings.
108
+
109
+ Args:
110
+ settings: Parsed Claude settings dictionary
111
+
112
+ Returns:
113
+ New settings dict with erk hooks added
114
+ """
115
+ # Deep copy to avoid mutating input
116
+ new_settings = json.loads(json.dumps(settings))
117
+
118
+ # Use defaultdict for cleaner hook list initialization
119
+ hooks: defaultdict[str, list] = defaultdict(list, new_settings.get("hooks", {}))
120
+
121
+ # Add UserPromptSubmit hook if missing
122
+ if not has_user_prompt_hook(settings):
123
+ hooks["UserPromptSubmit"].append(
124
+ {
125
+ "matcher": "*",
126
+ "hooks": [
127
+ {
128
+ "type": "command",
129
+ "command": ERK_USER_PROMPT_HOOK_COMMAND,
130
+ }
131
+ ],
132
+ }
133
+ )
134
+
135
+ # Add PreToolUse hook for ExitPlanMode if missing
136
+ if not has_exit_plan_hook(settings):
137
+ hooks["PreToolUse"].append(
138
+ {
139
+ "matcher": "ExitPlanMode",
140
+ "hooks": [
141
+ {
142
+ "type": "command",
143
+ "command": ERK_EXIT_PLAN_HOOK_COMMAND,
144
+ }
145
+ ],
146
+ }
147
+ )
148
+
149
+ new_settings["hooks"] = dict(hooks)
150
+ return new_settings
151
+
152
+
153
+ def add_erk_permission(settings: dict) -> dict:
154
+ """Return a new settings dict with erk permission added.
155
+
156
+ This is a pure function that doesn't modify the input.
157
+
158
+ Args:
159
+ settings: Parsed Claude settings dictionary
160
+
161
+ Returns:
162
+ New settings dict with Bash(erk:*) added to permissions.allow
163
+ """
164
+ # Deep copy to avoid mutating input
165
+ new_settings = json.loads(json.dumps(settings))
166
+
167
+ # Ensure permissions.allow exists
168
+ if "permissions" not in new_settings:
169
+ new_settings["permissions"] = {}
170
+ if "allow" not in new_settings["permissions"]:
171
+ new_settings["permissions"]["allow"] = []
172
+
173
+ # Add permission if not present
174
+ if ERK_PERMISSION not in new_settings["permissions"]["allow"]:
175
+ new_settings["permissions"]["allow"].append(ERK_PERMISSION)
176
+
177
+ return new_settings
178
+
179
+
180
+ def read_claude_settings(settings_path: Path) -> dict | None:
181
+ """Read and parse Claude settings from disk.
182
+
183
+ Args:
184
+ settings_path: Path to settings.json file
185
+
186
+ Returns:
187
+ Parsed settings dict, or None if file doesn't exist
188
+
189
+ Raises:
190
+ json.JSONDecodeError: If file contains invalid JSON
191
+ OSError: If file cannot be read
192
+ """
193
+ if not settings_path.exists():
194
+ return None
195
+
196
+ content = settings_path.read_text(encoding="utf-8")
197
+ return json.loads(content)
198
+
199
+
200
+ def write_claude_settings(settings_path: Path, settings: dict) -> Path | NoBackupCreated:
201
+ """Write Claude settings to disk.
202
+
203
+ Creates a backup of the existing file before writing (if it exists).
204
+
205
+ Args:
206
+ settings_path: Path to settings.json file
207
+ settings: Settings dict to write
208
+
209
+ Returns:
210
+ Path to backup file if created, NoBackupCreated sentinel otherwise.
211
+
212
+ Raises:
213
+ PermissionError: If unable to write to file
214
+ OSError: If unable to write to file
215
+ """
216
+ # Create backup of existing file (if it exists)
217
+ backup_result: Path | NoBackupCreated
218
+ if settings_path.exists():
219
+ backup_path = settings_path.with_suffix(".json.bak")
220
+ backup_path.write_bytes(settings_path.read_bytes())
221
+ backup_result = backup_path
222
+ else:
223
+ backup_result = NoBackupCreated()
224
+
225
+ # Ensure parent directory exists
226
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
227
+
228
+ # Write with pretty formatting to match Claude's style
229
+ content = json.dumps(settings, indent=2)
230
+ settings_path.write_text(content, encoding="utf-8")
231
+
232
+ return backup_result
233
+
234
+
235
+ @dataclass(frozen=True)
236
+ class StatuslineConfig:
237
+ """Represents the current statusline configuration."""
238
+
239
+ type: str
240
+ command: str
241
+
242
+
243
+ @dataclass(frozen=True)
244
+ class StatuslineNotConfigured:
245
+ """Sentinel indicating statusline is not configured."""
246
+
247
+
248
+ def has_statusline_configured(settings: dict) -> bool:
249
+ """Check if statusLine is configured in Claude settings.
250
+
251
+ Args:
252
+ settings: Parsed Claude settings dictionary
253
+
254
+ Returns:
255
+ True if statusLine configuration exists
256
+ """
257
+ return "statusLine" in settings
258
+
259
+
260
+ def get_statusline_config(settings: dict) -> StatuslineConfig | StatuslineNotConfigured:
261
+ """Get the current statusline configuration from settings.
262
+
263
+ Args:
264
+ settings: Parsed Claude settings dictionary
265
+
266
+ Returns:
267
+ StatuslineConfig if configured, StatuslineNotConfigured otherwise
268
+ """
269
+ statusline = settings.get("statusLine")
270
+ if statusline is None:
271
+ return StatuslineNotConfigured()
272
+
273
+ statusline_type = statusline.get("type")
274
+ command = statusline.get("command")
275
+
276
+ if statusline_type is None or command is None:
277
+ return StatuslineNotConfigured()
278
+
279
+ return StatuslineConfig(type=statusline_type, command=command)
280
+
281
+
282
+ def has_erk_statusline(settings: dict) -> bool:
283
+ """Check if erk-statusline is already configured.
284
+
285
+ Args:
286
+ settings: Parsed Claude settings dictionary
287
+
288
+ Returns:
289
+ True if statusLine is configured with a command containing "erk-statusline"
290
+ """
291
+ config = get_statusline_config(settings)
292
+ if isinstance(config, StatuslineNotConfigured):
293
+ return False
294
+ # Accept any command containing "erk-statusline" (with or without uvx prefix)
295
+ return "erk-statusline" in config.command
296
+
297
+
298
+ def add_erk_statusline(settings: dict) -> dict:
299
+ """Return a new settings dict with erk-statusline configured.
300
+
301
+ This is a pure function that doesn't modify the input.
302
+
303
+ Args:
304
+ settings: Parsed Claude settings dictionary
305
+
306
+ Returns:
307
+ New settings dict with statusLine configured
308
+ """
309
+ # Deep copy to avoid mutating input
310
+ new_settings = json.loads(json.dumps(settings))
311
+
312
+ new_settings["statusLine"] = {
313
+ "type": "command",
314
+ "command": get_erk_statusline_command(),
315
+ }
316
+
317
+ return new_settings