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.
- erk/__init__.py +12 -0
- erk/__main__.py +6 -0
- erk/agent_docs/__init__.py +5 -0
- erk/agent_docs/models.py +123 -0
- erk/agent_docs/operations.py +666 -0
- erk/artifacts/__init__.py +5 -0
- erk/artifacts/artifact_health.py +623 -0
- erk/artifacts/detection.py +16 -0
- erk/artifacts/discovery.py +343 -0
- erk/artifacts/models.py +63 -0
- erk/artifacts/staleness.py +56 -0
- erk/artifacts/state.py +100 -0
- erk/artifacts/sync.py +624 -0
- erk/cli/__init__.py +0 -0
- erk/cli/activation.py +132 -0
- erk/cli/alias.py +53 -0
- erk/cli/cli.py +221 -0
- erk/cli/commands/__init__.py +0 -0
- erk/cli/commands/admin.py +153 -0
- erk/cli/commands/artifact/__init__.py +1 -0
- erk/cli/commands/artifact/check.py +260 -0
- erk/cli/commands/artifact/group.py +31 -0
- erk/cli/commands/artifact/list_cmd.py +89 -0
- erk/cli/commands/artifact/show.py +62 -0
- erk/cli/commands/artifact/sync_cmd.py +39 -0
- erk/cli/commands/branch/__init__.py +26 -0
- erk/cli/commands/branch/assign_cmd.py +152 -0
- erk/cli/commands/branch/checkout_cmd.py +357 -0
- erk/cli/commands/branch/create_cmd.py +161 -0
- erk/cli/commands/branch/list_cmd.py +82 -0
- erk/cli/commands/branch/unassign_cmd.py +197 -0
- erk/cli/commands/cc/__init__.py +15 -0
- erk/cli/commands/cc/jsonl_cmd.py +20 -0
- erk/cli/commands/cc/session/AGENTS.md +30 -0
- erk/cli/commands/cc/session/CLAUDE.md +1 -0
- erk/cli/commands/cc/session/__init__.py +15 -0
- erk/cli/commands/cc/session/list_cmd.py +167 -0
- erk/cli/commands/cc/session/show_cmd.py +175 -0
- erk/cli/commands/completion.py +89 -0
- erk/cli/commands/completions.py +165 -0
- erk/cli/commands/config.py +327 -0
- erk/cli/commands/docs/__init__.py +1 -0
- erk/cli/commands/docs/group.py +16 -0
- erk/cli/commands/docs/sync.py +121 -0
- erk/cli/commands/docs/validate.py +102 -0
- erk/cli/commands/doctor.py +243 -0
- erk/cli/commands/down.py +171 -0
- erk/cli/commands/exec/__init__.py +1 -0
- erk/cli/commands/exec/group.py +164 -0
- erk/cli/commands/exec/scripts/AGENTS.md +79 -0
- erk/cli/commands/exec/scripts/CLAUDE.md +1 -0
- erk/cli/commands/exec/scripts/__init__.py +5 -0
- erk/cli/commands/exec/scripts/add_reaction_to_comment.py +69 -0
- erk/cli/commands/exec/scripts/add_remote_execution_note.py +68 -0
- erk/cli/commands/exec/scripts/check_impl.py +152 -0
- erk/cli/commands/exec/scripts/ci_update_pr_body.py +294 -0
- erk/cli/commands/exec/scripts/create_extraction_branch.py +138 -0
- erk/cli/commands/exec/scripts/create_extraction_plan.py +242 -0
- erk/cli/commands/exec/scripts/create_issue_from_session.py +103 -0
- erk/cli/commands/exec/scripts/create_plan_from_context.py +103 -0
- erk/cli/commands/exec/scripts/create_worker_impl_from_issue.py +93 -0
- erk/cli/commands/exec/scripts/detect_trunk_branch.py +121 -0
- erk/cli/commands/exec/scripts/exit_plan_mode_hook.py +777 -0
- erk/cli/commands/exec/scripts/extract_latest_plan.py +49 -0
- erk/cli/commands/exec/scripts/extract_session_from_issue.py +150 -0
- erk/cli/commands/exec/scripts/find_project_dir.py +214 -0
- erk/cli/commands/exec/scripts/generate_pr_summary.py +112 -0
- erk/cli/commands/exec/scripts/get_closing_text.py +98 -0
- erk/cli/commands/exec/scripts/get_embedded_prompt.py +62 -0
- erk/cli/commands/exec/scripts/get_plan_metadata.py +95 -0
- erk/cli/commands/exec/scripts/get_pr_body_footer.py +70 -0
- erk/cli/commands/exec/scripts/get_pr_discussion_comments.py +149 -0
- erk/cli/commands/exec/scripts/get_pr_review_comments.py +155 -0
- erk/cli/commands/exec/scripts/impl_init.py +158 -0
- erk/cli/commands/exec/scripts/impl_signal.py +375 -0
- erk/cli/commands/exec/scripts/impl_verify.py +49 -0
- erk/cli/commands/exec/scripts/issue_title_to_filename.py +34 -0
- erk/cli/commands/exec/scripts/list_sessions.py +296 -0
- erk/cli/commands/exec/scripts/mark_impl_ended.py +188 -0
- erk/cli/commands/exec/scripts/mark_impl_started.py +188 -0
- erk/cli/commands/exec/scripts/marker.py +163 -0
- erk/cli/commands/exec/scripts/objective_save_to_issue.py +109 -0
- erk/cli/commands/exec/scripts/plan_save_to_issue.py +269 -0
- erk/cli/commands/exec/scripts/plan_update_issue.py +147 -0
- erk/cli/commands/exec/scripts/post_extraction_comment.py +237 -0
- erk/cli/commands/exec/scripts/post_or_update_pr_summary.py +133 -0
- erk/cli/commands/exec/scripts/post_pr_inline_comment.py +143 -0
- erk/cli/commands/exec/scripts/post_workflow_started_comment.py +168 -0
- erk/cli/commands/exec/scripts/preprocess_session.py +777 -0
- erk/cli/commands/exec/scripts/quick_submit.py +32 -0
- erk/cli/commands/exec/scripts/rebase_with_conflict_resolution.py +260 -0
- erk/cli/commands/exec/scripts/reply_to_discussion_comment.py +173 -0
- erk/cli/commands/exec/scripts/resolve_review_thread.py +170 -0
- erk/cli/commands/exec/scripts/session_id_injector_hook.py +52 -0
- erk/cli/commands/exec/scripts/setup_impl_from_issue.py +159 -0
- erk/cli/commands/exec/scripts/slot_objective.py +102 -0
- erk/cli/commands/exec/scripts/tripwires_reminder_hook.py +20 -0
- erk/cli/commands/exec/scripts/update_dispatch_info.py +116 -0
- erk/cli/commands/exec/scripts/user_prompt_hook.py +113 -0
- erk/cli/commands/exec/scripts/validate_plan_content.py +98 -0
- erk/cli/commands/exec/scripts/wrap_plan_in_metadata_block.py +34 -0
- erk/cli/commands/implement.py +695 -0
- erk/cli/commands/implement_shared.py +649 -0
- erk/cli/commands/info/__init__.py +14 -0
- erk/cli/commands/info/release_notes_cmd.py +128 -0
- erk/cli/commands/init.py +801 -0
- erk/cli/commands/land_cmd.py +690 -0
- erk/cli/commands/log_cmd.py +137 -0
- erk/cli/commands/md/__init__.py +5 -0
- erk/cli/commands/md/check.py +118 -0
- erk/cli/commands/md/group.py +14 -0
- erk/cli/commands/navigation_helpers.py +430 -0
- erk/cli/commands/objective/__init__.py +16 -0
- erk/cli/commands/objective/list_cmd.py +47 -0
- erk/cli/commands/objective_helpers.py +132 -0
- erk/cli/commands/plan/__init__.py +32 -0
- erk/cli/commands/plan/check_cmd.py +174 -0
- erk/cli/commands/plan/close_cmd.py +69 -0
- erk/cli/commands/plan/create_cmd.py +120 -0
- erk/cli/commands/plan/docs/__init__.py +18 -0
- erk/cli/commands/plan/docs/extract_cmd.py +53 -0
- erk/cli/commands/plan/docs/unextract_cmd.py +38 -0
- erk/cli/commands/plan/docs/unextracted_cmd.py +72 -0
- erk/cli/commands/plan/extraction/__init__.py +16 -0
- erk/cli/commands/plan/extraction/complete_cmd.py +101 -0
- erk/cli/commands/plan/extraction/create_raw_cmd.py +63 -0
- erk/cli/commands/plan/get.py +71 -0
- erk/cli/commands/plan/list_cmd.py +754 -0
- erk/cli/commands/plan/log_cmd.py +440 -0
- erk/cli/commands/plan/start_cmd.py +459 -0
- erk/cli/commands/planner/__init__.py +40 -0
- erk/cli/commands/planner/configure_cmd.py +73 -0
- erk/cli/commands/planner/connect_cmd.py +96 -0
- erk/cli/commands/planner/create_cmd.py +148 -0
- erk/cli/commands/planner/list_cmd.py +51 -0
- erk/cli/commands/planner/register_cmd.py +105 -0
- erk/cli/commands/planner/set_default_cmd.py +23 -0
- erk/cli/commands/planner/unregister_cmd.py +43 -0
- erk/cli/commands/pr/__init__.py +23 -0
- erk/cli/commands/pr/check_cmd.py +112 -0
- erk/cli/commands/pr/checkout_cmd.py +165 -0
- erk/cli/commands/pr/fix_conflicts_cmd.py +82 -0
- erk/cli/commands/pr/parse_pr_reference.py +10 -0
- erk/cli/commands/pr/submit_cmd.py +360 -0
- erk/cli/commands/pr/sync_cmd.py +181 -0
- erk/cli/commands/prepare_cwd_recovery.py +60 -0
- erk/cli/commands/project/__init__.py +16 -0
- erk/cli/commands/project/init_cmd.py +91 -0
- erk/cli/commands/run/__init__.py +17 -0
- erk/cli/commands/run/list_cmd.py +189 -0
- erk/cli/commands/run/logs_cmd.py +54 -0
- erk/cli/commands/run/shared.py +19 -0
- erk/cli/commands/shell_integration.py +29 -0
- erk/cli/commands/slot/__init__.py +23 -0
- erk/cli/commands/slot/check_cmd.py +277 -0
- erk/cli/commands/slot/common.py +314 -0
- erk/cli/commands/slot/init_pool_cmd.py +157 -0
- erk/cli/commands/slot/list_cmd.py +228 -0
- erk/cli/commands/slot/repair_cmd.py +190 -0
- erk/cli/commands/stack/__init__.py +23 -0
- erk/cli/commands/stack/consolidate_cmd.py +470 -0
- erk/cli/commands/stack/list_cmd.py +79 -0
- erk/cli/commands/stack/move_cmd.py +309 -0
- erk/cli/commands/stack/split_old/README.md +64 -0
- erk/cli/commands/stack/split_old/__init__.py +5 -0
- erk/cli/commands/stack/split_old/command.py +233 -0
- erk/cli/commands/stack/split_old/display.py +116 -0
- erk/cli/commands/stack/split_old/plan.py +216 -0
- erk/cli/commands/status.py +58 -0
- erk/cli/commands/submit.py +768 -0
- erk/cli/commands/up.py +154 -0
- erk/cli/commands/upgrade.py +82 -0
- erk/cli/commands/wt/__init__.py +29 -0
- erk/cli/commands/wt/checkout_cmd.py +110 -0
- erk/cli/commands/wt/create_cmd.py +998 -0
- erk/cli/commands/wt/current_cmd.py +35 -0
- erk/cli/commands/wt/delete_cmd.py +573 -0
- erk/cli/commands/wt/list_cmd.py +332 -0
- erk/cli/commands/wt/rename_cmd.py +66 -0
- erk/cli/config.py +242 -0
- erk/cli/constants.py +29 -0
- erk/cli/core.py +65 -0
- erk/cli/debug.py +9 -0
- erk/cli/ensure-conversion-tasks.md +288 -0
- erk/cli/ensure.py +628 -0
- erk/cli/github_parsing.py +96 -0
- erk/cli/graphite.py +81 -0
- erk/cli/graphite_command.py +80 -0
- erk/cli/help_formatter.py +345 -0
- erk/cli/output.py +361 -0
- erk/cli/presets/dagster.toml +12 -0
- erk/cli/presets/generic.toml +12 -0
- erk/cli/prompt_hooks_templates/README.md +68 -0
- erk/cli/script_output.py +32 -0
- erk/cli/shell_integration/bash_wrapper.sh +32 -0
- erk/cli/shell_integration/fish_wrapper.fish +39 -0
- erk/cli/shell_integration/handler.py +338 -0
- erk/cli/shell_integration/zsh_wrapper.sh +32 -0
- erk/cli/shell_utils.py +171 -0
- erk/cli/subprocess_utils.py +92 -0
- erk/cli/uvx_detection.py +59 -0
- erk/core/__init__.py +0 -0
- erk/core/claude_executor.py +511 -0
- erk/core/claude_settings.py +317 -0
- erk/core/command_log.py +406 -0
- erk/core/commit_message_generator.py +234 -0
- erk/core/completion.py +10 -0
- erk/core/consolidation_utils.py +177 -0
- erk/core/context.py +570 -0
- erk/core/display/__init__.py +4 -0
- erk/core/display/abc.py +24 -0
- erk/core/display/real.py +30 -0
- erk/core/display_utils.py +526 -0
- erk/core/file_utils.py +87 -0
- erk/core/health_checks.py +1315 -0
- erk/core/health_checks_dogfooder/__init__.py +85 -0
- erk/core/health_checks_dogfooder/deprecated_dot_agent_config.py +64 -0
- erk/core/health_checks_dogfooder/legacy_claude_docs.py +69 -0
- erk/core/health_checks_dogfooder/legacy_config_locations.py +122 -0
- erk/core/health_checks_dogfooder/legacy_erk_docs_agent.py +61 -0
- erk/core/health_checks_dogfooder/legacy_erk_kits_folder.py +60 -0
- erk/core/health_checks_dogfooder/legacy_hook_settings.py +104 -0
- erk/core/health_checks_dogfooder/legacy_kit_yaml.py +78 -0
- erk/core/health_checks_dogfooder/legacy_kits_toml.py +43 -0
- erk/core/health_checks_dogfooder/outdated_erk_skill.py +43 -0
- erk/core/implementation_queue/__init__.py +1 -0
- erk/core/implementation_queue/github/__init__.py +8 -0
- erk/core/implementation_queue/github/abc.py +7 -0
- erk/core/implementation_queue/github/noop.py +38 -0
- erk/core/implementation_queue/github/printing.py +43 -0
- erk/core/implementation_queue/github/real.py +119 -0
- erk/core/init_utils.py +227 -0
- erk/core/output_filter.py +338 -0
- erk/core/plan_store/__init__.py +6 -0
- erk/core/planner/__init__.py +1 -0
- erk/core/planner/registry_abc.py +8 -0
- erk/core/planner/registry_fake.py +129 -0
- erk/core/planner/registry_real.py +195 -0
- erk/core/planner/types.py +7 -0
- erk/core/pr_utils.py +30 -0
- erk/core/release_notes.py +263 -0
- erk/core/repo_discovery.py +126 -0
- erk/core/script_writer.py +41 -0
- erk/core/services/__init__.py +1 -0
- erk/core/services/plan_list_service.py +94 -0
- erk/core/shell.py +51 -0
- erk/core/user_feedback.py +11 -0
- erk/core/version_check.py +55 -0
- erk/core/workflow_display.py +75 -0
- erk/core/worktree_pool.py +190 -0
- erk/core/worktree_utils.py +300 -0
- erk/data/CHANGELOG.md +438 -0
- erk/data/__init__.py +1 -0
- erk/data/claude/agents/devrun.md +180 -0
- erk/data/claude/commands/erk/__init__.py +0 -0
- erk/data/claude/commands/erk/create-extraction-plan.md +360 -0
- erk/data/claude/commands/erk/fix-conflicts.md +25 -0
- erk/data/claude/commands/erk/git-pr-push.md +345 -0
- erk/data/claude/commands/erk/implement-stacked-plan.md +96 -0
- erk/data/claude/commands/erk/land.md +193 -0
- erk/data/claude/commands/erk/objective-create.md +370 -0
- erk/data/claude/commands/erk/objective-list.md +34 -0
- erk/data/claude/commands/erk/objective-next-plan.md +220 -0
- erk/data/claude/commands/erk/objective-update-with-landed-pr.md +216 -0
- erk/data/claude/commands/erk/plan-implement.md +202 -0
- erk/data/claude/commands/erk/plan-save.md +45 -0
- erk/data/claude/commands/erk/plan-submit.md +39 -0
- erk/data/claude/commands/erk/pr-address.md +367 -0
- erk/data/claude/commands/erk/pr-submit.md +58 -0
- erk/data/claude/skills/dignified-python/SKILL.md +48 -0
- erk/data/claude/skills/dignified-python/cli-patterns.md +155 -0
- erk/data/claude/skills/dignified-python/dignified-python-core.md +1190 -0
- erk/data/claude/skills/dignified-python/subprocess.md +99 -0
- erk/data/claude/skills/dignified-python/versions/python-3.10.md +517 -0
- erk/data/claude/skills/dignified-python/versions/python-3.11.md +536 -0
- erk/data/claude/skills/dignified-python/versions/python-3.12.md +662 -0
- erk/data/claude/skills/dignified-python/versions/python-3.13.md +653 -0
- erk/data/claude/skills/erk-diff-analysis/SKILL.md +27 -0
- erk/data/claude/skills/erk-diff-analysis/references/commit-message-prompt.md +78 -0
- erk/data/claude/skills/learned-docs/SKILL.md +362 -0
- erk/data/github/actions/setup-claude-erk/action.yml +11 -0
- erk/data/github/prompts/dignified-python-review.md +125 -0
- erk/data/github/workflows/dignified-python-review.yml +61 -0
- erk/data/github/workflows/erk-impl.yml +251 -0
- erk/hooks/__init__.py +1 -0
- erk/hooks/decorators.py +319 -0
- erk/status/__init__.py +8 -0
- erk/status/collectors/__init__.py +9 -0
- erk/status/collectors/base.py +52 -0
- erk/status/collectors/git.py +76 -0
- erk/status/collectors/github.py +81 -0
- erk/status/collectors/graphite.py +80 -0
- erk/status/collectors/impl.py +145 -0
- erk/status/models/__init__.py +4 -0
- erk/status/models/status_data.py +404 -0
- erk/status/orchestrator.py +169 -0
- erk/status/renderers/__init__.py +5 -0
- erk/status/renderers/simple.py +322 -0
- erk/tui/AGENTS.md +193 -0
- erk/tui/CLAUDE.md +1 -0
- erk/tui/__init__.py +1 -0
- erk/tui/app.py +1404 -0
- erk/tui/commands/__init__.py +1 -0
- erk/tui/commands/executor.py +66 -0
- erk/tui/commands/provider.py +165 -0
- erk/tui/commands/real_executor.py +63 -0
- erk/tui/commands/registry.py +121 -0
- erk/tui/commands/types.py +36 -0
- erk/tui/data/__init__.py +1 -0
- erk/tui/data/provider.py +492 -0
- erk/tui/data/types.py +104 -0
- erk/tui/filtering/__init__.py +1 -0
- erk/tui/filtering/logic.py +43 -0
- erk/tui/filtering/types.py +55 -0
- erk/tui/jsonl_viewer/__init__.py +1 -0
- erk/tui/jsonl_viewer/app.py +61 -0
- erk/tui/jsonl_viewer/models.py +208 -0
- erk/tui/jsonl_viewer/widgets.py +204 -0
- erk/tui/sorting/__init__.py +6 -0
- erk/tui/sorting/logic.py +55 -0
- erk/tui/sorting/types.py +68 -0
- erk/tui/styles/dash.tcss +95 -0
- erk/tui/widgets/__init__.py +1 -0
- erk/tui/widgets/command_output.py +112 -0
- erk/tui/widgets/plan_table.py +276 -0
- erk/tui/widgets/status_bar.py +116 -0
- erk-0.4.5.dist-info/METADATA +376 -0
- erk-0.4.5.dist-info/RECORD +331 -0
- erk-0.4.5.dist-info/WHEEL +4 -0
- erk-0.4.5.dist-info/entry_points.txt +2 -0
- erk-0.4.5.dist-info/licenses/LICENSE.md +3 -0
|
@@ -0,0 +1,1315 @@
|
|
|
1
|
+
"""Health check implementations for erk doctor command.
|
|
2
|
+
|
|
3
|
+
This module provides diagnostic checks for erk setup, including
|
|
4
|
+
CLI availability, repository configuration, and Claude settings.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from erk.artifacts.artifact_health import (
|
|
12
|
+
ArtifactHealthResult,
|
|
13
|
+
ArtifactStatusType,
|
|
14
|
+
get_artifact_health,
|
|
15
|
+
)
|
|
16
|
+
from erk.artifacts.detection import is_in_erk_repo
|
|
17
|
+
from erk.artifacts.models import ArtifactFileState
|
|
18
|
+
from erk.artifacts.state import load_artifact_state
|
|
19
|
+
from erk.core.claude_settings import (
|
|
20
|
+
ERK_PERMISSION,
|
|
21
|
+
StatuslineNotConfigured,
|
|
22
|
+
get_repo_claude_settings_path,
|
|
23
|
+
get_statusline_config,
|
|
24
|
+
has_erk_permission,
|
|
25
|
+
has_erk_statusline,
|
|
26
|
+
has_exit_plan_hook,
|
|
27
|
+
read_claude_settings,
|
|
28
|
+
)
|
|
29
|
+
from erk.core.context import ErkContext
|
|
30
|
+
from erk.core.init_utils import has_shell_integration_in_rc
|
|
31
|
+
from erk.core.repo_discovery import RepoContext
|
|
32
|
+
from erk.core.version_check import get_required_version, is_version_mismatch
|
|
33
|
+
from erk_shared.extraction.claude_installation import ClaudeInstallation
|
|
34
|
+
from erk_shared.gateway.shell.abc import Shell
|
|
35
|
+
from erk_shared.github.issues.abc import GitHubIssues
|
|
36
|
+
from erk_shared.github.plan_issues import get_erk_label_definitions
|
|
37
|
+
from erk_shared.github_admin.abc import GitHubAdmin
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class CheckResult:
|
|
42
|
+
"""Result of a single health check.
|
|
43
|
+
|
|
44
|
+
Attributes:
|
|
45
|
+
name: Name of the check
|
|
46
|
+
passed: Whether the check passed
|
|
47
|
+
message: Human-readable message describing the result
|
|
48
|
+
details: Optional additional details (e.g., version info)
|
|
49
|
+
verbose_details: Extended details shown only in verbose mode
|
|
50
|
+
warning: If True and passed=True, displays ⚠️ instead of ✅
|
|
51
|
+
info: If True and passed=True, displays ℹ️ (informational, not success)
|
|
52
|
+
remediation: Optional command/action to fix a failing check
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
name: str
|
|
56
|
+
passed: bool
|
|
57
|
+
message: str
|
|
58
|
+
details: str | None = None
|
|
59
|
+
verbose_details: str | None = None
|
|
60
|
+
warning: bool = False
|
|
61
|
+
info: bool = False
|
|
62
|
+
remediation: str | None = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def check_erk_version() -> CheckResult:
|
|
66
|
+
"""Check erk CLI version."""
|
|
67
|
+
try:
|
|
68
|
+
from importlib.metadata import version
|
|
69
|
+
|
|
70
|
+
erk_version = version("erk")
|
|
71
|
+
return CheckResult(
|
|
72
|
+
name="erk",
|
|
73
|
+
passed=True,
|
|
74
|
+
message=f"erk CLI installed: v{erk_version}",
|
|
75
|
+
)
|
|
76
|
+
except Exception:
|
|
77
|
+
return CheckResult(
|
|
78
|
+
name="erk",
|
|
79
|
+
passed=False,
|
|
80
|
+
message="erk package not found",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _get_installed_erk_version() -> str | None:
|
|
85
|
+
"""Get installed erk version, or None if not installed."""
|
|
86
|
+
try:
|
|
87
|
+
from importlib.metadata import version
|
|
88
|
+
|
|
89
|
+
return version("erk")
|
|
90
|
+
except Exception:
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def check_required_tool_version(repo_root: Path) -> CheckResult:
|
|
95
|
+
"""Check that installed erk version matches the required version file.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
repo_root: Path to the repository root
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
CheckResult indicating:
|
|
102
|
+
- FAIL if version file missing
|
|
103
|
+
- FAIL with warning if versions mismatch
|
|
104
|
+
- PASS if versions match
|
|
105
|
+
"""
|
|
106
|
+
required_version = get_required_version(repo_root)
|
|
107
|
+
if required_version is None:
|
|
108
|
+
return CheckResult(
|
|
109
|
+
name="required-version",
|
|
110
|
+
passed=False,
|
|
111
|
+
message="Required version file missing (.erk/required-erk-uv-tool-version)",
|
|
112
|
+
remediation="Run 'erk init' to create this file",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
installed_version = _get_installed_erk_version()
|
|
116
|
+
if installed_version is None:
|
|
117
|
+
return CheckResult(
|
|
118
|
+
name="required-version",
|
|
119
|
+
passed=False,
|
|
120
|
+
message="Could not determine installed erk version",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
if is_version_mismatch(installed_version, required_version):
|
|
124
|
+
return CheckResult(
|
|
125
|
+
name="required-version",
|
|
126
|
+
passed=False,
|
|
127
|
+
message=f"Version mismatch: installed {installed_version}, required {required_version}",
|
|
128
|
+
remediation="Run 'uv tool upgrade erk' to update",
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
return CheckResult(
|
|
132
|
+
name="required-version",
|
|
133
|
+
passed=True,
|
|
134
|
+
message=f"erk version matches required ({required_version})",
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def check_claude_cli(shell: Shell) -> CheckResult:
|
|
139
|
+
"""Check if Claude CLI is installed and available in PATH.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
shell: Shell implementation for tool detection
|
|
143
|
+
"""
|
|
144
|
+
claude_path = shell.get_installed_tool_path("claude")
|
|
145
|
+
if claude_path is None:
|
|
146
|
+
return CheckResult(
|
|
147
|
+
name="claude",
|
|
148
|
+
passed=False,
|
|
149
|
+
message="Claude CLI not found in PATH",
|
|
150
|
+
details="Install from: https://claude.com/download",
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# Try to get version
|
|
154
|
+
version_output = shell.get_tool_version("claude")
|
|
155
|
+
if version_output is None:
|
|
156
|
+
return CheckResult(
|
|
157
|
+
name="claude",
|
|
158
|
+
passed=True,
|
|
159
|
+
message="Claude CLI found (version check failed)",
|
|
160
|
+
details="unknown",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# Parse version from output (format: "claude X.Y.Z")
|
|
164
|
+
version_str = version_output.split()[-1] if version_output else "unknown"
|
|
165
|
+
return CheckResult(
|
|
166
|
+
name="claude",
|
|
167
|
+
passed=True,
|
|
168
|
+
message=f"Claude CLI installed: {version_str}",
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def check_graphite_cli(shell: Shell) -> CheckResult:
|
|
173
|
+
"""Check if Graphite CLI (gt) is installed and available in PATH.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
shell: Shell implementation for tool detection
|
|
177
|
+
"""
|
|
178
|
+
gt_path = shell.get_installed_tool_path("gt")
|
|
179
|
+
if gt_path is None:
|
|
180
|
+
return CheckResult(
|
|
181
|
+
name="graphite",
|
|
182
|
+
passed=False,
|
|
183
|
+
message="Graphite CLI (gt) not found in PATH",
|
|
184
|
+
details="Install from: https://graphite.dev/docs/installing-the-cli",
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# Try to get version
|
|
188
|
+
version_output = shell.get_tool_version("gt")
|
|
189
|
+
if version_output is None:
|
|
190
|
+
return CheckResult(
|
|
191
|
+
name="graphite",
|
|
192
|
+
passed=True,
|
|
193
|
+
message="Graphite CLI found (version check failed)",
|
|
194
|
+
details="unknown",
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
return CheckResult(
|
|
198
|
+
name="graphite",
|
|
199
|
+
passed=True,
|
|
200
|
+
message=f"Graphite CLI installed: {version_output}",
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def check_github_cli(shell: Shell) -> CheckResult:
|
|
205
|
+
"""Check if GitHub CLI (gh) is installed and available in PATH.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
shell: Shell implementation for tool detection
|
|
209
|
+
"""
|
|
210
|
+
gh_path = shell.get_installed_tool_path("gh")
|
|
211
|
+
if gh_path is None:
|
|
212
|
+
return CheckResult(
|
|
213
|
+
name="github",
|
|
214
|
+
passed=False,
|
|
215
|
+
message="GitHub CLI (gh) not found in PATH",
|
|
216
|
+
details="Install from: https://cli.github.com/",
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
# Try to get version
|
|
220
|
+
version_output = shell.get_tool_version("gh")
|
|
221
|
+
if version_output is None:
|
|
222
|
+
return CheckResult(
|
|
223
|
+
name="github",
|
|
224
|
+
passed=True,
|
|
225
|
+
message="GitHub CLI found (version check failed)",
|
|
226
|
+
details="unknown",
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
# Take first line only (gh version has multi-line output)
|
|
230
|
+
version_first_line = version_output.split("\n")[0] if version_output else "unknown"
|
|
231
|
+
return CheckResult(
|
|
232
|
+
name="github",
|
|
233
|
+
passed=True,
|
|
234
|
+
message=f"GitHub CLI installed: {version_first_line}",
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def check_github_auth(shell: Shell, admin: GitHubAdmin) -> CheckResult:
|
|
239
|
+
"""Check if GitHub CLI is authenticated.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
shell: Shell implementation for tool detection
|
|
243
|
+
admin: GitHubAdmin implementation for auth status check
|
|
244
|
+
"""
|
|
245
|
+
gh_path = shell.get_installed_tool_path("gh")
|
|
246
|
+
if gh_path is None:
|
|
247
|
+
return CheckResult(
|
|
248
|
+
name="github-auth",
|
|
249
|
+
passed=False,
|
|
250
|
+
message="Cannot check auth: gh not installed",
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
auth_status = admin.check_auth_status()
|
|
254
|
+
|
|
255
|
+
if auth_status.error is not None:
|
|
256
|
+
return CheckResult(
|
|
257
|
+
name="github-auth",
|
|
258
|
+
passed=False,
|
|
259
|
+
message=f"Auth check failed: {auth_status.error}",
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
if auth_status.authenticated:
|
|
263
|
+
if auth_status.username:
|
|
264
|
+
return CheckResult(
|
|
265
|
+
name="github-auth",
|
|
266
|
+
passed=True,
|
|
267
|
+
message=f"GitHub authenticated as {auth_status.username}",
|
|
268
|
+
)
|
|
269
|
+
return CheckResult(
|
|
270
|
+
name="github-auth",
|
|
271
|
+
passed=True,
|
|
272
|
+
message="Authenticated to GitHub",
|
|
273
|
+
)
|
|
274
|
+
else:
|
|
275
|
+
return CheckResult(
|
|
276
|
+
name="github-auth",
|
|
277
|
+
passed=False,
|
|
278
|
+
message="Not authenticated to GitHub",
|
|
279
|
+
remediation="Run 'gh auth login' to authenticate",
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def check_workflow_permissions(ctx: ErkContext, repo_root: Path, admin: GitHubAdmin) -> CheckResult:
|
|
284
|
+
"""Check if GitHub Actions workflows can create PRs.
|
|
285
|
+
|
|
286
|
+
This is an info-level check - it always passes, but shows whether
|
|
287
|
+
PR creation is enabled for workflows. This is required for erk's
|
|
288
|
+
remote implementation feature.
|
|
289
|
+
|
|
290
|
+
Args:
|
|
291
|
+
ctx: ErkContext for repository access
|
|
292
|
+
repo_root: Path to the repository root
|
|
293
|
+
admin: GitHubAdmin implementation for API calls
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
CheckResult with info about workflow permission status
|
|
297
|
+
"""
|
|
298
|
+
# Need GitHub identity to check permissions
|
|
299
|
+
try:
|
|
300
|
+
remote_url = ctx.git.get_remote_url(repo_root, "origin")
|
|
301
|
+
except ValueError:
|
|
302
|
+
return CheckResult(
|
|
303
|
+
name="workflow-permissions",
|
|
304
|
+
passed=True, # Info level
|
|
305
|
+
message="No origin remote configured",
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
# Parse GitHub owner/repo from remote URL
|
|
309
|
+
from erk_shared.github.parsing import parse_git_remote_url
|
|
310
|
+
from erk_shared.github.types import GitHubRepoId, GitHubRepoLocation
|
|
311
|
+
|
|
312
|
+
try:
|
|
313
|
+
owner_repo = parse_git_remote_url(remote_url)
|
|
314
|
+
except ValueError:
|
|
315
|
+
return CheckResult(
|
|
316
|
+
name="workflow-permissions",
|
|
317
|
+
passed=True, # Info level
|
|
318
|
+
message="Not a GitHub repository",
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
repo_id = GitHubRepoId(owner=owner_repo[0], repo=owner_repo[1])
|
|
322
|
+
location = GitHubRepoLocation(root=repo_root, repo_id=repo_id)
|
|
323
|
+
|
|
324
|
+
try:
|
|
325
|
+
perms = admin.get_workflow_permissions(location)
|
|
326
|
+
enabled = perms.get("can_approve_pull_request_reviews", False)
|
|
327
|
+
|
|
328
|
+
if enabled:
|
|
329
|
+
return CheckResult(
|
|
330
|
+
name="workflow-permissions",
|
|
331
|
+
passed=True,
|
|
332
|
+
message="Workflows can create PRs",
|
|
333
|
+
)
|
|
334
|
+
else:
|
|
335
|
+
return CheckResult(
|
|
336
|
+
name="workflow-permissions",
|
|
337
|
+
passed=True, # Info level - always passes
|
|
338
|
+
message="Workflows cannot create PRs",
|
|
339
|
+
details="Run 'erk admin github-pr-setting --enable' to allow",
|
|
340
|
+
)
|
|
341
|
+
except Exception:
|
|
342
|
+
return CheckResult(
|
|
343
|
+
name="workflow-permissions",
|
|
344
|
+
passed=True, # Info level - don't fail on API errors
|
|
345
|
+
message="Could not check workflow permissions",
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def check_uv_version(shell: Shell) -> CheckResult:
|
|
350
|
+
"""Check if uv is installed.
|
|
351
|
+
|
|
352
|
+
Shows version and upgrade instructions. erk works best with recent uv versions.
|
|
353
|
+
|
|
354
|
+
Args:
|
|
355
|
+
shell: Shell implementation for tool detection
|
|
356
|
+
"""
|
|
357
|
+
uv_path = shell.get_installed_tool_path("uv")
|
|
358
|
+
if uv_path is None:
|
|
359
|
+
return CheckResult(
|
|
360
|
+
name="uv",
|
|
361
|
+
passed=False,
|
|
362
|
+
message="uv not found in PATH",
|
|
363
|
+
details="Install from: https://docs.astral.sh/uv/getting-started/installation/",
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
# Get installed version
|
|
367
|
+
version_output = shell.get_tool_version("uv")
|
|
368
|
+
if version_output is None:
|
|
369
|
+
return CheckResult(
|
|
370
|
+
name="uv",
|
|
371
|
+
passed=True,
|
|
372
|
+
message="uv found (version check failed)",
|
|
373
|
+
details="Upgrade: uv self update",
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
# Parse version (format: "uv 0.9.2" or "uv 0.9.2 (Homebrew 2025-10-10)")
|
|
377
|
+
parts = version_output.split()
|
|
378
|
+
version = parts[1] if len(parts) >= 2 else version_output
|
|
379
|
+
|
|
380
|
+
return CheckResult(
|
|
381
|
+
name="uv",
|
|
382
|
+
passed=True,
|
|
383
|
+
message=f"uv installed: {version}",
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def check_hooks_disabled(claude_installation: ClaudeInstallation) -> CheckResult:
|
|
388
|
+
"""Check if Claude Code hooks are globally disabled.
|
|
389
|
+
|
|
390
|
+
Checks global settings for hooks.disabled=true via the ClaudeInstallation gateway.
|
|
391
|
+
|
|
392
|
+
Args:
|
|
393
|
+
claude_installation: Gateway for accessing Claude settings
|
|
394
|
+
|
|
395
|
+
Returns a warning (not failure) if hooks are disabled, since the user
|
|
396
|
+
may have intentionally disabled them.
|
|
397
|
+
"""
|
|
398
|
+
disabled_in: list[str] = []
|
|
399
|
+
|
|
400
|
+
# Check global settings via gateway
|
|
401
|
+
settings = claude_installation.read_settings()
|
|
402
|
+
if settings:
|
|
403
|
+
hooks = settings.get("hooks", {})
|
|
404
|
+
if hooks.get("disabled") is True:
|
|
405
|
+
disabled_in.append("settings.json")
|
|
406
|
+
|
|
407
|
+
# Check local settings file directly (not yet in gateway)
|
|
408
|
+
local_settings_path = claude_installation.get_local_settings_path()
|
|
409
|
+
if local_settings_path.exists():
|
|
410
|
+
content = local_settings_path.read_text(encoding="utf-8")
|
|
411
|
+
local_settings = json.loads(content)
|
|
412
|
+
hooks = local_settings.get("hooks", {})
|
|
413
|
+
if hooks.get("disabled") is True:
|
|
414
|
+
disabled_in.append("settings.local.json")
|
|
415
|
+
|
|
416
|
+
if disabled_in:
|
|
417
|
+
return CheckResult(
|
|
418
|
+
name="claude-hooks",
|
|
419
|
+
passed=True, # Don't fail, just warn
|
|
420
|
+
warning=True,
|
|
421
|
+
message=f"Hooks disabled in {', '.join(disabled_in)}",
|
|
422
|
+
details="Set hooks.disabled=false or remove the setting to enable hooks",
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
return CheckResult(
|
|
426
|
+
name="claude-hooks",
|
|
427
|
+
passed=True,
|
|
428
|
+
message="Hooks enabled (not globally disabled)",
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def check_statusline_configured(claude_installation: ClaudeInstallation) -> CheckResult:
|
|
433
|
+
"""Check if erk-statusline is configured in global Claude settings.
|
|
434
|
+
|
|
435
|
+
This is an info-level check - it always passes, but informs users
|
|
436
|
+
they can configure the erk statusline feature.
|
|
437
|
+
|
|
438
|
+
Args:
|
|
439
|
+
claude_installation: Gateway for accessing Claude settings
|
|
440
|
+
|
|
441
|
+
Returns:
|
|
442
|
+
CheckResult with info about statusline status
|
|
443
|
+
"""
|
|
444
|
+
# Read settings via gateway
|
|
445
|
+
if not claude_installation.settings_exists():
|
|
446
|
+
return CheckResult(
|
|
447
|
+
name="statusline",
|
|
448
|
+
passed=True,
|
|
449
|
+
message="No global Claude settings (statusline not configured)",
|
|
450
|
+
details="Run 'erk init --statusline' to enable erk statusline",
|
|
451
|
+
info=True,
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
settings = claude_installation.read_settings()
|
|
455
|
+
|
|
456
|
+
if has_erk_statusline(settings):
|
|
457
|
+
return CheckResult(
|
|
458
|
+
name="statusline",
|
|
459
|
+
passed=True,
|
|
460
|
+
message="erk-statusline configured",
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
# Check if a different statusline is configured
|
|
464
|
+
statusline_config = get_statusline_config(settings)
|
|
465
|
+
if not isinstance(statusline_config, StatuslineNotConfigured):
|
|
466
|
+
return CheckResult(
|
|
467
|
+
name="statusline",
|
|
468
|
+
passed=True,
|
|
469
|
+
message=f"Different statusline configured: {statusline_config.command}",
|
|
470
|
+
details="Run 'erk init --statusline' to switch to erk statusline",
|
|
471
|
+
info=True,
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
return CheckResult(
|
|
475
|
+
name="statusline",
|
|
476
|
+
passed=True,
|
|
477
|
+
message="erk-statusline not configured",
|
|
478
|
+
details="Run 'erk init --statusline' to enable erk statusline",
|
|
479
|
+
info=True,
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def check_shell_integration(shell: Shell) -> CheckResult:
|
|
484
|
+
"""Check if shell integration is configured in user's shell RC file.
|
|
485
|
+
|
|
486
|
+
This is an info-level check - it always passes, but informs users
|
|
487
|
+
whether shell integration is configured for their shell.
|
|
488
|
+
|
|
489
|
+
Args:
|
|
490
|
+
shell: Shell implementation for detecting current shell
|
|
491
|
+
|
|
492
|
+
Returns:
|
|
493
|
+
CheckResult with info about shell integration status
|
|
494
|
+
"""
|
|
495
|
+
shell_info = shell.detect_shell()
|
|
496
|
+
if shell_info is None:
|
|
497
|
+
return CheckResult(
|
|
498
|
+
name="shell-integration",
|
|
499
|
+
passed=True,
|
|
500
|
+
message="Shell not detected (unsupported shell)",
|
|
501
|
+
info=True,
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
shell_name, rc_path = shell_info
|
|
505
|
+
|
|
506
|
+
if not rc_path.exists():
|
|
507
|
+
return CheckResult(
|
|
508
|
+
name="shell-integration",
|
|
509
|
+
passed=True,
|
|
510
|
+
message=f"Shell RC file not found ({rc_path.name})",
|
|
511
|
+
info=True,
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
if has_shell_integration_in_rc(rc_path):
|
|
515
|
+
return CheckResult(
|
|
516
|
+
name="shell-integration",
|
|
517
|
+
passed=True,
|
|
518
|
+
message=f"Shell integration configured ({shell_name})",
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
return CheckResult(
|
|
522
|
+
name="shell-integration",
|
|
523
|
+
passed=True,
|
|
524
|
+
message=f"Shell integration not configured ({shell_name})",
|
|
525
|
+
info=True,
|
|
526
|
+
remediation="Run 'erk init' to add shell integration",
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def check_gitignore_entries(repo_root: Path) -> CheckResult:
|
|
531
|
+
"""Check that required gitignore entries exist.
|
|
532
|
+
|
|
533
|
+
Args:
|
|
534
|
+
repo_root: Path to the repository root (where .gitignore should be located)
|
|
535
|
+
|
|
536
|
+
Returns:
|
|
537
|
+
CheckResult indicating whether required entries are present
|
|
538
|
+
"""
|
|
539
|
+
required_entries = [".erk/scratch/", ".impl/"]
|
|
540
|
+
gitignore_path = repo_root / ".gitignore"
|
|
541
|
+
|
|
542
|
+
# No gitignore file - pass (user may not have one yet)
|
|
543
|
+
if not gitignore_path.exists():
|
|
544
|
+
return CheckResult(
|
|
545
|
+
name="gitignore",
|
|
546
|
+
passed=True,
|
|
547
|
+
message="No .gitignore file (entries not needed yet)",
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
gitignore_content = gitignore_path.read_text(encoding="utf-8")
|
|
551
|
+
|
|
552
|
+
# Check for missing entries
|
|
553
|
+
missing_entries: list[str] = []
|
|
554
|
+
for entry in required_entries:
|
|
555
|
+
if entry not in gitignore_content:
|
|
556
|
+
missing_entries.append(entry)
|
|
557
|
+
|
|
558
|
+
if missing_entries:
|
|
559
|
+
return CheckResult(
|
|
560
|
+
name="gitignore",
|
|
561
|
+
passed=False,
|
|
562
|
+
message=f"Missing gitignore entries: {', '.join(missing_entries)}",
|
|
563
|
+
remediation="Run 'erk init' to add missing entries",
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
return CheckResult(
|
|
567
|
+
name="gitignore",
|
|
568
|
+
passed=True,
|
|
569
|
+
message="Required gitignore entries present",
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def check_post_plan_implement_ci_hook(repo_root: Path) -> CheckResult:
|
|
574
|
+
"""Check for post-plan-implement CI instructions hook.
|
|
575
|
+
|
|
576
|
+
When the hook file exists and has content, this returns a success (green).
|
|
577
|
+
When missing, it returns info-level with the path to create.
|
|
578
|
+
|
|
579
|
+
Args:
|
|
580
|
+
repo_root: Path to the repository root
|
|
581
|
+
|
|
582
|
+
Returns:
|
|
583
|
+
CheckResult with CI hook status
|
|
584
|
+
"""
|
|
585
|
+
hook_relative_path = ".erk/prompt-hooks/post-plan-implement-ci.md"
|
|
586
|
+
hook_path = repo_root / hook_relative_path
|
|
587
|
+
|
|
588
|
+
if hook_path.exists():
|
|
589
|
+
return CheckResult(
|
|
590
|
+
name="post-plan-implement-ci-hook",
|
|
591
|
+
passed=True,
|
|
592
|
+
message=f"CI instructions hook configured ({hook_relative_path})",
|
|
593
|
+
info=False,
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
return CheckResult(
|
|
597
|
+
name="post-plan-implement-ci-hook",
|
|
598
|
+
passed=True,
|
|
599
|
+
message=f"No CI instructions hook ({hook_relative_path})",
|
|
600
|
+
details=(
|
|
601
|
+
"Create .erk/prompt-hooks/post-plan-implement-ci.md "
|
|
602
|
+
"to add CI instructions for plan implementation"
|
|
603
|
+
),
|
|
604
|
+
info=True,
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def check_legacy_prompt_hooks(repo_root: Path) -> CheckResult:
|
|
609
|
+
"""Check for legacy prompt hook files that should be migrated.
|
|
610
|
+
|
|
611
|
+
Checks if .erk/post-implement.md exists (old location) and suggests
|
|
612
|
+
migration to the new .erk/prompt-hooks/ structure.
|
|
613
|
+
|
|
614
|
+
Args:
|
|
615
|
+
repo_root: Path to the repository root
|
|
616
|
+
|
|
617
|
+
Returns:
|
|
618
|
+
CheckResult with migration suggestion if old location found
|
|
619
|
+
"""
|
|
620
|
+
old_hook_path = repo_root / ".erk" / "post-implement.md"
|
|
621
|
+
new_hook_path = repo_root / ".erk" / "prompt-hooks" / "post-plan-implement-ci.md"
|
|
622
|
+
|
|
623
|
+
# Old location doesn't exist - all good
|
|
624
|
+
if not old_hook_path.exists():
|
|
625
|
+
return CheckResult(
|
|
626
|
+
name="legacy-prompt-hooks",
|
|
627
|
+
passed=True,
|
|
628
|
+
message="No legacy prompt hooks found",
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
# Old location exists and new location exists - user hasn't cleaned up
|
|
632
|
+
if new_hook_path.exists():
|
|
633
|
+
return CheckResult(
|
|
634
|
+
name="legacy-prompt-hooks",
|
|
635
|
+
passed=True,
|
|
636
|
+
warning=True,
|
|
637
|
+
message="Legacy prompt hook found alongside new location",
|
|
638
|
+
details=f"Remove old file: rm {old_hook_path.relative_to(repo_root)}",
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
# Old location exists, new location doesn't - needs migration
|
|
642
|
+
return CheckResult(
|
|
643
|
+
name="legacy-prompt-hooks",
|
|
644
|
+
passed=True,
|
|
645
|
+
warning=True,
|
|
646
|
+
message="Legacy prompt hook found (needs migration)",
|
|
647
|
+
details=(
|
|
648
|
+
f"Old: {old_hook_path.relative_to(repo_root)}\n"
|
|
649
|
+
f"New: {new_hook_path.relative_to(repo_root)}\n"
|
|
650
|
+
f"Run: mkdir -p .erk/prompt-hooks && "
|
|
651
|
+
f"mv {old_hook_path.relative_to(repo_root)} "
|
|
652
|
+
f"{new_hook_path.relative_to(repo_root)}"
|
|
653
|
+
),
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def check_claude_erk_permission(repo_root: Path) -> CheckResult:
|
|
658
|
+
"""Check if erk permission is configured in repo's Claude Code settings.
|
|
659
|
+
|
|
660
|
+
This is an info-level check - it always passes, but shows whether
|
|
661
|
+
the permission is configured or not. The permission allows Claude
|
|
662
|
+
to run erk commands without prompting.
|
|
663
|
+
|
|
664
|
+
Args:
|
|
665
|
+
repo_root: Path to the repository root
|
|
666
|
+
|
|
667
|
+
Returns:
|
|
668
|
+
CheckResult with info about permission status
|
|
669
|
+
"""
|
|
670
|
+
settings_path = get_repo_claude_settings_path(repo_root)
|
|
671
|
+
settings = read_claude_settings(settings_path)
|
|
672
|
+
if settings is None:
|
|
673
|
+
return CheckResult(
|
|
674
|
+
name="claude-erk-permission",
|
|
675
|
+
passed=True, # Info level - always passes
|
|
676
|
+
message="No .claude/settings.json in repo",
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
# Check for permission
|
|
680
|
+
if has_erk_permission(settings):
|
|
681
|
+
return CheckResult(
|
|
682
|
+
name="claude-erk-permission",
|
|
683
|
+
passed=True,
|
|
684
|
+
message=f"erk permission configured ({ERK_PERMISSION})",
|
|
685
|
+
)
|
|
686
|
+
else:
|
|
687
|
+
return CheckResult(
|
|
688
|
+
name="claude-erk-permission",
|
|
689
|
+
passed=True, # Info level - always passes
|
|
690
|
+
message="erk permission not configured",
|
|
691
|
+
details=f"Run 'erk init' to add {ERK_PERMISSION} to .claude/settings.json",
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def check_plans_repo_labels(
|
|
696
|
+
repo_root: Path,
|
|
697
|
+
plans_repo: str,
|
|
698
|
+
github_issues: GitHubIssues,
|
|
699
|
+
) -> CheckResult:
|
|
700
|
+
"""Check that required erk labels exist in the plans repository.
|
|
701
|
+
|
|
702
|
+
When plans_repo is configured, issues are created in that repository.
|
|
703
|
+
This check verifies that all erk labels (erk-plan, erk-extraction,
|
|
704
|
+
erk-objective) exist in the target repository.
|
|
705
|
+
|
|
706
|
+
Args:
|
|
707
|
+
repo_root: Path to the working repository root (for gh CLI context)
|
|
708
|
+
plans_repo: Target repository in "owner/repo" format
|
|
709
|
+
github_issues: GitHubIssues interface (should be configured with target_repo)
|
|
710
|
+
|
|
711
|
+
Returns:
|
|
712
|
+
CheckResult indicating whether labels are present
|
|
713
|
+
"""
|
|
714
|
+
labels = get_erk_label_definitions()
|
|
715
|
+
missing_labels: list[str] = []
|
|
716
|
+
|
|
717
|
+
# Check each label exists (LBYL pattern - check before reporting)
|
|
718
|
+
for label in labels:
|
|
719
|
+
if not github_issues.label_exists(repo_root, label.name):
|
|
720
|
+
missing_labels.append(label.name)
|
|
721
|
+
|
|
722
|
+
if missing_labels:
|
|
723
|
+
return CheckResult(
|
|
724
|
+
name="plans-repo-labels",
|
|
725
|
+
passed=False,
|
|
726
|
+
message=f"Missing labels in {plans_repo}: {', '.join(missing_labels)}",
|
|
727
|
+
remediation="Run 'erk init' to set up labels, or create them manually in GitHub",
|
|
728
|
+
)
|
|
729
|
+
|
|
730
|
+
return CheckResult(
|
|
731
|
+
name="plans-repo-labels",
|
|
732
|
+
passed=True,
|
|
733
|
+
message=f"Labels configured in {plans_repo}",
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def check_repository(ctx: ErkContext) -> CheckResult:
|
|
738
|
+
"""Check repository setup."""
|
|
739
|
+
# First check if we're in a git repo using git_common_dir
|
|
740
|
+
# (get_repository_root raises on non-git dirs, but git_common_dir returns None)
|
|
741
|
+
git_dir = ctx.git.get_git_common_dir(ctx.cwd)
|
|
742
|
+
if git_dir is None:
|
|
743
|
+
return CheckResult(
|
|
744
|
+
name="repository",
|
|
745
|
+
passed=False,
|
|
746
|
+
message="Not in a git repository",
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
# Now safe to get repo root
|
|
750
|
+
repo_root = ctx.git.get_repository_root(ctx.cwd)
|
|
751
|
+
|
|
752
|
+
# Check for .erk directory at repo root
|
|
753
|
+
erk_dir = repo_root / ".erk"
|
|
754
|
+
if not erk_dir.exists():
|
|
755
|
+
return CheckResult(
|
|
756
|
+
name="repository",
|
|
757
|
+
passed=True,
|
|
758
|
+
message="Git repository detected (no .erk/ directory)",
|
|
759
|
+
details="Run 'erk init' to set up erk for this repository",
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
return CheckResult(
|
|
763
|
+
name="repository",
|
|
764
|
+
passed=True,
|
|
765
|
+
message="Git repository with erk setup detected",
|
|
766
|
+
)
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def check_claude_settings(repo_root: Path) -> CheckResult:
|
|
770
|
+
"""Check Claude settings for misconfigurations.
|
|
771
|
+
|
|
772
|
+
Args:
|
|
773
|
+
repo_root: Path to the repository root (where .claude/ should be located)
|
|
774
|
+
|
|
775
|
+
Raises:
|
|
776
|
+
json.JSONDecodeError: If settings.json contains invalid JSON
|
|
777
|
+
"""
|
|
778
|
+
settings_path = repo_root / ".claude" / "settings.json"
|
|
779
|
+
settings = read_claude_settings(settings_path)
|
|
780
|
+
if settings is None:
|
|
781
|
+
return CheckResult(
|
|
782
|
+
name="claude-settings",
|
|
783
|
+
passed=True,
|
|
784
|
+
message="No .claude/settings.json (using defaults)",
|
|
785
|
+
)
|
|
786
|
+
|
|
787
|
+
return CheckResult(
|
|
788
|
+
name="claude-settings",
|
|
789
|
+
passed=True,
|
|
790
|
+
message=".claude/settings.json looks valid",
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def check_user_prompt_hook(repo_root: Path) -> CheckResult:
|
|
795
|
+
"""Check that the UserPromptSubmit hook is configured.
|
|
796
|
+
|
|
797
|
+
Verifies that .claude/settings.json contains the erk exec user-prompt-hook
|
|
798
|
+
command for the UserPromptSubmit event.
|
|
799
|
+
|
|
800
|
+
Args:
|
|
801
|
+
repo_root: Path to the repository root (where .claude/ should be located)
|
|
802
|
+
"""
|
|
803
|
+
settings_path = repo_root / ".claude" / "settings.json"
|
|
804
|
+
if not settings_path.exists():
|
|
805
|
+
return CheckResult(
|
|
806
|
+
name="user-prompt-hook",
|
|
807
|
+
passed=False,
|
|
808
|
+
message="No .claude/settings.json found",
|
|
809
|
+
remediation="Run 'erk init' to create settings with the hook configured",
|
|
810
|
+
)
|
|
811
|
+
# File exists, so read_claude_settings won't return None
|
|
812
|
+
settings = read_claude_settings(settings_path)
|
|
813
|
+
assert settings is not None # file existence already checked
|
|
814
|
+
|
|
815
|
+
# Look for UserPromptSubmit hooks
|
|
816
|
+
hooks = settings.get("hooks", {})
|
|
817
|
+
user_prompt_hooks = hooks.get("UserPromptSubmit", [])
|
|
818
|
+
|
|
819
|
+
if not user_prompt_hooks:
|
|
820
|
+
return CheckResult(
|
|
821
|
+
name="user-prompt-hook",
|
|
822
|
+
passed=False,
|
|
823
|
+
message="No UserPromptSubmit hook configured",
|
|
824
|
+
remediation="Add 'erk exec user-prompt-hook' hook to .claude/settings.json",
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
# Check if the unified hook is present (handles nested matcher structure)
|
|
828
|
+
expected_command = "erk exec user-prompt-hook"
|
|
829
|
+
for hook_entry in user_prompt_hooks:
|
|
830
|
+
if not isinstance(hook_entry, dict):
|
|
831
|
+
continue
|
|
832
|
+
# Handle nested structure: {matcher: ..., hooks: [...]}
|
|
833
|
+
nested_hooks = hook_entry.get("hooks", [])
|
|
834
|
+
if nested_hooks:
|
|
835
|
+
for hook in nested_hooks:
|
|
836
|
+
if not isinstance(hook, dict):
|
|
837
|
+
continue
|
|
838
|
+
command = hook.get("command", "")
|
|
839
|
+
if expected_command in command:
|
|
840
|
+
return CheckResult(
|
|
841
|
+
name="user-prompt-hook",
|
|
842
|
+
passed=True,
|
|
843
|
+
message="UserPromptSubmit hook configured",
|
|
844
|
+
)
|
|
845
|
+
# Handle flat structure: {type: command, command: ...}
|
|
846
|
+
command = hook_entry.get("command", "")
|
|
847
|
+
if expected_command in command:
|
|
848
|
+
return CheckResult(
|
|
849
|
+
name="user-prompt-hook",
|
|
850
|
+
passed=True,
|
|
851
|
+
message="UserPromptSubmit hook configured",
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
# Hook section exists but doesn't have the expected command
|
|
855
|
+
return CheckResult(
|
|
856
|
+
name="user-prompt-hook",
|
|
857
|
+
passed=False,
|
|
858
|
+
message="UserPromptSubmit hook missing unified hook script",
|
|
859
|
+
details=f"Expected command containing: {expected_command}",
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def check_exit_plan_hook(repo_root: Path) -> CheckResult:
|
|
864
|
+
"""Check that the ExitPlanMode hook is configured.
|
|
865
|
+
|
|
866
|
+
Verifies that .claude/settings.json contains the erk exec exit-plan-mode-hook
|
|
867
|
+
command for the PreToolUse ExitPlanMode matcher.
|
|
868
|
+
|
|
869
|
+
Args:
|
|
870
|
+
repo_root: Path to the repository root (where .claude/ should be located)
|
|
871
|
+
"""
|
|
872
|
+
settings_path = repo_root / ".claude" / "settings.json"
|
|
873
|
+
if not settings_path.exists():
|
|
874
|
+
return CheckResult(
|
|
875
|
+
name="exit-plan-hook",
|
|
876
|
+
passed=False,
|
|
877
|
+
message="No .claude/settings.json found",
|
|
878
|
+
remediation="Run 'erk init' to create settings with the hook configured",
|
|
879
|
+
)
|
|
880
|
+
# File exists, so read_claude_settings won't return None
|
|
881
|
+
settings = read_claude_settings(settings_path)
|
|
882
|
+
assert settings is not None # file existence already checked
|
|
883
|
+
|
|
884
|
+
if has_exit_plan_hook(settings):
|
|
885
|
+
return CheckResult(
|
|
886
|
+
name="exit-plan-hook",
|
|
887
|
+
passed=True,
|
|
888
|
+
message="ExitPlanMode hook configured",
|
|
889
|
+
)
|
|
890
|
+
|
|
891
|
+
return CheckResult(
|
|
892
|
+
name="exit-plan-hook",
|
|
893
|
+
passed=False,
|
|
894
|
+
message="ExitPlanMode hook not configured",
|
|
895
|
+
remediation="Run 'erk init' to add the hook to .claude/settings.json",
|
|
896
|
+
)
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
def check_hook_health(repo_root: Path) -> CheckResult:
|
|
900
|
+
"""Check hook execution health from recent logs.
|
|
901
|
+
|
|
902
|
+
Reads logs from .erk/scratch/sessions/*/hooks/*/*.json for the last 24 hours
|
|
903
|
+
and reports any failures (non-zero exit codes, exceptions).
|
|
904
|
+
|
|
905
|
+
Args:
|
|
906
|
+
repo_root: Path to the repository root
|
|
907
|
+
|
|
908
|
+
Returns:
|
|
909
|
+
CheckResult with hook health status
|
|
910
|
+
"""
|
|
911
|
+
from erk_shared.hooks.logging import read_recent_hook_logs
|
|
912
|
+
from erk_shared.hooks.types import HookExitStatus
|
|
913
|
+
|
|
914
|
+
logs = read_recent_hook_logs(repo_root, max_age_hours=24)
|
|
915
|
+
|
|
916
|
+
if not logs:
|
|
917
|
+
return CheckResult(
|
|
918
|
+
name="hooks",
|
|
919
|
+
passed=True,
|
|
920
|
+
message="No hook logs in last 24h",
|
|
921
|
+
)
|
|
922
|
+
|
|
923
|
+
# Count by status
|
|
924
|
+
success_count = 0
|
|
925
|
+
blocked_count = 0
|
|
926
|
+
error_count = 0
|
|
927
|
+
exception_count = 0
|
|
928
|
+
|
|
929
|
+
# Track failures by hook for detailed reporting
|
|
930
|
+
failures_by_hook: dict[str, list[tuple[str, str]]] = {}
|
|
931
|
+
|
|
932
|
+
for log in logs:
|
|
933
|
+
if log.exit_status == HookExitStatus.SUCCESS:
|
|
934
|
+
success_count += 1
|
|
935
|
+
elif log.exit_status == HookExitStatus.BLOCKED:
|
|
936
|
+
blocked_count += 1
|
|
937
|
+
elif log.exit_status == HookExitStatus.ERROR:
|
|
938
|
+
error_count += 1
|
|
939
|
+
hook_key = f"{log.kit_id}/{log.hook_id}"
|
|
940
|
+
if hook_key not in failures_by_hook:
|
|
941
|
+
failures_by_hook[hook_key] = []
|
|
942
|
+
failures_by_hook[hook_key].append(
|
|
943
|
+
(f"error (exit code {log.exit_code})", log.stderr[:200] if log.stderr else "")
|
|
944
|
+
)
|
|
945
|
+
elif log.exit_status == HookExitStatus.EXCEPTION:
|
|
946
|
+
exception_count += 1
|
|
947
|
+
hook_key = f"{log.kit_id}/{log.hook_id}"
|
|
948
|
+
if hook_key not in failures_by_hook:
|
|
949
|
+
failures_by_hook[hook_key] = []
|
|
950
|
+
failures_by_hook[hook_key].append(
|
|
951
|
+
("exception", log.error_message or log.stderr[:200] if log.stderr else "")
|
|
952
|
+
)
|
|
953
|
+
|
|
954
|
+
total_failures = error_count + exception_count
|
|
955
|
+
total_executions = success_count + blocked_count + error_count + exception_count
|
|
956
|
+
|
|
957
|
+
if total_failures == 0:
|
|
958
|
+
# Build verbose details showing execution stats
|
|
959
|
+
verbose_lines = [f"{total_executions} executions in last 24h"]
|
|
960
|
+
if success_count > 0:
|
|
961
|
+
verbose_lines.append(f" {success_count} successful")
|
|
962
|
+
if blocked_count > 0:
|
|
963
|
+
verbose_lines.append(f" {blocked_count} blocked (expected behavior)")
|
|
964
|
+
verbose_details = "\n".join(verbose_lines)
|
|
965
|
+
|
|
966
|
+
return CheckResult(
|
|
967
|
+
name="hooks",
|
|
968
|
+
passed=True,
|
|
969
|
+
message="Hooks healthy",
|
|
970
|
+
verbose_details=verbose_details,
|
|
971
|
+
)
|
|
972
|
+
|
|
973
|
+
# Build failure details
|
|
974
|
+
details_lines: list[str] = []
|
|
975
|
+
for hook_key, failures in failures_by_hook.items():
|
|
976
|
+
details_lines.append(f" {hook_key}: {len(failures)} failure(s)")
|
|
977
|
+
# Show most recent failure
|
|
978
|
+
if failures:
|
|
979
|
+
status, message = failures[0]
|
|
980
|
+
details_lines.append(f" Last failure: {status}")
|
|
981
|
+
if message:
|
|
982
|
+
# Truncate long messages
|
|
983
|
+
truncated = message[:100] + "..." if len(message) > 100 else message
|
|
984
|
+
details_lines.append(f" {truncated}")
|
|
985
|
+
|
|
986
|
+
return CheckResult(
|
|
987
|
+
name="hooks",
|
|
988
|
+
passed=False,
|
|
989
|
+
message=f"{total_failures} hook failure(s) in last 24h",
|
|
990
|
+
details="\n".join(details_lines),
|
|
991
|
+
)
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def _worst_status(statuses: list[ArtifactStatusType]) -> ArtifactStatusType:
|
|
995
|
+
"""Determine worst status from a list of statuses.
|
|
996
|
+
|
|
997
|
+
Priority: not-installed > locally-modified > changed-upstream > up-to-date
|
|
998
|
+
"""
|
|
999
|
+
if "not-installed" in statuses:
|
|
1000
|
+
return "not-installed"
|
|
1001
|
+
if "locally-modified" in statuses:
|
|
1002
|
+
return "locally-modified"
|
|
1003
|
+
if "changed-upstream" in statuses:
|
|
1004
|
+
return "changed-upstream"
|
|
1005
|
+
return "up-to-date"
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
def _extract_artifact_type(name: str) -> str:
|
|
1009
|
+
"""Extract artifact type from artifact name.
|
|
1010
|
+
|
|
1011
|
+
Examples:
|
|
1012
|
+
skills/dignified-python → skills
|
|
1013
|
+
commands/erk/plan-implement.md → commands
|
|
1014
|
+
agents/devrun → agents
|
|
1015
|
+
workflows/erk-impl.yml → workflows
|
|
1016
|
+
actions/setup-claude-erk → actions
|
|
1017
|
+
hooks/user-prompt-hook → hooks
|
|
1018
|
+
"""
|
|
1019
|
+
return name.split("/")[0]
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def _status_icon(status: ArtifactStatusType) -> str:
|
|
1023
|
+
"""Get status icon for artifact status."""
|
|
1024
|
+
if status == "up-to-date":
|
|
1025
|
+
return "✅"
|
|
1026
|
+
if status == "locally-modified" or status == "changed-upstream":
|
|
1027
|
+
return "⚠️"
|
|
1028
|
+
return "❌"
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
def _status_description(status: ArtifactStatusType, count: int) -> str:
|
|
1032
|
+
"""Get human-readable status description."""
|
|
1033
|
+
if status == "not-installed":
|
|
1034
|
+
if count == 1:
|
|
1035
|
+
return "not installed"
|
|
1036
|
+
return f"{count} not installed"
|
|
1037
|
+
if status == "locally-modified":
|
|
1038
|
+
if count == 1:
|
|
1039
|
+
return "locally modified"
|
|
1040
|
+
return f"{count} locally modified"
|
|
1041
|
+
if status == "changed-upstream":
|
|
1042
|
+
if count == 1:
|
|
1043
|
+
return "changed upstream"
|
|
1044
|
+
return f"{count} changed upstream"
|
|
1045
|
+
return ""
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
def _build_erk_repo_artifacts_result(result: ArtifactHealthResult) -> CheckResult:
|
|
1049
|
+
"""Build CheckResult for erk repo case (all artifacts from source)."""
|
|
1050
|
+
# Group artifacts by type, storing names
|
|
1051
|
+
by_type: dict[str, list[str]] = {}
|
|
1052
|
+
for artifact in result.artifacts:
|
|
1053
|
+
artifact_type = _extract_artifact_type(artifact.name)
|
|
1054
|
+
# Extract display name (e.g. "skills/dignified-python" -> "dignified-python")
|
|
1055
|
+
display_name = artifact.name.split("/", 1)[1] if "/" in artifact.name else artifact.name
|
|
1056
|
+
by_type.setdefault(artifact_type, []).append(display_name)
|
|
1057
|
+
|
|
1058
|
+
# Build per-type summary (all ✅) and verbose details with individual names
|
|
1059
|
+
type_summaries: list[str] = []
|
|
1060
|
+
verbose_summaries: list[str] = []
|
|
1061
|
+
type_order = ["skills", "commands", "agents", "workflows", "actions", "hooks"]
|
|
1062
|
+
for artifact_type in type_order:
|
|
1063
|
+
if artifact_type not in by_type:
|
|
1064
|
+
continue
|
|
1065
|
+
names = sorted(by_type[artifact_type])
|
|
1066
|
+
type_summaries.append(f" ✅ {artifact_type} ({len(names)})")
|
|
1067
|
+
verbose_summaries.append(f" ✅ {artifact_type} ({len(names)})")
|
|
1068
|
+
for name in names:
|
|
1069
|
+
verbose_summaries.append(f" {name}")
|
|
1070
|
+
|
|
1071
|
+
details = "\n".join(type_summaries)
|
|
1072
|
+
verbose_details = "\n".join(verbose_summaries)
|
|
1073
|
+
|
|
1074
|
+
return CheckResult(
|
|
1075
|
+
name="managed-artifacts",
|
|
1076
|
+
passed=True,
|
|
1077
|
+
message="Managed artifacts (from source)",
|
|
1078
|
+
details=details,
|
|
1079
|
+
verbose_details=verbose_details,
|
|
1080
|
+
)
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
@dataclass(frozen=True)
|
|
1084
|
+
class _ArtifactInfo:
|
|
1085
|
+
"""Internal: artifact name and status for grouping."""
|
|
1086
|
+
|
|
1087
|
+
name: str
|
|
1088
|
+
status: ArtifactStatusType
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
def _build_managed_artifacts_result(result: ArtifactHealthResult) -> CheckResult:
|
|
1092
|
+
"""Build CheckResult from ArtifactHealthResult."""
|
|
1093
|
+
# Group artifacts by type, storing name and status
|
|
1094
|
+
by_type: dict[str, list[_ArtifactInfo]] = {}
|
|
1095
|
+
for artifact in result.artifacts:
|
|
1096
|
+
artifact_type = _extract_artifact_type(artifact.name)
|
|
1097
|
+
# Extract display name (e.g. "skills/dignified-python" -> "dignified-python")
|
|
1098
|
+
display_name = artifact.name.split("/", 1)[1] if "/" in artifact.name else artifact.name
|
|
1099
|
+
by_type.setdefault(artifact_type, []).append(
|
|
1100
|
+
_ArtifactInfo(name=display_name, status=artifact.status)
|
|
1101
|
+
)
|
|
1102
|
+
|
|
1103
|
+
# Build per-type summary and verbose details
|
|
1104
|
+
type_summaries: list[str] = []
|
|
1105
|
+
verbose_summaries: list[str] = []
|
|
1106
|
+
overall_worst: ArtifactStatusType = "up-to-date"
|
|
1107
|
+
has_issues = False
|
|
1108
|
+
|
|
1109
|
+
# Consistent type ordering
|
|
1110
|
+
type_order = ["skills", "commands", "agents", "workflows", "actions", "hooks"]
|
|
1111
|
+
for artifact_type in type_order:
|
|
1112
|
+
if artifact_type not in by_type:
|
|
1113
|
+
continue
|
|
1114
|
+
|
|
1115
|
+
artifacts = by_type[artifact_type]
|
|
1116
|
+
statuses: list[ArtifactStatusType] = [a.status for a in artifacts]
|
|
1117
|
+
count = len(statuses)
|
|
1118
|
+
worst = _worst_status(statuses)
|
|
1119
|
+
|
|
1120
|
+
# Track overall worst for header
|
|
1121
|
+
if overall_worst == "up-to-date":
|
|
1122
|
+
overall_worst = worst
|
|
1123
|
+
elif worst == "not-installed":
|
|
1124
|
+
overall_worst = "not-installed"
|
|
1125
|
+
elif worst in ("locally-modified", "changed-upstream") and overall_worst not in (
|
|
1126
|
+
"not-installed",
|
|
1127
|
+
):
|
|
1128
|
+
overall_worst = worst
|
|
1129
|
+
|
|
1130
|
+
icon = _status_icon(worst)
|
|
1131
|
+
line = f" {icon} {artifact_type} ({count})"
|
|
1132
|
+
|
|
1133
|
+
# Add issue description if not up-to-date
|
|
1134
|
+
if worst != "up-to-date":
|
|
1135
|
+
has_issues = True
|
|
1136
|
+
issue_count = sum(1 for s in statuses if s == worst)
|
|
1137
|
+
desc = _status_description(worst, issue_count)
|
|
1138
|
+
line += f" - {desc}"
|
|
1139
|
+
|
|
1140
|
+
type_summaries.append(line)
|
|
1141
|
+
verbose_summaries.append(line)
|
|
1142
|
+
|
|
1143
|
+
# Add individual artifact names to verbose output
|
|
1144
|
+
for artifact_info in sorted(artifacts, key=lambda a: a.name):
|
|
1145
|
+
if artifact_info.status == "up-to-date":
|
|
1146
|
+
status_indicator = ""
|
|
1147
|
+
else:
|
|
1148
|
+
status_indicator = f" ({artifact_info.status})"
|
|
1149
|
+
verbose_summaries.append(f" {artifact_info.name}{status_indicator}")
|
|
1150
|
+
|
|
1151
|
+
details = "\n".join(type_summaries)
|
|
1152
|
+
verbose_details = "\n".join(verbose_summaries)
|
|
1153
|
+
|
|
1154
|
+
# Determine remediation
|
|
1155
|
+
remediation: str | None = None
|
|
1156
|
+
if overall_worst == "not-installed":
|
|
1157
|
+
remediation = "Run 'erk artifact sync' to restore missing artifacts"
|
|
1158
|
+
|
|
1159
|
+
# Determine overall result
|
|
1160
|
+
if overall_worst == "not-installed":
|
|
1161
|
+
return CheckResult(
|
|
1162
|
+
name="managed-artifacts",
|
|
1163
|
+
passed=False,
|
|
1164
|
+
message="Managed artifacts have issues",
|
|
1165
|
+
details=details,
|
|
1166
|
+
verbose_details=verbose_details,
|
|
1167
|
+
remediation=remediation,
|
|
1168
|
+
)
|
|
1169
|
+
elif has_issues:
|
|
1170
|
+
return CheckResult(
|
|
1171
|
+
name="managed-artifacts",
|
|
1172
|
+
passed=True,
|
|
1173
|
+
warning=True,
|
|
1174
|
+
message="Managed artifacts have issues",
|
|
1175
|
+
details=details,
|
|
1176
|
+
verbose_details=verbose_details,
|
|
1177
|
+
remediation=remediation,
|
|
1178
|
+
)
|
|
1179
|
+
else:
|
|
1180
|
+
return CheckResult(
|
|
1181
|
+
name="managed-artifacts",
|
|
1182
|
+
passed=True,
|
|
1183
|
+
message="Managed artifacts healthy",
|
|
1184
|
+
details=details,
|
|
1185
|
+
verbose_details=verbose_details,
|
|
1186
|
+
)
|
|
1187
|
+
|
|
1188
|
+
|
|
1189
|
+
def check_managed_artifacts(repo_root: Path) -> CheckResult:
|
|
1190
|
+
"""Check status of erk-managed artifacts.
|
|
1191
|
+
|
|
1192
|
+
Shows a summary of artifact status by type (skills, commands, agents, etc.)
|
|
1193
|
+
with per-type counts and status indicators.
|
|
1194
|
+
|
|
1195
|
+
Args:
|
|
1196
|
+
repo_root: Path to the repository root
|
|
1197
|
+
|
|
1198
|
+
Returns:
|
|
1199
|
+
CheckResult with artifact health status
|
|
1200
|
+
"""
|
|
1201
|
+
in_erk_repo = is_in_erk_repo(repo_root)
|
|
1202
|
+
|
|
1203
|
+
# Check for .claude/ directory
|
|
1204
|
+
claude_dir = repo_root / ".claude"
|
|
1205
|
+
if not claude_dir.exists():
|
|
1206
|
+
return CheckResult(
|
|
1207
|
+
name="managed-artifacts",
|
|
1208
|
+
passed=True,
|
|
1209
|
+
message="No .claude/ directory (nothing to check)",
|
|
1210
|
+
)
|
|
1211
|
+
|
|
1212
|
+
# Load saved artifact state
|
|
1213
|
+
state = load_artifact_state(repo_root)
|
|
1214
|
+
saved_files: dict[str, ArtifactFileState] = dict(state.files) if state else {}
|
|
1215
|
+
|
|
1216
|
+
# Get artifact health
|
|
1217
|
+
result = get_artifact_health(repo_root, saved_files)
|
|
1218
|
+
|
|
1219
|
+
# Handle skipped cases from get_artifact_health
|
|
1220
|
+
if result.skipped_reason == "no-claude-dir":
|
|
1221
|
+
return CheckResult(
|
|
1222
|
+
name="managed-artifacts",
|
|
1223
|
+
passed=True,
|
|
1224
|
+
message="No .claude/ directory (nothing to check)",
|
|
1225
|
+
)
|
|
1226
|
+
|
|
1227
|
+
if result.skipped_reason == "no-bundled-dir":
|
|
1228
|
+
return CheckResult(
|
|
1229
|
+
name="managed-artifacts",
|
|
1230
|
+
passed=True,
|
|
1231
|
+
message="Bundled .claude/ not found (skipping check)",
|
|
1232
|
+
)
|
|
1233
|
+
|
|
1234
|
+
# No artifacts to check
|
|
1235
|
+
if not result.artifacts:
|
|
1236
|
+
return CheckResult(
|
|
1237
|
+
name="managed-artifacts",
|
|
1238
|
+
passed=True,
|
|
1239
|
+
message="No managed artifacts found",
|
|
1240
|
+
)
|
|
1241
|
+
|
|
1242
|
+
# In erk repo, show counts without status comparison (all from source)
|
|
1243
|
+
if in_erk_repo:
|
|
1244
|
+
return _build_erk_repo_artifacts_result(result)
|
|
1245
|
+
|
|
1246
|
+
return _build_managed_artifacts_result(result)
|
|
1247
|
+
|
|
1248
|
+
|
|
1249
|
+
def run_all_checks(ctx: ErkContext) -> list[CheckResult]:
|
|
1250
|
+
"""Run all health checks and return results.
|
|
1251
|
+
|
|
1252
|
+
Args:
|
|
1253
|
+
ctx: ErkContext for repository checks (includes github_admin)
|
|
1254
|
+
|
|
1255
|
+
Returns:
|
|
1256
|
+
List of CheckResult objects
|
|
1257
|
+
"""
|
|
1258
|
+
shell = ctx.shell
|
|
1259
|
+
admin = ctx.github_admin
|
|
1260
|
+
|
|
1261
|
+
claude_installation = ctx.claude_installation
|
|
1262
|
+
|
|
1263
|
+
results = [
|
|
1264
|
+
check_erk_version(),
|
|
1265
|
+
check_claude_cli(shell),
|
|
1266
|
+
check_graphite_cli(shell),
|
|
1267
|
+
check_github_cli(shell),
|
|
1268
|
+
check_github_auth(shell, admin),
|
|
1269
|
+
check_uv_version(shell),
|
|
1270
|
+
check_hooks_disabled(claude_installation),
|
|
1271
|
+
check_statusline_configured(claude_installation),
|
|
1272
|
+
check_shell_integration(shell),
|
|
1273
|
+
]
|
|
1274
|
+
|
|
1275
|
+
# Add repository check
|
|
1276
|
+
results.append(check_repository(ctx))
|
|
1277
|
+
|
|
1278
|
+
# Check Claude settings, gitignore, and GitHub checks if we're in a repo
|
|
1279
|
+
# (get_git_common_dir returns None if not in a repo)
|
|
1280
|
+
git_dir = ctx.git.get_git_common_dir(ctx.cwd)
|
|
1281
|
+
if git_dir is not None:
|
|
1282
|
+
repo_root = ctx.git.get_repository_root(ctx.cwd)
|
|
1283
|
+
results.append(check_claude_erk_permission(repo_root))
|
|
1284
|
+
results.append(check_claude_settings(repo_root))
|
|
1285
|
+
results.append(check_user_prompt_hook(repo_root))
|
|
1286
|
+
results.append(check_exit_plan_hook(repo_root))
|
|
1287
|
+
results.append(check_gitignore_entries(repo_root))
|
|
1288
|
+
results.append(check_required_tool_version(repo_root))
|
|
1289
|
+
results.append(check_legacy_prompt_hooks(repo_root))
|
|
1290
|
+
results.append(check_post_plan_implement_ci_hook(repo_root))
|
|
1291
|
+
# Hook health check
|
|
1292
|
+
results.append(check_hook_health(repo_root))
|
|
1293
|
+
# GitHub workflow permissions check (requires repo context)
|
|
1294
|
+
results.append(check_workflow_permissions(ctx, repo_root, admin))
|
|
1295
|
+
# Managed artifacts check (consolidated from orphaned + missing)
|
|
1296
|
+
results.append(check_managed_artifacts(repo_root))
|
|
1297
|
+
|
|
1298
|
+
# Check plans_repo labels if configured
|
|
1299
|
+
from erk.cli.config import load_config as load_repo_config
|
|
1300
|
+
from erk_shared.github.issues.real import RealGitHubIssues
|
|
1301
|
+
|
|
1302
|
+
repo_config = load_repo_config(repo_root)
|
|
1303
|
+
if repo_config.plans_repo is not None:
|
|
1304
|
+
github_issues = RealGitHubIssues(target_repo=repo_config.plans_repo)
|
|
1305
|
+
results.append(
|
|
1306
|
+
check_plans_repo_labels(repo_root, repo_config.plans_repo, github_issues)
|
|
1307
|
+
)
|
|
1308
|
+
|
|
1309
|
+
from erk.core.health_checks_dogfooder import run_early_dogfooder_checks
|
|
1310
|
+
|
|
1311
|
+
# Get metadata_dir if we have a RepoContext (for legacy config detection)
|
|
1312
|
+
metadata_dir = ctx.repo.repo_dir if isinstance(ctx.repo, RepoContext) else None
|
|
1313
|
+
results.extend(run_early_dogfooder_checks(repo_root, metadata_dir))
|
|
1314
|
+
|
|
1315
|
+
return results
|