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,338 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shlex
|
|
3
|
+
import subprocess
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Final
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from erk.cli.commands.prepare_cwd_recovery import generate_recovery_script
|
|
11
|
+
from erk.cli.shell_utils import (
|
|
12
|
+
STALE_SCRIPT_MAX_AGE_SECONDS,
|
|
13
|
+
cleanup_stale_scripts,
|
|
14
|
+
)
|
|
15
|
+
from erk.cli.uvx_detection import get_uvx_warning_message, is_running_via_uvx
|
|
16
|
+
from erk.core.context import create_context
|
|
17
|
+
from erk_shared.debug import debug_log
|
|
18
|
+
from erk_shared.output.output import user_confirm, user_output
|
|
19
|
+
|
|
20
|
+
PASSTHROUGH_MARKER: Final[str] = "__ERK_PASSTHROUGH__"
|
|
21
|
+
PASSTHROUGH_COMMANDS: Final[set[str]] = {"sync"}
|
|
22
|
+
|
|
23
|
+
# Global flags that should be stripped from args before command matching
|
|
24
|
+
# These are top-level flags that don't affect which command is being invoked
|
|
25
|
+
GLOBAL_FLAGS: Final[set[str]] = {"--debug", "--dry-run", "--verbose", "-v"}
|
|
26
|
+
|
|
27
|
+
# Commands that require shell integration (directory switching)
|
|
28
|
+
# Maps command names (as received from shell) to CLI command paths (for subprocess)
|
|
29
|
+
# Keys are what the shell handler receives, values are what gets passed to subprocess
|
|
30
|
+
SHELL_INTEGRATION_COMMANDS: Final[dict[str, list[str]]] = {
|
|
31
|
+
# Top-level commands (key matches CLI path)
|
|
32
|
+
"checkout": ["checkout"],
|
|
33
|
+
"co": ["checkout"], # Alias for checkout
|
|
34
|
+
"up": ["up"],
|
|
35
|
+
"down": ["down"],
|
|
36
|
+
"implement": ["implement"],
|
|
37
|
+
"impl": ["implement"], # Alias for implement
|
|
38
|
+
"land": ["land"], # Top-level land command
|
|
39
|
+
# Subcommands under pr
|
|
40
|
+
"pr checkout": ["pr", "checkout"],
|
|
41
|
+
"pr co": ["pr", "checkout"], # Alias for pr checkout
|
|
42
|
+
# Legacy top-level aliases (map to actual CLI paths)
|
|
43
|
+
"create": ["wt", "create"],
|
|
44
|
+
"consolidate": ["stack", "consolidate"],
|
|
45
|
+
# Subcommands under wt
|
|
46
|
+
"wt create": ["wt", "create"],
|
|
47
|
+
"wt checkout": ["wt", "checkout"],
|
|
48
|
+
"wt co": ["wt", "checkout"], # Alias for wt checkout
|
|
49
|
+
# Subcommands under stack
|
|
50
|
+
"stack consolidate": ["stack", "consolidate"],
|
|
51
|
+
# Subcommands under branch
|
|
52
|
+
"branch checkout": ["branch", "checkout"],
|
|
53
|
+
"branch co": ["branch", "checkout"],
|
|
54
|
+
"br checkout": ["branch", "checkout"],
|
|
55
|
+
"br co": ["branch", "checkout"],
|
|
56
|
+
"branch land": ["branch", "land"],
|
|
57
|
+
"br land": ["branch", "land"],
|
|
58
|
+
# Subcommands under slot
|
|
59
|
+
"slot checkout": ["slot", "checkout"],
|
|
60
|
+
"slot co": ["slot", "checkout"],
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class ShellIntegrationResult:
|
|
66
|
+
"""Result returned by shell integration handlers."""
|
|
67
|
+
|
|
68
|
+
passthrough: bool
|
|
69
|
+
script: str | None
|
|
70
|
+
exit_code: int
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def process_command_result(
|
|
74
|
+
exit_code: int,
|
|
75
|
+
stdout: str | None,
|
|
76
|
+
stderr: str | None,
|
|
77
|
+
command_name: str,
|
|
78
|
+
exception: BaseException | None = None,
|
|
79
|
+
) -> ShellIntegrationResult:
|
|
80
|
+
"""Process command result and determine shell integration behavior.
|
|
81
|
+
|
|
82
|
+
This function implements the core logic for deciding whether to use a script
|
|
83
|
+
or passthrough based on command output. It prioritizes script availability
|
|
84
|
+
over exit code to handle destructive commands that output scripts early.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
exit_code: Command exit code
|
|
88
|
+
stdout: Command stdout (expected to be script path if successful)
|
|
89
|
+
stderr: Command stderr (error messages)
|
|
90
|
+
command_name: Name of the command (for user messages)
|
|
91
|
+
exception: Exception from CliRunner result (e.g., Click's MissingParameter)
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
ShellIntegrationResult with passthrough, script, and exit_code
|
|
95
|
+
"""
|
|
96
|
+
script_path = stdout.strip() if stdout else None
|
|
97
|
+
|
|
98
|
+
debug_log(f"Handler: Got script_path={script_path}, exit_code={exit_code}")
|
|
99
|
+
|
|
100
|
+
# Check if the script exists (only if we have a path)
|
|
101
|
+
script_exists = False
|
|
102
|
+
if script_path:
|
|
103
|
+
script_exists = Path(script_path).exists()
|
|
104
|
+
debug_log(f"Handler: Script exists? {script_exists}")
|
|
105
|
+
|
|
106
|
+
# If we have a valid script, use it even if command had errors.
|
|
107
|
+
# This handles destructive commands (like pr land) that output the script
|
|
108
|
+
# before failure. The shell can still navigate to the destination.
|
|
109
|
+
if script_path and script_exists:
|
|
110
|
+
# Forward stderr so user sees status messages even on success
|
|
111
|
+
# (e.g., "✓ Removed worktree", "✓ Deleted branch", etc.)
|
|
112
|
+
if stderr:
|
|
113
|
+
user_output(stderr, nl=False)
|
|
114
|
+
return ShellIntegrationResult(passthrough=False, script=script_path, exit_code=exit_code)
|
|
115
|
+
|
|
116
|
+
# No script available - if command failed, forward the error and don't passthrough.
|
|
117
|
+
# Passthrough would run the command again WITHOUT --script, which for commands
|
|
118
|
+
# like 'pr land' would show a misleading "requires shell integration" error
|
|
119
|
+
# instead of the actual failure reason.
|
|
120
|
+
if exit_code != 0:
|
|
121
|
+
if stderr:
|
|
122
|
+
user_output(stderr, nl=False)
|
|
123
|
+
elif exception is not None:
|
|
124
|
+
# Handle Click exceptions that don't go to stderr (e.g., MissingParameter)
|
|
125
|
+
# When using standalone_mode=False, Click stores usage errors in result.exception
|
|
126
|
+
# but leaves stderr empty, causing silent exits without this handling.
|
|
127
|
+
user_output(f"Error: {exception}")
|
|
128
|
+
return ShellIntegrationResult(passthrough=False, script=None, exit_code=exit_code)
|
|
129
|
+
|
|
130
|
+
# Forward stderr messages to user (only for successful commands)
|
|
131
|
+
if stderr:
|
|
132
|
+
user_output(stderr, nl=False)
|
|
133
|
+
|
|
134
|
+
# Note when command completed successfully but no directory change is needed
|
|
135
|
+
if script_path is None or not script_path:
|
|
136
|
+
user_output(f"Note: '{command_name}' completed (no directory change needed)")
|
|
137
|
+
|
|
138
|
+
return ShellIntegrationResult(passthrough=False, script=script_path, exit_code=exit_code)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _invoke_hidden_command(command_name: str, args: tuple[str, ...]) -> ShellIntegrationResult:
|
|
142
|
+
"""Invoke a command with --script flag for shell integration.
|
|
143
|
+
|
|
144
|
+
If args contain help flags or explicit --script, passthrough to regular command.
|
|
145
|
+
Otherwise, add --script flag and run as subprocess with live stderr streaming.
|
|
146
|
+
|
|
147
|
+
Uses subprocess.run instead of CliRunner to allow stderr (user messages)
|
|
148
|
+
to stream directly to the terminal in real-time, while capturing stdout
|
|
149
|
+
(the activation script path) for shell integration.
|
|
150
|
+
"""
|
|
151
|
+
# Check if help flags, --script, --dry-run, or non-interactive flags are present
|
|
152
|
+
# These should pass through to avoid shell integration adding --script.
|
|
153
|
+
# --yolo and --no-interactive conflict with --script (mutually exclusive).
|
|
154
|
+
passthrough_flags = {"-h", "--help", "--script", "--dry-run", "--yolo", "--no-interactive"}
|
|
155
|
+
if passthrough_flags & set(args):
|
|
156
|
+
return ShellIntegrationResult(passthrough=True, script=None, exit_code=0)
|
|
157
|
+
|
|
158
|
+
cli_cmd_parts = SHELL_INTEGRATION_COMMANDS.get(command_name)
|
|
159
|
+
if cli_cmd_parts is None:
|
|
160
|
+
if command_name in PASSTHROUGH_COMMANDS:
|
|
161
|
+
return _build_passthrough_script(command_name, args)
|
|
162
|
+
return ShellIntegrationResult(passthrough=True, script=None, exit_code=0)
|
|
163
|
+
|
|
164
|
+
# Check for uvx invocation and warn (command is already confirmed in SHELL_INTEGRATION_COMMANDS)
|
|
165
|
+
if is_running_via_uvx():
|
|
166
|
+
user_output(click.style("Warning: ", fg="yellow") + get_uvx_warning_message(command_name))
|
|
167
|
+
user_output("") # Blank line for readability
|
|
168
|
+
if not user_confirm("Continue anyway?", default=False):
|
|
169
|
+
return ShellIntegrationResult(passthrough=False, script=None, exit_code=1)
|
|
170
|
+
|
|
171
|
+
# Clean up stale scripts before running (opportunistic cleanup)
|
|
172
|
+
cleanup_stale_scripts(max_age_seconds=STALE_SCRIPT_MAX_AGE_SECONDS)
|
|
173
|
+
|
|
174
|
+
# Build full command: erk <cli_cmd_parts> <args> --script
|
|
175
|
+
# cli_cmd_parts contains the actual CLI path (e.g., ["wt", "create"] for "create")
|
|
176
|
+
cmd = ["erk", *cli_cmd_parts, *args, "--script"]
|
|
177
|
+
|
|
178
|
+
debug_log(f"Handler: Running subprocess: {cmd}")
|
|
179
|
+
|
|
180
|
+
# Run subprocess with:
|
|
181
|
+
# - stdout captured (contains activation script path)
|
|
182
|
+
# - stderr passed through to terminal (live streaming of user messages)
|
|
183
|
+
result = subprocess.run(
|
|
184
|
+
cmd,
|
|
185
|
+
stdout=subprocess.PIPE, # Capture stdout for script path
|
|
186
|
+
stderr=None, # Let stderr pass through to terminal (live streaming)
|
|
187
|
+
text=True,
|
|
188
|
+
check=False, # Don't raise on non-zero exit
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
return process_command_result(
|
|
192
|
+
exit_code=result.returncode,
|
|
193
|
+
stdout=result.stdout,
|
|
194
|
+
stderr=None, # stderr already shown to user
|
|
195
|
+
command_name=command_name,
|
|
196
|
+
exception=None, # No exception from subprocess
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def handle_shell_request(args: tuple[str, ...]) -> ShellIntegrationResult:
|
|
201
|
+
"""Dispatch shell integration handling based on the original CLI invocation."""
|
|
202
|
+
if len(args) == 0:
|
|
203
|
+
return ShellIntegrationResult(passthrough=True, script=None, exit_code=0)
|
|
204
|
+
|
|
205
|
+
# Strip global flags from the beginning of args before command matching
|
|
206
|
+
# This ensures commands like "erk --debug pr land" are recognized correctly
|
|
207
|
+
args_list = list(args)
|
|
208
|
+
while args_list and args_list[0] in GLOBAL_FLAGS:
|
|
209
|
+
args_list.pop(0)
|
|
210
|
+
|
|
211
|
+
if len(args_list) == 0:
|
|
212
|
+
return ShellIntegrationResult(passthrough=True, script=None, exit_code=0)
|
|
213
|
+
|
|
214
|
+
# Try compound command first (e.g., "wt create", "stack consolidate")
|
|
215
|
+
if len(args_list) >= 2:
|
|
216
|
+
compound_name = f"{args_list[0]} {args_list[1]}"
|
|
217
|
+
if compound_name in SHELL_INTEGRATION_COMMANDS:
|
|
218
|
+
return _invoke_hidden_command(compound_name, tuple(args_list[2:]))
|
|
219
|
+
|
|
220
|
+
# Fall back to single command
|
|
221
|
+
command_name = args_list[0]
|
|
222
|
+
command_args = tuple(args_list[1:]) if len(args_list) > 1 else ()
|
|
223
|
+
return _invoke_hidden_command(command_name, command_args)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _build_passthrough_script(command_name: str, args: tuple[str, ...]) -> ShellIntegrationResult:
|
|
227
|
+
"""Create a passthrough script tailored for the caller's shell."""
|
|
228
|
+
shell_name = os.environ.get("ERK_SHELL", "bash").lower()
|
|
229
|
+
ctx = create_context(dry_run=False)
|
|
230
|
+
recovery_path = generate_recovery_script(ctx)
|
|
231
|
+
|
|
232
|
+
script_content = _render_passthrough_script(shell_name, command_name, args, recovery_path)
|
|
233
|
+
result = ctx.script_writer.write_activation_script(
|
|
234
|
+
script_content,
|
|
235
|
+
command_name=f"{command_name}-passthrough",
|
|
236
|
+
comment="generated by __shell passthrough handler",
|
|
237
|
+
)
|
|
238
|
+
return ShellIntegrationResult(passthrough=False, script=str(result.path), exit_code=0)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _render_passthrough_script(
|
|
242
|
+
shell_name: str,
|
|
243
|
+
command_name: str,
|
|
244
|
+
args: tuple[str, ...],
|
|
245
|
+
recovery_path: Path | None,
|
|
246
|
+
) -> str:
|
|
247
|
+
"""Render shell-specific script that runs the command and performs recovery."""
|
|
248
|
+
if shell_name == "fish":
|
|
249
|
+
return _render_fish_passthrough(command_name, args, recovery_path)
|
|
250
|
+
return _render_posix_passthrough(command_name, args, recovery_path)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _render_posix_passthrough(
|
|
254
|
+
command_name: str,
|
|
255
|
+
args: tuple[str, ...],
|
|
256
|
+
recovery_path: Path | None,
|
|
257
|
+
) -> str:
|
|
258
|
+
quoted_args = " ".join(shlex.quote(part) for part in (command_name, *args))
|
|
259
|
+
recovery_literal = shlex.quote(str(recovery_path)) if recovery_path is not None else "''"
|
|
260
|
+
lines = [
|
|
261
|
+
f"command erk {quoted_args}",
|
|
262
|
+
"__erk_exit=$?",
|
|
263
|
+
f"__erk_recovery={recovery_literal}",
|
|
264
|
+
'if [ -n "$__erk_recovery" ] && [ -f "$__erk_recovery" ]; then',
|
|
265
|
+
' if [ ! -d "$PWD" ]; then',
|
|
266
|
+
' . "$__erk_recovery"',
|
|
267
|
+
" fi",
|
|
268
|
+
' if [ -z "$ERK_KEEP_SCRIPTS" ]; then',
|
|
269
|
+
' rm -f "$__erk_recovery"',
|
|
270
|
+
" fi",
|
|
271
|
+
"fi",
|
|
272
|
+
"return $__erk_exit",
|
|
273
|
+
]
|
|
274
|
+
return "\n".join(lines) + "\n"
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _quote_fish(arg: str) -> str:
|
|
278
|
+
if not arg:
|
|
279
|
+
return '""'
|
|
280
|
+
|
|
281
|
+
escape_map = {
|
|
282
|
+
"\\": "\\\\",
|
|
283
|
+
'"': '\\"',
|
|
284
|
+
"$": "\\$",
|
|
285
|
+
"`": "\\`",
|
|
286
|
+
"~": "\\~",
|
|
287
|
+
"*": "\\*",
|
|
288
|
+
"?": "\\?",
|
|
289
|
+
"{": "\\{",
|
|
290
|
+
"}": "\\}",
|
|
291
|
+
"[": "\\[",
|
|
292
|
+
"]": "\\]",
|
|
293
|
+
"(": "\\(",
|
|
294
|
+
")": "\\)",
|
|
295
|
+
"<": "\\<",
|
|
296
|
+
">": "\\>",
|
|
297
|
+
"|": "\\|",
|
|
298
|
+
";": "\\;",
|
|
299
|
+
"&": "\\&",
|
|
300
|
+
}
|
|
301
|
+
escaped_parts: list[str] = []
|
|
302
|
+
for char in arg:
|
|
303
|
+
if char == "\n":
|
|
304
|
+
escaped_parts.append("\\n")
|
|
305
|
+
continue
|
|
306
|
+
if char == "\t":
|
|
307
|
+
escaped_parts.append("\\t")
|
|
308
|
+
continue
|
|
309
|
+
escaped_parts.append(escape_map.get(char, char))
|
|
310
|
+
|
|
311
|
+
escaped = "".join(escaped_parts)
|
|
312
|
+
return f'"{escaped}"'
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _render_fish_passthrough(
|
|
316
|
+
command_name: str,
|
|
317
|
+
args: tuple[str, ...],
|
|
318
|
+
recovery_path: Path | None,
|
|
319
|
+
) -> str:
|
|
320
|
+
command_parts = " ".join(_quote_fish(part) for part in (command_name, *args))
|
|
321
|
+
recovery_literal = _quote_fish(str(recovery_path)) if recovery_path is not None else '""'
|
|
322
|
+
lines = [
|
|
323
|
+
f"command erk {command_parts}",
|
|
324
|
+
"set __erk_exit $status",
|
|
325
|
+
f"set __erk_recovery {recovery_literal}",
|
|
326
|
+
'if test -n "$__erk_recovery"',
|
|
327
|
+
' if test -f "$__erk_recovery"',
|
|
328
|
+
' if not test -d "$PWD"',
|
|
329
|
+
' source "$__erk_recovery"',
|
|
330
|
+
" end",
|
|
331
|
+
" if not set -q ERK_KEEP_SCRIPTS",
|
|
332
|
+
' rm -f "$__erk_recovery"',
|
|
333
|
+
" end",
|
|
334
|
+
" end",
|
|
335
|
+
"end",
|
|
336
|
+
"return $__erk_exit",
|
|
337
|
+
]
|
|
338
|
+
return "\n".join(lines) + "\n"
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Erk shell integration for zsh
|
|
2
|
+
# This function wraps the erk CLI to provide seamless worktree switching
|
|
3
|
+
|
|
4
|
+
erk() {
|
|
5
|
+
# Don't intercept if we're doing shell completion
|
|
6
|
+
[ -n "$_ERK_COMPLETE" ] && { command erk "$@"; return; }
|
|
7
|
+
|
|
8
|
+
local script_path exit_status
|
|
9
|
+
script_path=$(ERK_SHELL=zsh command erk __shell "$@")
|
|
10
|
+
exit_status=$?
|
|
11
|
+
|
|
12
|
+
# Passthrough mode: run the original command directly
|
|
13
|
+
[ "$script_path" = "__ERK_PASSTHROUGH__" ] && { command erk "$@"; return; }
|
|
14
|
+
|
|
15
|
+
# Source the script file if it exists, regardless of exit code.
|
|
16
|
+
# This matches Python handler logic: use script even if command had errors.
|
|
17
|
+
# The script contains important state changes (like cd to target dir).
|
|
18
|
+
if [ -n "$script_path" ] && [ -f "$script_path" ]; then
|
|
19
|
+
source "$script_path"
|
|
20
|
+
local source_exit=$?
|
|
21
|
+
|
|
22
|
+
# Clean up unless ERK_KEEP_SCRIPTS is set
|
|
23
|
+
if [ -z "$ERK_KEEP_SCRIPTS" ]; then
|
|
24
|
+
rm -f "$script_path"
|
|
25
|
+
fi
|
|
26
|
+
|
|
27
|
+
return $source_exit
|
|
28
|
+
fi
|
|
29
|
+
|
|
30
|
+
# Only return exit_status if no script was provided
|
|
31
|
+
[ $exit_status -ne 0 ] && return $exit_status
|
|
32
|
+
}
|
erk/cli/shell_utils.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Utilities for generating shell integration scripts."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tempfile
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from erk.cli.activation import _render_logging_helper, render_activation_script
|
|
11
|
+
from erk_shared.debug import debug_log
|
|
12
|
+
|
|
13
|
+
STALE_SCRIPT_MAX_AGE_SECONDS = 3600
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def render_cd_script(path: Path, *, comment: str, success_message: str) -> str:
|
|
17
|
+
"""Generate shell script to change directory with feedback.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
path: Target directory path to cd into.
|
|
21
|
+
comment: Shell comment describing the operation.
|
|
22
|
+
success_message: Message to echo after successful cd.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
Shell script that changes directory and shows success message.
|
|
26
|
+
"""
|
|
27
|
+
path_str = str(path)
|
|
28
|
+
path_name = path.name
|
|
29
|
+
quoted_path = "'" + path_str.replace("'", "'\\''") + "'"
|
|
30
|
+
logging_helper = _render_logging_helper()
|
|
31
|
+
lines = [
|
|
32
|
+
f"# {comment}",
|
|
33
|
+
logging_helper,
|
|
34
|
+
f'__erk_log "->" "Switching to: {path_name}"',
|
|
35
|
+
f'__erk_log_verbose "->" "Directory: $(pwd) -> {path}"',
|
|
36
|
+
f"cd {quoted_path}",
|
|
37
|
+
f'echo "{success_message}"',
|
|
38
|
+
]
|
|
39
|
+
return "\n".join(lines) + "\n"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def render_navigation_script(
|
|
43
|
+
target_path: Path,
|
|
44
|
+
repo_root: Path,
|
|
45
|
+
*,
|
|
46
|
+
comment: str,
|
|
47
|
+
success_message: str,
|
|
48
|
+
) -> str:
|
|
49
|
+
"""Generate navigation script that automatically chooses between simple cd or full activation.
|
|
50
|
+
|
|
51
|
+
This function determines whether the target is the root worktree or a non-root worktree
|
|
52
|
+
and generates the appropriate navigation script:
|
|
53
|
+
|
|
54
|
+
- Root worktree (target_path == repo_root): Simple cd script via render_cd_script()
|
|
55
|
+
- Only changes directory
|
|
56
|
+
- No venv activation needed (user manages their own environment)
|
|
57
|
+
|
|
58
|
+
- Non-root worktree (target_path != repo_root): Full activation script via
|
|
59
|
+
render_activation_script()
|
|
60
|
+
- Changes directory
|
|
61
|
+
- Creates/activates virtual environment
|
|
62
|
+
- Loads .env file
|
|
63
|
+
- Required for erk-managed worktrees
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
target_path: Directory to navigate to
|
|
67
|
+
repo_root: Repository root path (used to determine if target is root worktree)
|
|
68
|
+
comment: Shell comment describing the operation
|
|
69
|
+
success_message: Message to display after successful navigation
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Shell script that performs appropriate navigation based on worktree type
|
|
73
|
+
|
|
74
|
+
Example:
|
|
75
|
+
>>> # Navigate to root worktree (simple cd)
|
|
76
|
+
>>> script = render_navigation_script(
|
|
77
|
+
... Path("/repo"),
|
|
78
|
+
... Path("/repo"),
|
|
79
|
+
... comment="return to root",
|
|
80
|
+
... success_message="At root"
|
|
81
|
+
... )
|
|
82
|
+
>>>
|
|
83
|
+
>>> # Navigate to non-root worktree (full activation)
|
|
84
|
+
>>> script = render_navigation_script(
|
|
85
|
+
... Path("/repo/worktrees/feature"),
|
|
86
|
+
... Path("/repo"),
|
|
87
|
+
... comment="switch to feature",
|
|
88
|
+
... success_message="Activated feature"
|
|
89
|
+
... )
|
|
90
|
+
"""
|
|
91
|
+
if target_path == repo_root:
|
|
92
|
+
return render_cd_script(
|
|
93
|
+
target_path,
|
|
94
|
+
comment=comment,
|
|
95
|
+
success_message=success_message,
|
|
96
|
+
)
|
|
97
|
+
return render_activation_script(
|
|
98
|
+
worktree_path=target_path,
|
|
99
|
+
target_subpath=None,
|
|
100
|
+
post_cd_commands=None,
|
|
101
|
+
final_message=f'echo "{success_message}"',
|
|
102
|
+
comment=comment,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def write_script_to_temp(
|
|
107
|
+
script_content: str,
|
|
108
|
+
*,
|
|
109
|
+
command_name: str,
|
|
110
|
+
comment: str | None = None,
|
|
111
|
+
) -> Path:
|
|
112
|
+
"""Write shell script to temp file with unique UUID.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
script_content: The shell script to write
|
|
116
|
+
command_name: Command that generated this (e.g., 'sync', 'switch', 'create')
|
|
117
|
+
comment: Optional comment to include in script header
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
Path to the temp file
|
|
121
|
+
|
|
122
|
+
Filename format: erk-{command}-{uuid}.sh
|
|
123
|
+
"""
|
|
124
|
+
unique_id = uuid.uuid4().hex[:8] # 8 chars sufficient (4 billion combinations)
|
|
125
|
+
temp_dir = Path(tempfile.gettempdir())
|
|
126
|
+
temp_file = temp_dir / f"erk-{command_name}-{unique_id}.sh"
|
|
127
|
+
|
|
128
|
+
# Add metadata header
|
|
129
|
+
header = [
|
|
130
|
+
"#!/bin/bash",
|
|
131
|
+
f"# erk {command_name}",
|
|
132
|
+
f"# Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
|
133
|
+
f"# UUID: {unique_id}",
|
|
134
|
+
f"# User: {os.getenv('USER', 'unknown')}",
|
|
135
|
+
f"# Working dir: {Path.cwd()}",
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
if comment:
|
|
139
|
+
header.append(f"# {comment}")
|
|
140
|
+
|
|
141
|
+
header.append("") # Blank line before script
|
|
142
|
+
|
|
143
|
+
full_content = "\n".join(header) + "\n" + script_content
|
|
144
|
+
temp_file.write_text(full_content, encoding="utf-8")
|
|
145
|
+
|
|
146
|
+
# Make executable for good measure
|
|
147
|
+
temp_file.chmod(0o755)
|
|
148
|
+
|
|
149
|
+
debug_log(f"write_script_to_temp: Created {temp_file}")
|
|
150
|
+
debug_log(f"write_script_to_temp: Content:\n{full_content}")
|
|
151
|
+
|
|
152
|
+
return temp_file
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def cleanup_stale_scripts(*, max_age_seconds: int = STALE_SCRIPT_MAX_AGE_SECONDS) -> None:
|
|
156
|
+
"""Remove erk temp scripts older than max_age_seconds.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
max_age_seconds: Maximum age before cleanup (default 1 hour)
|
|
160
|
+
"""
|
|
161
|
+
temp_dir = Path(tempfile.gettempdir())
|
|
162
|
+
cutoff = time.time() - max_age_seconds
|
|
163
|
+
|
|
164
|
+
for script_file in temp_dir.glob("erk-*.sh"):
|
|
165
|
+
if script_file.exists():
|
|
166
|
+
try:
|
|
167
|
+
if script_file.stat().st_mtime < cutoff:
|
|
168
|
+
script_file.unlink()
|
|
169
|
+
except (FileNotFoundError, PermissionError):
|
|
170
|
+
# Scripts may disappear between stat/unlink or be owned by another user.
|
|
171
|
+
continue
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Utilities for running subprocesses with better error reporting.
|
|
2
|
+
|
|
3
|
+
This module provides CLI-layer subprocess execution with user-friendly error output.
|
|
4
|
+
|
|
5
|
+
For integration layer subprocess calls (raises RuntimeError), use:
|
|
6
|
+
from erk_shared.subprocess_utils import run_subprocess_with_context
|
|
7
|
+
|
|
8
|
+
For CLI-layer subprocess calls (prints message, raises SystemExit), use:
|
|
9
|
+
from erk.cli.subprocess_utils import run_with_error_reporting (this module)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import subprocess
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from erk_shared.output.output import user_output
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def run_with_error_reporting(
|
|
20
|
+
cmd: Sequence[str],
|
|
21
|
+
*,
|
|
22
|
+
cwd: Path | None = None,
|
|
23
|
+
error_prefix: str = "Command failed",
|
|
24
|
+
troubleshooting: list[str] | None = None,
|
|
25
|
+
show_output: bool = False,
|
|
26
|
+
) -> subprocess.CompletedProcess[str]:
|
|
27
|
+
"""Run subprocess command with user-friendly error reporting for CLI layer.
|
|
28
|
+
|
|
29
|
+
This function is designed for CLI commands that need to display error messages
|
|
30
|
+
directly to users and exit the program. For integration layer code that needs
|
|
31
|
+
to raise exceptions with context, use run_subprocess_with_context() instead.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
cmd: Command to run as a list of strings
|
|
35
|
+
cwd: Working directory for the command
|
|
36
|
+
error_prefix: Prefix for error message
|
|
37
|
+
troubleshooting: Optional list of troubleshooting suggestions
|
|
38
|
+
show_output: If True, show stdout/stderr in real-time (default: False)
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
CompletedProcess if successful
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
SystemExit: If command fails (after displaying user-friendly error)
|
|
45
|
+
|
|
46
|
+
Example:
|
|
47
|
+
>>> run_with_error_reporting(
|
|
48
|
+
... ["gh", "pr", "view", "123"],
|
|
49
|
+
... cwd=repo_root,
|
|
50
|
+
... error_prefix="Failed to view PR",
|
|
51
|
+
... troubleshooting=["Ensure gh is installed", "Run 'gh auth login'"]
|
|
52
|
+
... )
|
|
53
|
+
|
|
54
|
+
Notes:
|
|
55
|
+
- This is for CLI-layer code (commands that interact with users)
|
|
56
|
+
- For integration layer code, use run_subprocess_with_context() instead
|
|
57
|
+
- Uses check=False and manually handles errors for user-friendly output
|
|
58
|
+
- Displays stderr/stdout to user before raising SystemExit
|
|
59
|
+
- When show_output=True, output streams directly to terminal
|
|
60
|
+
"""
|
|
61
|
+
result = subprocess.run(cmd, cwd=cwd, check=False, capture_output=not show_output, text=True)
|
|
62
|
+
|
|
63
|
+
if result.returncode != 0:
|
|
64
|
+
# When show_output=True, output already displayed, only show error context
|
|
65
|
+
if show_output:
|
|
66
|
+
message_parts = [
|
|
67
|
+
f"Error: {error_prefix}.\n",
|
|
68
|
+
f"Command: {' '.join(cmd)}",
|
|
69
|
+
f"Exit code: {result.returncode}\n",
|
|
70
|
+
]
|
|
71
|
+
else:
|
|
72
|
+
error_msg = result.stderr.strip() if result.stderr else result.stdout.strip()
|
|
73
|
+
|
|
74
|
+
# Build error message
|
|
75
|
+
message_parts = [
|
|
76
|
+
f"Error: {error_prefix}.\n",
|
|
77
|
+
f"Command: {' '.join(cmd)}",
|
|
78
|
+
f"Exit code: {result.returncode}\n",
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
if error_msg:
|
|
82
|
+
message_parts.append(f"Output:\n{error_msg}\n")
|
|
83
|
+
|
|
84
|
+
if troubleshooting:
|
|
85
|
+
message_parts.append("Troubleshooting:")
|
|
86
|
+
for tip in troubleshooting:
|
|
87
|
+
message_parts.append(f" • {tip}")
|
|
88
|
+
|
|
89
|
+
user_output("\n".join(message_parts))
|
|
90
|
+
raise SystemExit(1)
|
|
91
|
+
|
|
92
|
+
return result
|
erk/cli/uvx_detection.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Detection for uvx (uv tool run) invocation.
|
|
2
|
+
|
|
3
|
+
This module detects when erk is running via 'uvx erk' or 'uv tool run erk',
|
|
4
|
+
which prevents shell integration from working properly.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def is_running_via_uvx() -> bool:
|
|
12
|
+
"""Detect if running via uvx/uv tool run (best effort, not officially supported).
|
|
13
|
+
|
|
14
|
+
Detection strategy:
|
|
15
|
+
1. Check pyvenv.cfg for uv marker - if absent, not a uv-created venv
|
|
16
|
+
2. Check if prefix path contains ephemeral cache markers (uvx uses cache, not tool dir)
|
|
17
|
+
|
|
18
|
+
Note: uvx environments are ephemeral (live in cache directory), while
|
|
19
|
+
`uv tool install` environments are persistent. We only want to detect uvx.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
True if running via uvx, False otherwise
|
|
23
|
+
"""
|
|
24
|
+
prefix = Path(sys.prefix)
|
|
25
|
+
|
|
26
|
+
# Check pyvenv.cfg for uv marker
|
|
27
|
+
pyvenv_cfg = prefix / "pyvenv.cfg"
|
|
28
|
+
if pyvenv_cfg.exists():
|
|
29
|
+
content = pyvenv_cfg.read_text(encoding="utf-8")
|
|
30
|
+
if "uv = " not in content:
|
|
31
|
+
return False # Not a uv-created venv
|
|
32
|
+
|
|
33
|
+
# uvx environments are ephemeral (in cache), not persistent (in UV_TOOL_DIR)
|
|
34
|
+
prefix_str = str(prefix)
|
|
35
|
+
ephemeral_markers = (
|
|
36
|
+
"/uv/archive-v", # uvx ephemeral environments
|
|
37
|
+
"/.cache/uv/",
|
|
38
|
+
"/cache/uv/",
|
|
39
|
+
)
|
|
40
|
+
return any(marker in prefix_str for marker in ephemeral_markers)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_uvx_warning_message(command_name: str) -> str:
|
|
44
|
+
"""Get the warning message to display when running via uvx.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
command_name: The shell integration command being invoked (e.g., "checkout", "up")
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Multi-line warning message explaining the issue and fix
|
|
51
|
+
"""
|
|
52
|
+
return f"""Running 'erk {command_name}' via uvx - this command requires shell integration
|
|
53
|
+
|
|
54
|
+
Shell integration commands need to change your shell's directory, which doesn't work
|
|
55
|
+
when running in uvx's isolated subprocess.
|
|
56
|
+
|
|
57
|
+
To fix this:
|
|
58
|
+
1. Install erk in uv's tools: uv tool install erk
|
|
59
|
+
2. Set up shell integration: erk init --shell"""
|
erk/core/__init__.py
ADDED
|
File without changes
|