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,343 @@
|
|
|
1
|
+
"""Discover artifacts installed in a project's .claude/ and .github/ directories."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from erk.artifacts.models import ArtifactType, InstalledArtifact
|
|
8
|
+
from erk.core.claude_settings import (
|
|
9
|
+
ERK_EXIT_PLAN_HOOK_COMMAND,
|
|
10
|
+
ERK_USER_PROMPT_HOOK_COMMAND,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _compute_file_hash(path: Path) -> str:
|
|
15
|
+
"""Compute SHA256 hash of single file content."""
|
|
16
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()[:16]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _compute_directory_hash(path: Path) -> str:
|
|
20
|
+
"""Compute combined hash of all files in directory.
|
|
21
|
+
|
|
22
|
+
Includes relative path in hash to detect structural changes (renames, moves).
|
|
23
|
+
Files are processed in sorted order for deterministic hashing.
|
|
24
|
+
"""
|
|
25
|
+
hasher = hashlib.sha256()
|
|
26
|
+
for file_path in sorted(path.rglob("*")):
|
|
27
|
+
if file_path.is_file():
|
|
28
|
+
# Include relative path in hash for structural changes
|
|
29
|
+
hasher.update(file_path.relative_to(path).as_posix().encode())
|
|
30
|
+
hasher.update(file_path.read_bytes())
|
|
31
|
+
return hasher.hexdigest()[:16]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _compute_hook_hash(command: str) -> str:
|
|
35
|
+
"""Compute hash of hook command string."""
|
|
36
|
+
return hashlib.sha256(command.encode()).hexdigest()[:16]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _compute_content_hash(path: Path) -> str:
|
|
40
|
+
"""Compute SHA256 hash of file or directory content.
|
|
41
|
+
|
|
42
|
+
For files: hashes the file content directly.
|
|
43
|
+
For directories: hashes all files with their relative paths.
|
|
44
|
+
"""
|
|
45
|
+
if path.is_dir():
|
|
46
|
+
return _compute_directory_hash(path)
|
|
47
|
+
return _compute_file_hash(path)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _discover_skills(claude_dir: Path) -> list[InstalledArtifact]:
|
|
51
|
+
"""Discover skills in .claude/skills/ directory.
|
|
52
|
+
|
|
53
|
+
Skills are identified by their SKILL.md entry point file.
|
|
54
|
+
Pattern: skills/<skill-name>/SKILL.md
|
|
55
|
+
|
|
56
|
+
Content hash is computed over the entire skill directory (all files),
|
|
57
|
+
not just the SKILL.md entry point.
|
|
58
|
+
"""
|
|
59
|
+
skills_dir = claude_dir / "skills"
|
|
60
|
+
if not skills_dir.exists():
|
|
61
|
+
return []
|
|
62
|
+
|
|
63
|
+
artifacts: list[InstalledArtifact] = []
|
|
64
|
+
for skill_dir in skills_dir.iterdir():
|
|
65
|
+
if not skill_dir.is_dir():
|
|
66
|
+
continue
|
|
67
|
+
skill_file = skill_dir / "SKILL.md"
|
|
68
|
+
if skill_file.exists():
|
|
69
|
+
artifacts.append(
|
|
70
|
+
InstalledArtifact(
|
|
71
|
+
name=skill_dir.name,
|
|
72
|
+
artifact_type="skill",
|
|
73
|
+
path=skill_file,
|
|
74
|
+
# Hash the entire skill directory, not just SKILL.md
|
|
75
|
+
content_hash=_compute_directory_hash(skill_dir),
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
return artifacts
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _discover_commands(claude_dir: Path) -> list[InstalledArtifact]:
|
|
82
|
+
"""Discover commands in .claude/commands/ directory.
|
|
83
|
+
|
|
84
|
+
Commands can be:
|
|
85
|
+
- Top-level: commands/<command>.md (no namespace)
|
|
86
|
+
- Namespaced: commands/<namespace>/<command>.md
|
|
87
|
+
"""
|
|
88
|
+
commands_dir = claude_dir / "commands"
|
|
89
|
+
if not commands_dir.exists():
|
|
90
|
+
return []
|
|
91
|
+
|
|
92
|
+
artifacts: list[InstalledArtifact] = []
|
|
93
|
+
|
|
94
|
+
# Discover top-level commands (no namespace)
|
|
95
|
+
for cmd_file in commands_dir.glob("*.md"):
|
|
96
|
+
artifacts.append(
|
|
97
|
+
InstalledArtifact(
|
|
98
|
+
name=cmd_file.stem,
|
|
99
|
+
artifact_type="command",
|
|
100
|
+
path=cmd_file,
|
|
101
|
+
content_hash=_compute_content_hash(cmd_file),
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# Discover namespaced commands
|
|
106
|
+
for namespace_dir in commands_dir.iterdir():
|
|
107
|
+
if not namespace_dir.is_dir():
|
|
108
|
+
continue
|
|
109
|
+
for cmd_file in namespace_dir.glob("*.md"):
|
|
110
|
+
# Name includes namespace: "local:fast-ci" or "erk:plan-implement"
|
|
111
|
+
name = f"{namespace_dir.name}:{cmd_file.stem}"
|
|
112
|
+
artifacts.append(
|
|
113
|
+
InstalledArtifact(
|
|
114
|
+
name=name,
|
|
115
|
+
artifact_type="command",
|
|
116
|
+
path=cmd_file,
|
|
117
|
+
content_hash=_compute_content_hash(cmd_file),
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
return artifacts
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _discover_agents(claude_dir: Path) -> list[InstalledArtifact]:
|
|
124
|
+
"""Discover agents in .claude/agents/ directory.
|
|
125
|
+
|
|
126
|
+
Supports two patterns:
|
|
127
|
+
1. Directory-based: agents/<name>/<name>.md (hash computed over entire directory)
|
|
128
|
+
2. Single-file: agents/<name>.md (hash computed over single file)
|
|
129
|
+
|
|
130
|
+
Directory-based agents take precedence if both exist.
|
|
131
|
+
"""
|
|
132
|
+
agents_dir = claude_dir / "agents"
|
|
133
|
+
if not agents_dir.exists():
|
|
134
|
+
return []
|
|
135
|
+
|
|
136
|
+
artifacts: list[InstalledArtifact] = []
|
|
137
|
+
|
|
138
|
+
# First, discover directory-based agents: agents/<name>/<name>.md
|
|
139
|
+
for item in agents_dir.iterdir():
|
|
140
|
+
if item.is_dir():
|
|
141
|
+
agent_file = item / f"{item.name}.md"
|
|
142
|
+
if agent_file.exists():
|
|
143
|
+
artifacts.append(
|
|
144
|
+
InstalledArtifact(
|
|
145
|
+
name=item.name,
|
|
146
|
+
artifact_type="agent",
|
|
147
|
+
path=agent_file,
|
|
148
|
+
content_hash=_compute_directory_hash(item),
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# Track discovered names to avoid duplicates
|
|
153
|
+
discovered_names = {a.name for a in artifacts}
|
|
154
|
+
|
|
155
|
+
# Second, discover single-file agents: agents/<name>.md
|
|
156
|
+
for item in agents_dir.iterdir():
|
|
157
|
+
if item.is_file() and item.suffix == ".md":
|
|
158
|
+
name = item.stem
|
|
159
|
+
if name not in discovered_names:
|
|
160
|
+
artifacts.append(
|
|
161
|
+
InstalledArtifact(
|
|
162
|
+
name=name,
|
|
163
|
+
artifact_type="agent",
|
|
164
|
+
path=item,
|
|
165
|
+
content_hash=_compute_file_hash(item),
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
return artifacts
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _discover_workflows(workflows_dir: Path) -> list[InstalledArtifact]:
|
|
173
|
+
"""Discover all workflows in .github/workflows/ directory.
|
|
174
|
+
|
|
175
|
+
Discovers all .yml and .yaml files in the workflows directory.
|
|
176
|
+
|
|
177
|
+
Pattern: .github/workflows/<name>.yml
|
|
178
|
+
"""
|
|
179
|
+
if not workflows_dir.exists():
|
|
180
|
+
return []
|
|
181
|
+
|
|
182
|
+
artifacts: list[InstalledArtifact] = []
|
|
183
|
+
for workflow_file in workflows_dir.iterdir():
|
|
184
|
+
if not workflow_file.is_file():
|
|
185
|
+
continue
|
|
186
|
+
if workflow_file.suffix not in (".yml", ".yaml"):
|
|
187
|
+
continue
|
|
188
|
+
artifacts.append(
|
|
189
|
+
InstalledArtifact(
|
|
190
|
+
name=workflow_file.stem,
|
|
191
|
+
artifact_type="workflow",
|
|
192
|
+
path=workflow_file,
|
|
193
|
+
content_hash=_compute_content_hash(workflow_file),
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
return artifacts
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _discover_actions(actions_dir: Path) -> list[InstalledArtifact]:
|
|
200
|
+
"""Discover all actions in .github/actions/ directory.
|
|
201
|
+
|
|
202
|
+
Actions are directories containing an action.yml or action.yaml file.
|
|
203
|
+
|
|
204
|
+
Pattern: .github/actions/<name>/action.yml
|
|
205
|
+
"""
|
|
206
|
+
if not actions_dir.exists():
|
|
207
|
+
return []
|
|
208
|
+
|
|
209
|
+
artifacts: list[InstalledArtifact] = []
|
|
210
|
+
for action_path in actions_dir.iterdir():
|
|
211
|
+
if not action_path.is_dir():
|
|
212
|
+
continue
|
|
213
|
+
# Look for action.yml or action.yaml
|
|
214
|
+
action_file = action_path / "action.yml"
|
|
215
|
+
if not action_file.exists():
|
|
216
|
+
action_file = action_path / "action.yaml"
|
|
217
|
+
if not action_file.exists():
|
|
218
|
+
continue
|
|
219
|
+
artifacts.append(
|
|
220
|
+
InstalledArtifact(
|
|
221
|
+
name=action_path.name,
|
|
222
|
+
artifact_type="action",
|
|
223
|
+
path=action_file,
|
|
224
|
+
content_hash=_compute_directory_hash(action_path),
|
|
225
|
+
)
|
|
226
|
+
)
|
|
227
|
+
return artifacts
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _extract_hook_name(command: str) -> str:
|
|
231
|
+
"""Extract a meaningful name from a hook command.
|
|
232
|
+
|
|
233
|
+
For erk hooks, returns the known name.
|
|
234
|
+
For local hooks, returns the full command text for identification.
|
|
235
|
+
"""
|
|
236
|
+
# Check for erk-managed hooks first
|
|
237
|
+
if command == ERK_USER_PROMPT_HOOK_COMMAND:
|
|
238
|
+
return "user-prompt-hook"
|
|
239
|
+
if command == ERK_EXIT_PLAN_HOOK_COMMAND:
|
|
240
|
+
return "exit-plan-mode-hook"
|
|
241
|
+
|
|
242
|
+
# For local hooks, use the full command text as the identifier
|
|
243
|
+
return command
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _discover_hooks(claude_dir: Path) -> list[InstalledArtifact]:
|
|
247
|
+
"""Discover all hooks configured in .claude/settings.json.
|
|
248
|
+
|
|
249
|
+
Hooks are configuration entries in settings.json, not files.
|
|
250
|
+
Discovers both erk-managed hooks and local/user-defined hooks.
|
|
251
|
+
|
|
252
|
+
Pattern: hooks.<HookType>[].hooks[].command
|
|
253
|
+
"""
|
|
254
|
+
settings_path = claude_dir / "settings.json"
|
|
255
|
+
if not settings_path.exists():
|
|
256
|
+
return []
|
|
257
|
+
|
|
258
|
+
content = settings_path.read_text(encoding="utf-8")
|
|
259
|
+
settings = json.loads(content)
|
|
260
|
+
hooks_section = settings.get("hooks", {})
|
|
261
|
+
if not hooks_section:
|
|
262
|
+
return []
|
|
263
|
+
|
|
264
|
+
artifacts: list[InstalledArtifact] = []
|
|
265
|
+
seen_names: set[str] = set()
|
|
266
|
+
|
|
267
|
+
# Iterate through all hook types (UserPromptSubmit, PreToolUse, etc.)
|
|
268
|
+
for hook_entries in hooks_section.values():
|
|
269
|
+
if not isinstance(hook_entries, list):
|
|
270
|
+
continue
|
|
271
|
+
for entry in hook_entries:
|
|
272
|
+
if not isinstance(entry, dict):
|
|
273
|
+
continue
|
|
274
|
+
for hook in entry.get("hooks", []):
|
|
275
|
+
if not isinstance(hook, dict):
|
|
276
|
+
continue
|
|
277
|
+
command = hook.get("command")
|
|
278
|
+
if not command:
|
|
279
|
+
continue
|
|
280
|
+
|
|
281
|
+
name = _extract_hook_name(command)
|
|
282
|
+
|
|
283
|
+
# Avoid duplicates
|
|
284
|
+
if name in seen_names:
|
|
285
|
+
continue
|
|
286
|
+
seen_names.add(name)
|
|
287
|
+
|
|
288
|
+
artifacts.append(
|
|
289
|
+
InstalledArtifact(
|
|
290
|
+
name=name,
|
|
291
|
+
artifact_type="hook",
|
|
292
|
+
path=settings_path,
|
|
293
|
+
content_hash=_compute_hook_hash(command),
|
|
294
|
+
)
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
return artifacts
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def discover_artifacts(project_dir: Path) -> list[InstalledArtifact]:
|
|
301
|
+
"""Scan project directory and return all installed artifacts.
|
|
302
|
+
|
|
303
|
+
Discovers:
|
|
304
|
+
- skills: .claude/skills/<name>/SKILL.md
|
|
305
|
+
- commands: .claude/commands/<namespace>/<name>.md
|
|
306
|
+
- agents: .claude/agents/<name>/<name>.md
|
|
307
|
+
- workflows: .github/workflows/<name>.yml (all workflows)
|
|
308
|
+
- actions: .github/actions/<name>/action.yml (all actions)
|
|
309
|
+
- hooks: configured in .claude/settings.json
|
|
310
|
+
"""
|
|
311
|
+
claude_dir = project_dir / ".claude"
|
|
312
|
+
workflows_dir = project_dir / ".github" / "workflows"
|
|
313
|
+
actions_dir = project_dir / ".github" / "actions"
|
|
314
|
+
|
|
315
|
+
artifacts: list[InstalledArtifact] = []
|
|
316
|
+
|
|
317
|
+
if claude_dir.exists():
|
|
318
|
+
artifacts.extend(_discover_skills(claude_dir))
|
|
319
|
+
artifacts.extend(_discover_commands(claude_dir))
|
|
320
|
+
artifacts.extend(_discover_agents(claude_dir))
|
|
321
|
+
artifacts.extend(_discover_hooks(claude_dir))
|
|
322
|
+
|
|
323
|
+
artifacts.extend(_discover_workflows(workflows_dir))
|
|
324
|
+
artifacts.extend(_discover_actions(actions_dir))
|
|
325
|
+
|
|
326
|
+
# Sort by type then name for consistent output
|
|
327
|
+
return sorted(artifacts, key=lambda a: (a.artifact_type, a.name))
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def get_artifact_by_name(
|
|
331
|
+
project_dir: Path, name: str, artifact_type: ArtifactType | None
|
|
332
|
+
) -> InstalledArtifact | None:
|
|
333
|
+
"""Find a specific artifact by name.
|
|
334
|
+
|
|
335
|
+
If artifact_type is provided, only search that type.
|
|
336
|
+
Otherwise, search all types and return first match.
|
|
337
|
+
"""
|
|
338
|
+
artifacts = discover_artifacts(project_dir)
|
|
339
|
+
for artifact in artifacts:
|
|
340
|
+
if artifact.name == name:
|
|
341
|
+
if artifact_type is None or artifact.artifact_type == artifact_type:
|
|
342
|
+
return artifact
|
|
343
|
+
return None
|
erk/artifacts/models.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Data models for artifact management."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
# Type of artifact based on directory structure in .claude/ or .github/
|
|
9
|
+
# Note: "hook" is not file-based like others; it's a config entry in settings.json
|
|
10
|
+
ArtifactType = Literal["skill", "command", "agent", "workflow", "action", "hook"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class InstalledArtifact:
|
|
15
|
+
"""An artifact installed in a project's .claude/ directory."""
|
|
16
|
+
|
|
17
|
+
name: str
|
|
18
|
+
artifact_type: ArtifactType
|
|
19
|
+
path: Path
|
|
20
|
+
# Content hash for staleness detection (optional)
|
|
21
|
+
content_hash: str | None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class ArtifactFileState:
|
|
26
|
+
"""Per-artifact state tracking version and hash at sync time."""
|
|
27
|
+
|
|
28
|
+
version: str # erk version when this artifact was synced
|
|
29
|
+
hash: str # content hash at sync time
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class ArtifactState:
|
|
34
|
+
"""State stored in .erk/state.toml tracking installed artifacts."""
|
|
35
|
+
|
|
36
|
+
version: str # last full sync version (keep for backwards compat)
|
|
37
|
+
files: Mapping[str, ArtifactFileState] # key: artifact path like "skills/dignified-python"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class StalenessResult:
|
|
42
|
+
"""Result of checking artifact staleness."""
|
|
43
|
+
|
|
44
|
+
is_stale: bool
|
|
45
|
+
reason: Literal["not-initialized", "version-mismatch", "up-to-date", "erk-repo"]
|
|
46
|
+
current_version: str
|
|
47
|
+
installed_version: str | None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class OrphanCheckResult:
|
|
52
|
+
"""Result of checking for orphaned artifacts."""
|
|
53
|
+
|
|
54
|
+
orphans: dict[str, list[str]] # folder -> list of orphaned filenames
|
|
55
|
+
skipped_reason: Literal["erk-repo", "no-claude-dir", "no-bundled-dir"] | None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class CompletenessCheckResult:
|
|
60
|
+
"""Result of checking for missing bundled artifacts."""
|
|
61
|
+
|
|
62
|
+
missing: dict[str, list[str]] # folder -> list of missing filenames
|
|
63
|
+
skipped_reason: Literal["erk-repo", "no-claude-dir", "no-bundled-dir"] | None
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Check artifact staleness by comparing installed vs current erk version."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from erk.artifacts.detection import is_in_erk_repo
|
|
6
|
+
from erk.artifacts.models import StalenessResult
|
|
7
|
+
from erk.artifacts.state import load_artifact_state
|
|
8
|
+
from erk.core.release_notes import get_current_version
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def check_staleness(project_dir: Path) -> StalenessResult:
|
|
12
|
+
"""Check if artifacts are stale compared to current erk version.
|
|
13
|
+
|
|
14
|
+
Returns a StalenessResult with:
|
|
15
|
+
- is_stale: True if artifacts need to be synced
|
|
16
|
+
- reason: "not-initialized", "version-mismatch", "up-to-date", or "erk-repo"
|
|
17
|
+
- current_version: The installed erk package version
|
|
18
|
+
- installed_version: The version artifacts were last synced from (or None)
|
|
19
|
+
"""
|
|
20
|
+
current_version = get_current_version()
|
|
21
|
+
|
|
22
|
+
# In erk repo, artifacts are read from source - always up to date
|
|
23
|
+
# Still load state.toml to dogfood the state loading path
|
|
24
|
+
if is_in_erk_repo(project_dir):
|
|
25
|
+
state = load_artifact_state(project_dir)
|
|
26
|
+
return StalenessResult(
|
|
27
|
+
is_stale=False,
|
|
28
|
+
reason="erk-repo",
|
|
29
|
+
current_version=current_version,
|
|
30
|
+
installed_version=state.version if state else None,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
state = load_artifact_state(project_dir)
|
|
34
|
+
|
|
35
|
+
if state is None:
|
|
36
|
+
return StalenessResult(
|
|
37
|
+
is_stale=True,
|
|
38
|
+
reason="not-initialized",
|
|
39
|
+
current_version=current_version,
|
|
40
|
+
installed_version=None,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
if state.version != current_version:
|
|
44
|
+
return StalenessResult(
|
|
45
|
+
is_stale=True,
|
|
46
|
+
reason="version-mismatch",
|
|
47
|
+
current_version=current_version,
|
|
48
|
+
installed_version=state.version,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
return StalenessResult(
|
|
52
|
+
is_stale=False,
|
|
53
|
+
reason="up-to-date",
|
|
54
|
+
current_version=current_version,
|
|
55
|
+
installed_version=state.version,
|
|
56
|
+
)
|
erk/artifacts/state.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Load and save artifact state from .erk/state.toml."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import tomli
|
|
7
|
+
import tomli_w
|
|
8
|
+
|
|
9
|
+
from erk.artifacts.models import ArtifactFileState, ArtifactState
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _state_file_path(project_dir: Path) -> Path:
|
|
13
|
+
"""Return path to state file."""
|
|
14
|
+
return project_dir / ".erk" / "state.toml"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_artifact_state(project_dir: Path) -> ArtifactState | None:
|
|
18
|
+
"""Load artifact state from .erk/state.toml.
|
|
19
|
+
|
|
20
|
+
Returns None if the state file doesn't exist, has no artifacts section,
|
|
21
|
+
or is missing the required files section.
|
|
22
|
+
|
|
23
|
+
Format:
|
|
24
|
+
[artifacts]
|
|
25
|
+
version = "0.3.1"
|
|
26
|
+
|
|
27
|
+
[artifacts.files."skills/dignified-python"]
|
|
28
|
+
version = "0.3.0"
|
|
29
|
+
hash = "a1b2c3d4e5f6g7h8"
|
|
30
|
+
"""
|
|
31
|
+
path = _state_file_path(project_dir)
|
|
32
|
+
if not path.exists():
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
with path.open("rb") as f:
|
|
36
|
+
data = tomli.load(f)
|
|
37
|
+
|
|
38
|
+
if "artifacts" not in data:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
artifacts_data = data["artifacts"]
|
|
42
|
+
if "version" not in artifacts_data:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
# Require files section
|
|
46
|
+
if "files" not in artifacts_data:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
files: dict[str, ArtifactFileState] = {}
|
|
50
|
+
files_data = artifacts_data["files"]
|
|
51
|
+
|
|
52
|
+
for artifact_path, file_data in files_data.items():
|
|
53
|
+
if isinstance(file_data, dict) and "version" in file_data and "hash" in file_data:
|
|
54
|
+
files[artifact_path] = ArtifactFileState(
|
|
55
|
+
version=file_data["version"],
|
|
56
|
+
hash=file_data["hash"],
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
return ArtifactState(version=artifacts_data["version"], files=files)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def save_artifact_state(project_dir: Path, state: ArtifactState) -> None:
|
|
63
|
+
"""Save artifact state to .erk/state.toml.
|
|
64
|
+
|
|
65
|
+
Creates the .erk/ directory and state.toml file if they don't exist.
|
|
66
|
+
Preserves other sections in the file if it already exists.
|
|
67
|
+
|
|
68
|
+
Format:
|
|
69
|
+
[artifacts]
|
|
70
|
+
version = "0.3.1"
|
|
71
|
+
|
|
72
|
+
[artifacts.files."skills/dignified-python"]
|
|
73
|
+
version = "0.3.0"
|
|
74
|
+
hash = "a1b2c3d4e5f6g7h8"
|
|
75
|
+
"""
|
|
76
|
+
path = _state_file_path(project_dir)
|
|
77
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
|
|
79
|
+
# Load existing data to preserve other sections
|
|
80
|
+
existing_data: dict[str, Any] = {}
|
|
81
|
+
if path.exists():
|
|
82
|
+
with path.open("rb") as f:
|
|
83
|
+
existing_data = tomli.load(f)
|
|
84
|
+
|
|
85
|
+
# Build files section
|
|
86
|
+
files_data: dict[str, dict[str, str]] = {}
|
|
87
|
+
for artifact_path, file_state in state.files.items():
|
|
88
|
+
files_data[artifact_path] = {
|
|
89
|
+
"version": file_state.version,
|
|
90
|
+
"hash": file_state.hash,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
# Update artifacts section
|
|
94
|
+
existing_data["artifacts"] = {
|
|
95
|
+
"version": state.version,
|
|
96
|
+
"files": files_data,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
with path.open("wb") as f:
|
|
100
|
+
tomli_w.dump(existing_data, f)
|