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,623 @@
|
|
|
1
|
+
"""Orphaned artifact detection for erk-managed .claude/ directories."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from erk.artifacts.detection import is_in_erk_repo
|
|
9
|
+
from erk.artifacts.discovery import (
|
|
10
|
+
_compute_directory_hash,
|
|
11
|
+
_compute_file_hash,
|
|
12
|
+
_compute_hook_hash,
|
|
13
|
+
)
|
|
14
|
+
from erk.artifacts.models import (
|
|
15
|
+
ArtifactFileState,
|
|
16
|
+
CompletenessCheckResult,
|
|
17
|
+
InstalledArtifact,
|
|
18
|
+
OrphanCheckResult,
|
|
19
|
+
)
|
|
20
|
+
from erk.artifacts.sync import get_bundled_claude_dir, get_bundled_github_dir
|
|
21
|
+
from erk.core.claude_settings import (
|
|
22
|
+
ERK_EXIT_PLAN_HOOK_COMMAND,
|
|
23
|
+
ERK_USER_PROMPT_HOOK_COMMAND,
|
|
24
|
+
has_exit_plan_hook,
|
|
25
|
+
has_user_prompt_hook,
|
|
26
|
+
)
|
|
27
|
+
from erk.core.release_notes import get_current_version
|
|
28
|
+
|
|
29
|
+
# Bundled artifacts that erk syncs to projects
|
|
30
|
+
BUNDLED_SKILLS = frozenset(
|
|
31
|
+
{
|
|
32
|
+
"dignified-python",
|
|
33
|
+
"learned-docs",
|
|
34
|
+
"erk-diff-analysis",
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
BUNDLED_AGENTS = frozenset({"devrun"})
|
|
38
|
+
BUNDLED_WORKFLOWS = frozenset({"erk-impl.yml"})
|
|
39
|
+
# Actions (composite GitHub actions) that erk syncs
|
|
40
|
+
BUNDLED_ACTIONS = frozenset({"setup-claude-erk"})
|
|
41
|
+
# Hook configurations that erk adds to settings.json
|
|
42
|
+
BUNDLED_HOOKS = frozenset({"user-prompt-hook", "exit-plan-mode-hook"})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def is_erk_managed(artifact: InstalledArtifact) -> bool:
|
|
46
|
+
"""Check if artifact is managed by erk (bundled with erk package).
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
artifact: The artifact to check
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
True if the artifact is bundled with erk, False if it's project-specific
|
|
53
|
+
"""
|
|
54
|
+
if artifact.artifact_type == "command":
|
|
55
|
+
return artifact.name.startswith("erk:")
|
|
56
|
+
if artifact.artifact_type == "skill":
|
|
57
|
+
return artifact.name in BUNDLED_SKILLS
|
|
58
|
+
if artifact.artifact_type == "agent":
|
|
59
|
+
return artifact.name in BUNDLED_AGENTS
|
|
60
|
+
if artifact.artifact_type == "workflow":
|
|
61
|
+
return f"{artifact.name}.yml" in BUNDLED_WORKFLOWS
|
|
62
|
+
if artifact.artifact_type == "action":
|
|
63
|
+
return artifact.name in BUNDLED_ACTIONS
|
|
64
|
+
if artifact.artifact_type == "hook":
|
|
65
|
+
return artifact.name in BUNDLED_HOOKS
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# Status types for per-artifact version tracking
|
|
70
|
+
ArtifactStatusType = Literal["up-to-date", "changed-upstream", "locally-modified", "not-installed"]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class ArtifactStatus:
|
|
75
|
+
"""Per-artifact status comparing installed vs bundled state."""
|
|
76
|
+
|
|
77
|
+
name: str # e.g. "skills/dignified-python", "commands/erk/plan-implement.md"
|
|
78
|
+
installed_version: str | None # version at sync time, None if not tracked
|
|
79
|
+
current_version: str # current erk version
|
|
80
|
+
installed_hash: str | None # hash at sync time, None if not tracked
|
|
81
|
+
current_hash: str | None # current computed hash, None if not installed
|
|
82
|
+
status: ArtifactStatusType
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class ArtifactHealthResult:
|
|
87
|
+
"""Result of per-artifact health check."""
|
|
88
|
+
|
|
89
|
+
artifacts: list[ArtifactStatus]
|
|
90
|
+
skipped_reason: Literal["erk-repo", "no-claude-dir", "no-bundled-dir"] | None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _compute_path_hash(path: Path, is_directory: bool) -> str | None:
|
|
94
|
+
"""Compute hash of a path, returning None if it doesn't exist.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
path: Path to the file or directory
|
|
98
|
+
is_directory: True for directory hash, False for file hash
|
|
99
|
+
"""
|
|
100
|
+
if not path.exists():
|
|
101
|
+
return None
|
|
102
|
+
if is_directory:
|
|
103
|
+
return _compute_directory_hash(path)
|
|
104
|
+
return _compute_file_hash(path)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _determine_status(
|
|
108
|
+
installed_version: str | None,
|
|
109
|
+
current_version: str,
|
|
110
|
+
installed_hash: str | None,
|
|
111
|
+
current_hash: str | None,
|
|
112
|
+
) -> ArtifactStatusType:
|
|
113
|
+
"""Determine artifact status from version/hash comparison.
|
|
114
|
+
|
|
115
|
+
Logic:
|
|
116
|
+
- current_hash is None → not installed
|
|
117
|
+
- installed_hash != current_hash AND installed_version == current_version → locally modified
|
|
118
|
+
- installed_version != current_version → changed upstream
|
|
119
|
+
- Both match → up-to-date
|
|
120
|
+
"""
|
|
121
|
+
if current_hash is None:
|
|
122
|
+
return "not-installed"
|
|
123
|
+
|
|
124
|
+
if installed_hash is None or installed_version is None:
|
|
125
|
+
# No prior state recorded - treat as changed upstream
|
|
126
|
+
return "changed-upstream"
|
|
127
|
+
|
|
128
|
+
if installed_hash != current_hash:
|
|
129
|
+
if installed_version == current_version:
|
|
130
|
+
# Hash changed but version didn't → local modification
|
|
131
|
+
return "locally-modified"
|
|
132
|
+
# Hash changed and version changed → upstream change
|
|
133
|
+
return "changed-upstream"
|
|
134
|
+
|
|
135
|
+
if installed_version != current_version:
|
|
136
|
+
# Version changed but hash didn't → still changed upstream
|
|
137
|
+
return "changed-upstream"
|
|
138
|
+
|
|
139
|
+
return "up-to-date"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _build_artifact_status(
|
|
143
|
+
key: str,
|
|
144
|
+
current_hash: str | None,
|
|
145
|
+
saved_files: dict[str, ArtifactFileState],
|
|
146
|
+
current_version: str,
|
|
147
|
+
) -> ArtifactStatus:
|
|
148
|
+
"""Build ArtifactStatus from key, hash, and saved state."""
|
|
149
|
+
saved = saved_files.get(key)
|
|
150
|
+
return ArtifactStatus(
|
|
151
|
+
name=key,
|
|
152
|
+
installed_version=saved.version if saved else None,
|
|
153
|
+
current_version=current_version,
|
|
154
|
+
installed_hash=saved.hash if saved else None,
|
|
155
|
+
current_hash=current_hash,
|
|
156
|
+
status=_determine_status(
|
|
157
|
+
saved.version if saved else None,
|
|
158
|
+
current_version,
|
|
159
|
+
saved.hash if saved else None,
|
|
160
|
+
current_hash,
|
|
161
|
+
),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def get_artifact_health(
|
|
166
|
+
project_dir: Path, saved_files: dict[str, ArtifactFileState]
|
|
167
|
+
) -> ArtifactHealthResult:
|
|
168
|
+
"""Get per-artifact health status comparing installed vs bundled state.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
project_dir: Path to the project root
|
|
172
|
+
saved_files: Per-artifact state from .erk/state.toml (artifact key -> ArtifactFileState)
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
ArtifactHealthResult with status for each bundled artifact
|
|
176
|
+
"""
|
|
177
|
+
# Skip if no .claude/ directory
|
|
178
|
+
project_claude_dir = project_dir / ".claude"
|
|
179
|
+
if not project_claude_dir.exists():
|
|
180
|
+
return ArtifactHealthResult(artifacts=[], skipped_reason="no-claude-dir")
|
|
181
|
+
|
|
182
|
+
bundled_claude_dir = get_bundled_claude_dir()
|
|
183
|
+
if not bundled_claude_dir.exists():
|
|
184
|
+
return ArtifactHealthResult(artifacts=[], skipped_reason="no-bundled-dir")
|
|
185
|
+
|
|
186
|
+
project_workflows_dir = project_dir / ".github" / "workflows"
|
|
187
|
+
project_actions_dir = project_dir / ".github" / "actions"
|
|
188
|
+
current_version = get_current_version()
|
|
189
|
+
|
|
190
|
+
artifacts: list[ArtifactStatus] = []
|
|
191
|
+
|
|
192
|
+
# Check skills (always directory-based)
|
|
193
|
+
for name in BUNDLED_SKILLS:
|
|
194
|
+
key = f"skills/{name}"
|
|
195
|
+
path = project_claude_dir / "skills" / name
|
|
196
|
+
installed_hash = _compute_path_hash(path, is_directory=True)
|
|
197
|
+
artifacts.append(_build_artifact_status(key, installed_hash, saved_files, current_version))
|
|
198
|
+
|
|
199
|
+
# Check agents (can be directory-based or single-file)
|
|
200
|
+
# Key format depends on structure:
|
|
201
|
+
# - Directory: agents/{name} (like skills)
|
|
202
|
+
# - Single-file: agents/{name}.md (like commands)
|
|
203
|
+
for name in BUNDLED_AGENTS:
|
|
204
|
+
dir_path = project_claude_dir / "agents" / name
|
|
205
|
+
file_path = project_claude_dir / "agents" / f"{name}.md"
|
|
206
|
+
|
|
207
|
+
# Check bundled structure to determine canonical key format
|
|
208
|
+
bundled_dir = bundled_claude_dir / "agents" / name
|
|
209
|
+
bundled_file = bundled_claude_dir / "agents" / f"{name}.md"
|
|
210
|
+
|
|
211
|
+
# Directory-based takes precedence, then single-file
|
|
212
|
+
if bundled_dir.exists() and bundled_dir.is_dir():
|
|
213
|
+
key = f"agents/{name}"
|
|
214
|
+
installed_hash = _compute_path_hash(dir_path, is_directory=True)
|
|
215
|
+
elif bundled_file.exists() and bundled_file.is_file():
|
|
216
|
+
key = f"agents/{name}.md"
|
|
217
|
+
installed_hash = _compute_path_hash(file_path, is_directory=False)
|
|
218
|
+
elif dir_path.exists() and dir_path.is_dir():
|
|
219
|
+
# Fallback: check installed structure
|
|
220
|
+
key = f"agents/{name}"
|
|
221
|
+
installed_hash = _compute_path_hash(dir_path, is_directory=True)
|
|
222
|
+
elif file_path.exists() and file_path.is_file():
|
|
223
|
+
key = f"agents/{name}.md"
|
|
224
|
+
installed_hash = _compute_path_hash(file_path, is_directory=False)
|
|
225
|
+
else:
|
|
226
|
+
# Not installed anywhere - use single-file key as default for new agents
|
|
227
|
+
key = f"agents/{name}.md"
|
|
228
|
+
installed_hash = None
|
|
229
|
+
|
|
230
|
+
artifacts.append(_build_artifact_status(key, installed_hash, saved_files, current_version))
|
|
231
|
+
|
|
232
|
+
# Check commands (enumerate erk commands from bundled source)
|
|
233
|
+
bundled_erk_commands = bundled_claude_dir / "commands" / "erk"
|
|
234
|
+
if bundled_erk_commands.exists():
|
|
235
|
+
for cmd_file in sorted(bundled_erk_commands.glob("*.md")):
|
|
236
|
+
key = f"commands/erk/{cmd_file.name}"
|
|
237
|
+
path = project_claude_dir / "commands" / "erk" / cmd_file.name
|
|
238
|
+
installed_hash = _compute_path_hash(path, is_directory=False)
|
|
239
|
+
artifacts.append(
|
|
240
|
+
_build_artifact_status(key, installed_hash, saved_files, current_version)
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
# Check workflows
|
|
244
|
+
for workflow_name in BUNDLED_WORKFLOWS:
|
|
245
|
+
key = f"workflows/{workflow_name}"
|
|
246
|
+
path = project_workflows_dir / workflow_name
|
|
247
|
+
installed_hash = _compute_path_hash(path, is_directory=False)
|
|
248
|
+
artifacts.append(_build_artifact_status(key, installed_hash, saved_files, current_version))
|
|
249
|
+
|
|
250
|
+
# Check actions (always directory-based)
|
|
251
|
+
for name in BUNDLED_ACTIONS:
|
|
252
|
+
key = f"actions/{name}"
|
|
253
|
+
path = project_actions_dir / name
|
|
254
|
+
installed_hash = _compute_path_hash(path, is_directory=True)
|
|
255
|
+
artifacts.append(_build_artifact_status(key, installed_hash, saved_files, current_version))
|
|
256
|
+
|
|
257
|
+
# Check hooks
|
|
258
|
+
settings_path = project_claude_dir / "settings.json"
|
|
259
|
+
if settings_path.exists():
|
|
260
|
+
content = settings_path.read_text(encoding="utf-8")
|
|
261
|
+
settings = json.loads(content)
|
|
262
|
+
|
|
263
|
+
hook_checks = [
|
|
264
|
+
("hooks/user-prompt-hook", has_user_prompt_hook, ERK_USER_PROMPT_HOOK_COMMAND),
|
|
265
|
+
("hooks/exit-plan-mode-hook", has_exit_plan_hook, ERK_EXIT_PLAN_HOOK_COMMAND),
|
|
266
|
+
]
|
|
267
|
+
for key, check_fn, command in hook_checks:
|
|
268
|
+
hook_hash = _compute_hook_hash(command) if check_fn(settings) else None
|
|
269
|
+
artifacts.append(_build_artifact_status(key, hook_hash, saved_files, current_version))
|
|
270
|
+
else:
|
|
271
|
+
# No settings.json - all hooks are not installed
|
|
272
|
+
for hook_name in ["user-prompt-hook", "exit-plan-mode-hook"]:
|
|
273
|
+
artifacts.append(
|
|
274
|
+
_build_artifact_status(f"hooks/{hook_name}", None, saved_files, current_version)
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
return ArtifactHealthResult(artifacts=artifacts, skipped_reason=None)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _find_orphaned_in_directory(local_dir: Path, bundled_dir: Path, folder_key: str) -> list[str]:
|
|
281
|
+
"""Find orphaned files in a directory (files in local but not in bundled)."""
|
|
282
|
+
if not local_dir.exists() or not bundled_dir.exists():
|
|
283
|
+
return []
|
|
284
|
+
|
|
285
|
+
bundled_files = {str(f.relative_to(bundled_dir)) for f in bundled_dir.rglob("*") if f.is_file()}
|
|
286
|
+
orphans: list[str] = []
|
|
287
|
+
for local_file in local_dir.rglob("*"):
|
|
288
|
+
if local_file.is_file():
|
|
289
|
+
relative_path = str(local_file.relative_to(local_dir))
|
|
290
|
+
if relative_path not in bundled_files:
|
|
291
|
+
orphans.append(relative_path)
|
|
292
|
+
return orphans
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _find_orphaned_claude_artifacts(
|
|
296
|
+
project_claude_dir: Path,
|
|
297
|
+
bundled_claude_dir: Path,
|
|
298
|
+
) -> dict[str, list[str]]:
|
|
299
|
+
"""Find files in bundled .claude/ folders that exist locally but not in package.
|
|
300
|
+
|
|
301
|
+
Compares bundled artifact directories with the local project's .claude/ directory
|
|
302
|
+
to find orphaned files that should be removed.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
project_claude_dir: Path to project's .claude/ directory
|
|
306
|
+
bundled_claude_dir: Path to bundled .claude/ in erk package
|
|
307
|
+
|
|
308
|
+
Returns:
|
|
309
|
+
Dict mapping folder path (relative to .claude/) to list of orphaned filenames
|
|
310
|
+
"""
|
|
311
|
+
orphans: dict[str, list[str]] = {}
|
|
312
|
+
|
|
313
|
+
# Check commands/erk/ directory
|
|
314
|
+
cmd_orphans = _find_orphaned_in_directory(
|
|
315
|
+
project_claude_dir / "commands" / "erk",
|
|
316
|
+
bundled_claude_dir / "commands" / "erk",
|
|
317
|
+
"commands/erk",
|
|
318
|
+
)
|
|
319
|
+
if cmd_orphans:
|
|
320
|
+
orphans["commands/erk"] = cmd_orphans
|
|
321
|
+
|
|
322
|
+
# Check directory-based artifacts (skills, agents)
|
|
323
|
+
for prefix, names in [("skills", BUNDLED_SKILLS), ("agents", BUNDLED_AGENTS)]:
|
|
324
|
+
for name in names:
|
|
325
|
+
folder_key = f"{prefix}/{name}"
|
|
326
|
+
dir_orphans = _find_orphaned_in_directory(
|
|
327
|
+
project_claude_dir / prefix / name,
|
|
328
|
+
bundled_claude_dir / prefix / name,
|
|
329
|
+
folder_key,
|
|
330
|
+
)
|
|
331
|
+
if dir_orphans:
|
|
332
|
+
orphans[folder_key] = dir_orphans
|
|
333
|
+
|
|
334
|
+
return orphans
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _find_orphaned_workflows(
|
|
338
|
+
project_workflows_dir: Path,
|
|
339
|
+
bundled_workflows_dir: Path,
|
|
340
|
+
) -> dict[str, list[str]]:
|
|
341
|
+
"""Find erk-managed workflow files that exist locally but not in package.
|
|
342
|
+
|
|
343
|
+
Only checks files that are in BUNDLED_WORKFLOWS - we don't want to flag
|
|
344
|
+
user workflows that erk doesn't manage.
|
|
345
|
+
|
|
346
|
+
Args:
|
|
347
|
+
project_workflows_dir: Path to project's .github/workflows/ directory
|
|
348
|
+
bundled_workflows_dir: Path to bundled .github/workflows/ in erk package
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
Dict mapping ".github/workflows" to list of orphaned workflow filenames
|
|
352
|
+
"""
|
|
353
|
+
if not project_workflows_dir.exists():
|
|
354
|
+
return {}
|
|
355
|
+
if not bundled_workflows_dir.exists():
|
|
356
|
+
return {}
|
|
357
|
+
|
|
358
|
+
orphans: dict[str, list[str]] = {}
|
|
359
|
+
|
|
360
|
+
# Only check erk-managed workflow files
|
|
361
|
+
for workflow_name in BUNDLED_WORKFLOWS:
|
|
362
|
+
local_workflow = project_workflows_dir / workflow_name
|
|
363
|
+
bundled_workflow = bundled_workflows_dir / workflow_name
|
|
364
|
+
|
|
365
|
+
# If file exists locally but not in bundled, it's orphaned
|
|
366
|
+
if local_workflow.exists() and not bundled_workflow.exists():
|
|
367
|
+
folder_key = ".github/workflows"
|
|
368
|
+
if folder_key not in orphans:
|
|
369
|
+
orphans[folder_key] = []
|
|
370
|
+
orphans[folder_key].append(workflow_name)
|
|
371
|
+
|
|
372
|
+
return orphans
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def find_orphaned_artifacts(project_dir: Path) -> OrphanCheckResult:
|
|
376
|
+
"""Find orphaned files in erk-managed artifact directories.
|
|
377
|
+
|
|
378
|
+
Compares local .claude/ and .github/ artifacts with bundled package to find files
|
|
379
|
+
that exist locally but are not in the current erk package version.
|
|
380
|
+
|
|
381
|
+
Args:
|
|
382
|
+
project_dir: Path to the project root
|
|
383
|
+
|
|
384
|
+
Returns:
|
|
385
|
+
OrphanCheckResult with orphan status
|
|
386
|
+
"""
|
|
387
|
+
# Skip check in erk repo - artifacts are source, not synced
|
|
388
|
+
if is_in_erk_repo(project_dir):
|
|
389
|
+
return OrphanCheckResult(
|
|
390
|
+
orphans={},
|
|
391
|
+
skipped_reason="erk-repo",
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
# Skip if no .claude/ directory
|
|
395
|
+
project_claude_dir = project_dir / ".claude"
|
|
396
|
+
if not project_claude_dir.exists():
|
|
397
|
+
return OrphanCheckResult(
|
|
398
|
+
orphans={},
|
|
399
|
+
skipped_reason="no-claude-dir",
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
bundled_claude_dir = get_bundled_claude_dir()
|
|
403
|
+
if not bundled_claude_dir.exists():
|
|
404
|
+
return OrphanCheckResult(
|
|
405
|
+
orphans={},
|
|
406
|
+
skipped_reason="no-bundled-dir",
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
orphans = _find_orphaned_claude_artifacts(project_claude_dir, bundled_claude_dir)
|
|
410
|
+
|
|
411
|
+
# Also check for orphaned workflows
|
|
412
|
+
bundled_github_dir = get_bundled_github_dir()
|
|
413
|
+
project_workflows_dir = project_dir / ".github" / "workflows"
|
|
414
|
+
bundled_workflows_dir = bundled_github_dir / "workflows"
|
|
415
|
+
orphans.update(_find_orphaned_workflows(project_workflows_dir, bundled_workflows_dir))
|
|
416
|
+
|
|
417
|
+
return OrphanCheckResult(
|
|
418
|
+
orphans=orphans,
|
|
419
|
+
skipped_reason=None,
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _find_missing_in_directory(bundled_dir: Path, local_dir: Path) -> list[str]:
|
|
424
|
+
"""Find missing files in a directory (files in bundled but not in local)."""
|
|
425
|
+
if not bundled_dir.exists():
|
|
426
|
+
return []
|
|
427
|
+
|
|
428
|
+
local_dir.mkdir(parents=True, exist_ok=True)
|
|
429
|
+
bundled_files = {str(f.relative_to(bundled_dir)) for f in bundled_dir.rglob("*") if f.is_file()}
|
|
430
|
+
local_files = {str(f.relative_to(local_dir)) for f in local_dir.rglob("*") if f.is_file()}
|
|
431
|
+
return sorted(bundled_files - local_files)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _find_missing_claude_artifacts(
|
|
435
|
+
project_claude_dir: Path,
|
|
436
|
+
bundled_claude_dir: Path,
|
|
437
|
+
) -> dict[str, list[str]]:
|
|
438
|
+
"""Find files in bundled .claude/ that are missing locally.
|
|
439
|
+
|
|
440
|
+
Checks bundled → local direction (opposite of orphan detection).
|
|
441
|
+
Returns dict mapping folder path to list of missing filenames.
|
|
442
|
+
|
|
443
|
+
Args:
|
|
444
|
+
project_claude_dir: Path to project's .claude/ directory
|
|
445
|
+
bundled_claude_dir: Path to bundled .claude/ in erk package
|
|
446
|
+
|
|
447
|
+
Returns:
|
|
448
|
+
Dict mapping folder path (relative to .claude/) to list of missing filenames
|
|
449
|
+
"""
|
|
450
|
+
missing: dict[str, list[str]] = {}
|
|
451
|
+
|
|
452
|
+
# Check commands/erk/ directory
|
|
453
|
+
cmd_missing = _find_missing_in_directory(
|
|
454
|
+
bundled_claude_dir / "commands" / "erk",
|
|
455
|
+
project_claude_dir / "commands" / "erk",
|
|
456
|
+
)
|
|
457
|
+
if cmd_missing:
|
|
458
|
+
missing["commands/erk"] = cmd_missing
|
|
459
|
+
|
|
460
|
+
# Check directory-based artifacts (skills, agents)
|
|
461
|
+
for prefix, names in [("skills", BUNDLED_SKILLS), ("agents", BUNDLED_AGENTS)]:
|
|
462
|
+
for name in names:
|
|
463
|
+
folder_key = f"{prefix}/{name}"
|
|
464
|
+
dir_missing = _find_missing_in_directory(
|
|
465
|
+
bundled_claude_dir / prefix / name,
|
|
466
|
+
project_claude_dir / prefix / name,
|
|
467
|
+
)
|
|
468
|
+
if dir_missing:
|
|
469
|
+
missing[folder_key] = dir_missing
|
|
470
|
+
|
|
471
|
+
return missing
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _find_missing_workflows(
|
|
475
|
+
project_workflows_dir: Path,
|
|
476
|
+
bundled_workflows_dir: Path,
|
|
477
|
+
) -> dict[str, list[str]]:
|
|
478
|
+
"""Find erk-managed workflows that exist in bundle but missing locally.
|
|
479
|
+
|
|
480
|
+
Args:
|
|
481
|
+
project_workflows_dir: Path to project's .github/workflows/ directory
|
|
482
|
+
bundled_workflows_dir: Path to bundled .github/workflows/ in erk package
|
|
483
|
+
|
|
484
|
+
Returns:
|
|
485
|
+
Dict mapping ".github/workflows" to list of missing workflow filenames
|
|
486
|
+
"""
|
|
487
|
+
if not bundled_workflows_dir.exists():
|
|
488
|
+
return {}
|
|
489
|
+
|
|
490
|
+
project_workflows_dir.mkdir(parents=True, exist_ok=True)
|
|
491
|
+
missing: dict[str, list[str]] = {}
|
|
492
|
+
|
|
493
|
+
for workflow_name in BUNDLED_WORKFLOWS:
|
|
494
|
+
bundled_workflow = bundled_workflows_dir / workflow_name
|
|
495
|
+
local_workflow = project_workflows_dir / workflow_name
|
|
496
|
+
|
|
497
|
+
# If bundled but not local, it's missing
|
|
498
|
+
if bundled_workflow.exists() and not local_workflow.exists():
|
|
499
|
+
folder_key = ".github/workflows"
|
|
500
|
+
if folder_key not in missing:
|
|
501
|
+
missing[folder_key] = []
|
|
502
|
+
missing[folder_key].append(workflow_name)
|
|
503
|
+
|
|
504
|
+
return missing
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _find_missing_actions(
|
|
508
|
+
project_actions_dir: Path,
|
|
509
|
+
bundled_actions_dir: Path,
|
|
510
|
+
) -> dict[str, list[str]]:
|
|
511
|
+
"""Find erk-managed actions that exist in bundle but missing locally.
|
|
512
|
+
|
|
513
|
+
Args:
|
|
514
|
+
project_actions_dir: Path to project's .github/actions/ directory
|
|
515
|
+
bundled_actions_dir: Path to bundled .github/actions/ in erk package
|
|
516
|
+
|
|
517
|
+
Returns:
|
|
518
|
+
Dict mapping ".github/actions" to list of missing action names
|
|
519
|
+
"""
|
|
520
|
+
if not bundled_actions_dir.exists():
|
|
521
|
+
return {}
|
|
522
|
+
|
|
523
|
+
missing: dict[str, list[str]] = {}
|
|
524
|
+
|
|
525
|
+
for action_name in BUNDLED_ACTIONS:
|
|
526
|
+
bundled_action = bundled_actions_dir / action_name
|
|
527
|
+
local_action = project_actions_dir / action_name
|
|
528
|
+
|
|
529
|
+
# If bundled but not local, it's missing
|
|
530
|
+
if bundled_action.exists() and not local_action.exists():
|
|
531
|
+
folder_key = ".github/actions"
|
|
532
|
+
if folder_key not in missing:
|
|
533
|
+
missing[folder_key] = []
|
|
534
|
+
missing[folder_key].append(action_name)
|
|
535
|
+
|
|
536
|
+
return missing
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _find_missing_hooks(project_claude_dir: Path) -> dict[str, list[str]]:
|
|
540
|
+
"""Find erk-managed hooks that are missing from settings.json.
|
|
541
|
+
|
|
542
|
+
Args:
|
|
543
|
+
project_claude_dir: Path to project's .claude/ directory
|
|
544
|
+
|
|
545
|
+
Returns:
|
|
546
|
+
Dict mapping "settings.json" to list of missing hook names
|
|
547
|
+
"""
|
|
548
|
+
settings_path = project_claude_dir / "settings.json"
|
|
549
|
+
missing: dict[str, list[str]] = {}
|
|
550
|
+
|
|
551
|
+
# If no settings.json, all hooks are missing
|
|
552
|
+
if not settings_path.exists():
|
|
553
|
+
return {"settings.json": sorted(BUNDLED_HOOKS)}
|
|
554
|
+
|
|
555
|
+
content = settings_path.read_text(encoding="utf-8")
|
|
556
|
+
settings = json.loads(content)
|
|
557
|
+
|
|
558
|
+
missing_hooks: list[str] = []
|
|
559
|
+
|
|
560
|
+
if not has_user_prompt_hook(settings):
|
|
561
|
+
missing_hooks.append("user-prompt-hook")
|
|
562
|
+
|
|
563
|
+
if not has_exit_plan_hook(settings):
|
|
564
|
+
missing_hooks.append("exit-plan-mode-hook")
|
|
565
|
+
|
|
566
|
+
if missing_hooks:
|
|
567
|
+
missing["settings.json"] = sorted(missing_hooks)
|
|
568
|
+
|
|
569
|
+
return missing
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def find_missing_artifacts(project_dir: Path) -> CompletenessCheckResult:
|
|
573
|
+
"""Find bundled artifacts that are missing from local installation.
|
|
574
|
+
|
|
575
|
+
Checks bundled → local direction to detect incomplete syncs.
|
|
576
|
+
|
|
577
|
+
Args:
|
|
578
|
+
project_dir: Path to the project root
|
|
579
|
+
|
|
580
|
+
Returns:
|
|
581
|
+
CompletenessCheckResult with missing artifact status
|
|
582
|
+
"""
|
|
583
|
+
# Skip in erk repo - artifacts are source
|
|
584
|
+
if is_in_erk_repo(project_dir):
|
|
585
|
+
return CompletenessCheckResult(
|
|
586
|
+
missing={},
|
|
587
|
+
skipped_reason="erk-repo",
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
# Skip if no .claude/ directory
|
|
591
|
+
project_claude_dir = project_dir / ".claude"
|
|
592
|
+
if not project_claude_dir.exists():
|
|
593
|
+
return CompletenessCheckResult(
|
|
594
|
+
missing={},
|
|
595
|
+
skipped_reason="no-claude-dir",
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
bundled_claude_dir = get_bundled_claude_dir()
|
|
599
|
+
if not bundled_claude_dir.exists():
|
|
600
|
+
return CompletenessCheckResult(
|
|
601
|
+
missing={},
|
|
602
|
+
skipped_reason="no-bundled-dir",
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
missing = _find_missing_claude_artifacts(project_claude_dir, bundled_claude_dir)
|
|
606
|
+
|
|
607
|
+
# Check workflows and actions
|
|
608
|
+
bundled_github_dir = get_bundled_github_dir()
|
|
609
|
+
project_workflows_dir = project_dir / ".github" / "workflows"
|
|
610
|
+
bundled_workflows_dir = bundled_github_dir / "workflows"
|
|
611
|
+
missing.update(_find_missing_workflows(project_workflows_dir, bundled_workflows_dir))
|
|
612
|
+
|
|
613
|
+
project_actions_dir = project_dir / ".github" / "actions"
|
|
614
|
+
bundled_actions_dir = bundled_github_dir / "actions"
|
|
615
|
+
missing.update(_find_missing_actions(project_actions_dir, bundled_actions_dir))
|
|
616
|
+
|
|
617
|
+
# Check hooks in settings.json
|
|
618
|
+
missing.update(_find_missing_hooks(project_claude_dir))
|
|
619
|
+
|
|
620
|
+
return CompletenessCheckResult(
|
|
621
|
+
missing=missing,
|
|
622
|
+
skipped_reason=None,
|
|
623
|
+
)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Detection utilities for artifact management."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def is_in_erk_repo(project_dir: Path) -> bool:
|
|
7
|
+
"""Check if we're running inside the erk repository itself.
|
|
8
|
+
|
|
9
|
+
When running in the erk repo, artifacts are read from source
|
|
10
|
+
rather than synced from package data.
|
|
11
|
+
"""
|
|
12
|
+
pyproject = project_dir / "pyproject.toml"
|
|
13
|
+
if not pyproject.exists():
|
|
14
|
+
return False
|
|
15
|
+
content = pyproject.read_text(encoding="utf-8")
|
|
16
|
+
return 'name = "erk"' in content
|