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,511 @@
|
|
|
1
|
+
"""Claude CLI execution abstraction.
|
|
2
|
+
|
|
3
|
+
This module provides the RealClaudeExecutor implementation and re-exports
|
|
4
|
+
ABC and types from erk_shared.core for backward compatibility.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import threading
|
|
14
|
+
from collections.abc import Iterator
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
# Re-export ABC and types from erk_shared.core for backward compatibility
|
|
18
|
+
from erk_shared.core.claude_executor import ClaudeEvent as ClaudeEvent
|
|
19
|
+
from erk_shared.core.claude_executor import ClaudeExecutor as ClaudeExecutor
|
|
20
|
+
from erk_shared.core.claude_executor import CommandResult as CommandResult
|
|
21
|
+
from erk_shared.core.claude_executor import ErrorEvent as ErrorEvent
|
|
22
|
+
from erk_shared.core.claude_executor import IssueNumberEvent as IssueNumberEvent
|
|
23
|
+
from erk_shared.core.claude_executor import NoOutputEvent as NoOutputEvent
|
|
24
|
+
from erk_shared.core.claude_executor import NoTurnsEvent as NoTurnsEvent
|
|
25
|
+
from erk_shared.core.claude_executor import PrNumberEvent as PrNumberEvent
|
|
26
|
+
from erk_shared.core.claude_executor import ProcessErrorEvent as ProcessErrorEvent
|
|
27
|
+
from erk_shared.core.claude_executor import PromptResult as PromptResult
|
|
28
|
+
from erk_shared.core.claude_executor import PrTitleEvent as PrTitleEvent
|
|
29
|
+
from erk_shared.core.claude_executor import PrUrlEvent as PrUrlEvent
|
|
30
|
+
from erk_shared.core.claude_executor import SpinnerUpdateEvent as SpinnerUpdateEvent
|
|
31
|
+
from erk_shared.core.claude_executor import TextEvent as TextEvent
|
|
32
|
+
from erk_shared.core.claude_executor import ToolEvent as ToolEvent
|
|
33
|
+
|
|
34
|
+
# Constants for process execution
|
|
35
|
+
PROCESS_TIMEOUT_SECONDS = 600 # 10 minutes
|
|
36
|
+
STDERR_JOIN_TIMEOUT = 5.0 # 5 seconds (increased from 1.0)
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class RealClaudeExecutor(ClaudeExecutor):
|
|
42
|
+
"""Production implementation using subprocess and Claude CLI."""
|
|
43
|
+
|
|
44
|
+
def is_claude_available(self) -> bool:
|
|
45
|
+
"""Check if Claude CLI is in PATH using shutil.which."""
|
|
46
|
+
return shutil.which("claude") is not None
|
|
47
|
+
|
|
48
|
+
def execute_command_streaming(
|
|
49
|
+
self,
|
|
50
|
+
command: str,
|
|
51
|
+
worktree_path: Path,
|
|
52
|
+
dangerous: bool,
|
|
53
|
+
verbose: bool = False,
|
|
54
|
+
debug: bool = False,
|
|
55
|
+
model: str | None = None,
|
|
56
|
+
) -> Iterator[ClaudeEvent]:
|
|
57
|
+
"""Execute Claude CLI command and yield typed events in real-time.
|
|
58
|
+
|
|
59
|
+
Implementation details:
|
|
60
|
+
- Uses subprocess.Popen() for streaming stdout line-by-line
|
|
61
|
+
- Passes --permission-mode acceptEdits, --output-format stream-json
|
|
62
|
+
- Optionally passes --dangerously-skip-permissions when dangerous=True
|
|
63
|
+
- Optionally passes --model when model is specified
|
|
64
|
+
- In verbose mode: streams output to terminal (no parsing, no events yielded)
|
|
65
|
+
- In filtered mode: parses stream-json and yields events in real-time
|
|
66
|
+
- In debug mode: emits additional debug information to stderr
|
|
67
|
+
"""
|
|
68
|
+
cmd_args = [
|
|
69
|
+
"claude",
|
|
70
|
+
"--print",
|
|
71
|
+
"--verbose",
|
|
72
|
+
"--permission-mode",
|
|
73
|
+
"acceptEdits",
|
|
74
|
+
"--output-format",
|
|
75
|
+
"stream-json",
|
|
76
|
+
]
|
|
77
|
+
if dangerous:
|
|
78
|
+
cmd_args.append("--dangerously-skip-permissions")
|
|
79
|
+
if model is not None:
|
|
80
|
+
cmd_args.extend(["--model", model])
|
|
81
|
+
cmd_args.append(command)
|
|
82
|
+
|
|
83
|
+
if verbose:
|
|
84
|
+
# Verbose mode - stream to terminal, no parsing, no events
|
|
85
|
+
result = subprocess.run(cmd_args, cwd=worktree_path, check=False)
|
|
86
|
+
|
|
87
|
+
if result.returncode != 0:
|
|
88
|
+
error_msg = f"Claude command {command} failed with exit code {result.returncode}"
|
|
89
|
+
yield ErrorEvent(message=error_msg)
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
# Filtered mode - streaming with real-time parsing
|
|
93
|
+
if debug:
|
|
94
|
+
print(f"[DEBUG executor] Starting Popen with args: {cmd_args}", file=sys.stderr)
|
|
95
|
+
print(f"[DEBUG executor] cwd: {worktree_path}", file=sys.stderr)
|
|
96
|
+
sys.stderr.flush()
|
|
97
|
+
|
|
98
|
+
# Handle Popen errors (e.g., claude not found, permission denied)
|
|
99
|
+
try:
|
|
100
|
+
process = subprocess.Popen(
|
|
101
|
+
cmd_args,
|
|
102
|
+
cwd=worktree_path,
|
|
103
|
+
stdout=subprocess.PIPE,
|
|
104
|
+
stderr=subprocess.PIPE,
|
|
105
|
+
text=True,
|
|
106
|
+
bufsize=1, # Line buffered
|
|
107
|
+
)
|
|
108
|
+
except OSError as e:
|
|
109
|
+
yield ProcessErrorEvent(
|
|
110
|
+
message=f"Failed to start Claude CLI: {e}\nCommand: {' '.join(cmd_args)}"
|
|
111
|
+
)
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
if debug:
|
|
115
|
+
print(f"[DEBUG executor] Popen started, pid={process.pid}", file=sys.stderr)
|
|
116
|
+
sys.stderr.flush()
|
|
117
|
+
|
|
118
|
+
stderr_output: list[str] = []
|
|
119
|
+
|
|
120
|
+
# Capture stderr in background thread
|
|
121
|
+
def capture_stderr() -> None:
|
|
122
|
+
if process.stderr:
|
|
123
|
+
for line in process.stderr:
|
|
124
|
+
stderr_output.append(line)
|
|
125
|
+
|
|
126
|
+
stderr_thread = threading.Thread(target=capture_stderr, daemon=True)
|
|
127
|
+
stderr_thread.start()
|
|
128
|
+
|
|
129
|
+
# Process stdout line by line in real-time
|
|
130
|
+
line_count = 0
|
|
131
|
+
if debug:
|
|
132
|
+
print("[DEBUG executor] Starting to read stdout...", file=sys.stderr)
|
|
133
|
+
sys.stderr.flush()
|
|
134
|
+
if process.stdout:
|
|
135
|
+
for line in process.stdout:
|
|
136
|
+
line_count += 1
|
|
137
|
+
if debug:
|
|
138
|
+
print(
|
|
139
|
+
f"[DEBUG executor] Line #{line_count}: {line[:100]!r}...", file=sys.stderr
|
|
140
|
+
)
|
|
141
|
+
sys.stderr.flush()
|
|
142
|
+
if not line.strip():
|
|
143
|
+
continue
|
|
144
|
+
|
|
145
|
+
# Try to parse as JSON
|
|
146
|
+
parsed = self._parse_stream_json_line(line, worktree_path, command)
|
|
147
|
+
if parsed is None:
|
|
148
|
+
if debug:
|
|
149
|
+
print(
|
|
150
|
+
f"[DEBUG executor] Line #{line_count} parsed to None", file=sys.stderr
|
|
151
|
+
)
|
|
152
|
+
sys.stderr.flush()
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
if debug:
|
|
156
|
+
print(f"[DEBUG executor] Line #{line_count} parsed: {parsed}", file=sys.stderr)
|
|
157
|
+
sys.stderr.flush()
|
|
158
|
+
|
|
159
|
+
# Yield text content and extract metadata from it
|
|
160
|
+
text_content = parsed.get("text_content")
|
|
161
|
+
if text_content is not None and isinstance(text_content, str):
|
|
162
|
+
yield TextEvent(content=text_content)
|
|
163
|
+
|
|
164
|
+
# Also try to extract PR metadata from text (simpler than nested JSON)
|
|
165
|
+
from erk.core.output_filter import extract_pr_metadata_from_text
|
|
166
|
+
|
|
167
|
+
text_metadata = extract_pr_metadata_from_text(text_content)
|
|
168
|
+
text_pr_url = text_metadata.get("pr_url")
|
|
169
|
+
if text_pr_url is not None:
|
|
170
|
+
yield PrUrlEvent(url=str(text_pr_url))
|
|
171
|
+
text_pr_number = text_metadata.get("pr_number")
|
|
172
|
+
if text_pr_number is not None:
|
|
173
|
+
yield PrNumberEvent(number=int(text_pr_number))
|
|
174
|
+
text_pr_title = text_metadata.get("pr_title")
|
|
175
|
+
if text_pr_title is not None:
|
|
176
|
+
yield PrTitleEvent(title=str(text_pr_title))
|
|
177
|
+
text_issue_number = text_metadata.get("issue_number")
|
|
178
|
+
if text_issue_number is not None:
|
|
179
|
+
yield IssueNumberEvent(number=int(text_issue_number))
|
|
180
|
+
|
|
181
|
+
# Yield tool summaries
|
|
182
|
+
tool_summary = parsed.get("tool_summary")
|
|
183
|
+
if tool_summary is not None and isinstance(tool_summary, str):
|
|
184
|
+
yield ToolEvent(summary=tool_summary)
|
|
185
|
+
|
|
186
|
+
# Yield spinner updates
|
|
187
|
+
spinner_text = parsed.get("spinner_update")
|
|
188
|
+
if spinner_text is not None and isinstance(spinner_text, str):
|
|
189
|
+
yield SpinnerUpdateEvent(status=spinner_text)
|
|
190
|
+
|
|
191
|
+
# Yield PR URL
|
|
192
|
+
pr_url_value = parsed.get("pr_url")
|
|
193
|
+
if pr_url_value is not None:
|
|
194
|
+
yield PrUrlEvent(url=str(pr_url_value))
|
|
195
|
+
|
|
196
|
+
# Yield PR number
|
|
197
|
+
pr_number_value = parsed.get("pr_number")
|
|
198
|
+
if pr_number_value is not None:
|
|
199
|
+
yield PrNumberEvent(number=int(pr_number_value))
|
|
200
|
+
|
|
201
|
+
# Yield PR title
|
|
202
|
+
pr_title_value = parsed.get("pr_title")
|
|
203
|
+
if pr_title_value is not None:
|
|
204
|
+
yield PrTitleEvent(title=str(pr_title_value))
|
|
205
|
+
|
|
206
|
+
# Yield issue number
|
|
207
|
+
issue_number_value = parsed.get("issue_number")
|
|
208
|
+
if issue_number_value is not None:
|
|
209
|
+
yield IssueNumberEvent(number=int(issue_number_value))
|
|
210
|
+
|
|
211
|
+
# Detect zero-turn completions (hook blocking)
|
|
212
|
+
num_turns = parsed.get("num_turns")
|
|
213
|
+
if num_turns is not None and num_turns == 0:
|
|
214
|
+
diag = f"Claude command {command} completed without processing"
|
|
215
|
+
diag += "\n This usually means a hook blocked the command"
|
|
216
|
+
diag += "\n Run 'claude' directly to see hook error messages"
|
|
217
|
+
diag += f"\n Working directory: {worktree_path}"
|
|
218
|
+
yield NoTurnsEvent(diagnostic=diag)
|
|
219
|
+
|
|
220
|
+
if debug:
|
|
221
|
+
print(
|
|
222
|
+
f"[DEBUG executor] stdout reading complete, total lines: {line_count}",
|
|
223
|
+
file=sys.stderr,
|
|
224
|
+
)
|
|
225
|
+
sys.stderr.flush()
|
|
226
|
+
|
|
227
|
+
# Wait for process to complete with timeout
|
|
228
|
+
try:
|
|
229
|
+
returncode = process.wait(timeout=PROCESS_TIMEOUT_SECONDS)
|
|
230
|
+
except subprocess.TimeoutExpired:
|
|
231
|
+
process.kill()
|
|
232
|
+
process.wait()
|
|
233
|
+
timeout_minutes = PROCESS_TIMEOUT_SECONDS // 60
|
|
234
|
+
yield ProcessErrorEvent(
|
|
235
|
+
message=f"Claude command {command} timed out after {timeout_minutes} minutes"
|
|
236
|
+
)
|
|
237
|
+
return
|
|
238
|
+
|
|
239
|
+
# Wait for stderr thread to finish with increased timeout
|
|
240
|
+
stderr_thread.join(timeout=STDERR_JOIN_TIMEOUT)
|
|
241
|
+
|
|
242
|
+
# Detect no output condition - yield before checking exit code
|
|
243
|
+
if line_count == 0:
|
|
244
|
+
diag = f"Claude command {command} completed but produced no output"
|
|
245
|
+
diag += f"\n Exit code: {returncode}"
|
|
246
|
+
diag += f"\n Working directory: {worktree_path}"
|
|
247
|
+
if stderr_output:
|
|
248
|
+
diag += "\n Stderr:\n" + "".join(stderr_output)
|
|
249
|
+
yield NoOutputEvent(diagnostic=diag)
|
|
250
|
+
|
|
251
|
+
if returncode != 0:
|
|
252
|
+
yield ErrorEvent(message=f"Exit code {returncode}")
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
# Enhanced error messages for non-zero exit codes
|
|
256
|
+
if returncode != 0:
|
|
257
|
+
error_msg = f"Claude command {command} failed"
|
|
258
|
+
error_msg += f"\n Exit code: {returncode}"
|
|
259
|
+
error_msg += f"\n Lines processed: {line_count}"
|
|
260
|
+
if stderr_output:
|
|
261
|
+
error_msg += "\n Stderr:\n" + "".join(stderr_output).strip()
|
|
262
|
+
yield ErrorEvent(message=error_msg)
|
|
263
|
+
|
|
264
|
+
# Debug summary
|
|
265
|
+
if debug:
|
|
266
|
+
print("[DEBUG executor] === Summary ===", file=sys.stderr)
|
|
267
|
+
print(f"[DEBUG executor] Exit code: {returncode}", file=sys.stderr)
|
|
268
|
+
print(f"[DEBUG executor] Lines: {line_count}", file=sys.stderr)
|
|
269
|
+
if stderr_output:
|
|
270
|
+
print(f"[DEBUG executor] Stderr: {''.join(stderr_output)}", file=sys.stderr)
|
|
271
|
+
sys.stderr.flush()
|
|
272
|
+
|
|
273
|
+
def _parse_stream_json_line(
|
|
274
|
+
self, line: str, worktree_path: Path, command: str
|
|
275
|
+
) -> dict[str, str | int | bool | None] | None:
|
|
276
|
+
"""Parse a single stream-json line and extract relevant information.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
line: JSON line from stream-json output
|
|
280
|
+
worktree_path: Path to worktree for relativizing paths
|
|
281
|
+
command: The slash command being executed
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
Dict with text_content, tool_summary, spinner_update, pr_url, pr_number,
|
|
285
|
+
pr_title, and issue_number keys, or None if not JSON
|
|
286
|
+
"""
|
|
287
|
+
# Import here to avoid circular dependency
|
|
288
|
+
from erk.core.output_filter import (
|
|
289
|
+
determine_spinner_status,
|
|
290
|
+
extract_pr_metadata,
|
|
291
|
+
extract_text_content,
|
|
292
|
+
summarize_tool_use,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
if not line.strip():
|
|
296
|
+
return None
|
|
297
|
+
|
|
298
|
+
# Parse JSON safely - JSON parsing requires exception handling
|
|
299
|
+
data: dict | None = None
|
|
300
|
+
if line.strip():
|
|
301
|
+
try:
|
|
302
|
+
parsed = json.loads(line)
|
|
303
|
+
if isinstance(parsed, dict):
|
|
304
|
+
data = parsed
|
|
305
|
+
except json.JSONDecodeError:
|
|
306
|
+
return None
|
|
307
|
+
|
|
308
|
+
if data is None:
|
|
309
|
+
return None
|
|
310
|
+
|
|
311
|
+
result: dict[str, str | int | bool | None] = {
|
|
312
|
+
"text_content": None,
|
|
313
|
+
"tool_summary": None,
|
|
314
|
+
"spinner_update": None,
|
|
315
|
+
"pr_url": None,
|
|
316
|
+
"pr_number": None,
|
|
317
|
+
"pr_title": None,
|
|
318
|
+
"issue_number": None,
|
|
319
|
+
"num_turns": None,
|
|
320
|
+
"is_error": None,
|
|
321
|
+
"result_text": None,
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
# stream-json format uses "type": "assistant" with nested "message" object
|
|
325
|
+
# (not "type": "assistant_message" with content at top level)
|
|
326
|
+
msg_type = data.get("type")
|
|
327
|
+
message = data.get("message", {})
|
|
328
|
+
if not isinstance(message, dict):
|
|
329
|
+
message = {}
|
|
330
|
+
|
|
331
|
+
# Extract text from assistant messages
|
|
332
|
+
if msg_type == "assistant":
|
|
333
|
+
text = extract_text_content(message)
|
|
334
|
+
if text:
|
|
335
|
+
result["text_content"] = text
|
|
336
|
+
|
|
337
|
+
# Extract tool summaries and spinner updates
|
|
338
|
+
content = message.get("content", [])
|
|
339
|
+
if isinstance(content, list):
|
|
340
|
+
for item in content:
|
|
341
|
+
if isinstance(item, dict) and item.get("type") == "tool_use":
|
|
342
|
+
summary = summarize_tool_use(item, worktree_path)
|
|
343
|
+
if summary:
|
|
344
|
+
result["tool_summary"] = summary
|
|
345
|
+
|
|
346
|
+
# Generate spinner update for all tools (even suppressible ones)
|
|
347
|
+
spinner_text = determine_spinner_status(item, command, worktree_path)
|
|
348
|
+
result["spinner_update"] = spinner_text
|
|
349
|
+
break
|
|
350
|
+
|
|
351
|
+
# Extract PR metadata from tool results
|
|
352
|
+
if msg_type == "user":
|
|
353
|
+
content = message.get("content", [])
|
|
354
|
+
if isinstance(content, list):
|
|
355
|
+
for item in content:
|
|
356
|
+
if isinstance(item, dict) and item.get("type") == "tool_result":
|
|
357
|
+
tool_content = item.get("content")
|
|
358
|
+
# Handle both string and list formats
|
|
359
|
+
# String format: raw JSON string
|
|
360
|
+
# List format: [{"type": "text", "text": "..."}]
|
|
361
|
+
content_str: str | None = None
|
|
362
|
+
if isinstance(tool_content, str):
|
|
363
|
+
content_str = tool_content
|
|
364
|
+
elif isinstance(tool_content, list):
|
|
365
|
+
# Extract text from list of content items
|
|
366
|
+
for content_item in tool_content:
|
|
367
|
+
is_text_item = (
|
|
368
|
+
isinstance(content_item, dict)
|
|
369
|
+
and content_item.get("type") == "text"
|
|
370
|
+
)
|
|
371
|
+
if is_text_item:
|
|
372
|
+
text = content_item.get("text")
|
|
373
|
+
if isinstance(text, str):
|
|
374
|
+
content_str = text
|
|
375
|
+
break
|
|
376
|
+
if content_str is not None:
|
|
377
|
+
pr_metadata = extract_pr_metadata(content_str)
|
|
378
|
+
if pr_metadata.get("pr_url"):
|
|
379
|
+
result["pr_url"] = pr_metadata["pr_url"]
|
|
380
|
+
result["pr_number"] = pr_metadata["pr_number"]
|
|
381
|
+
result["pr_title"] = pr_metadata["pr_title"]
|
|
382
|
+
result["issue_number"] = pr_metadata.get("issue_number")
|
|
383
|
+
break
|
|
384
|
+
|
|
385
|
+
# Parse type: result messages for num_turns (hook blocking detection)
|
|
386
|
+
if msg_type == "result":
|
|
387
|
+
num_turns = data.get("num_turns")
|
|
388
|
+
if num_turns is not None:
|
|
389
|
+
result["num_turns"] = num_turns
|
|
390
|
+
result["is_error"] = data.get("is_error", False)
|
|
391
|
+
result_text = data.get("result")
|
|
392
|
+
if result_text is not None:
|
|
393
|
+
result["result_text"] = result_text
|
|
394
|
+
|
|
395
|
+
return result
|
|
396
|
+
|
|
397
|
+
def execute_interactive(
|
|
398
|
+
self,
|
|
399
|
+
worktree_path: Path,
|
|
400
|
+
dangerous: bool,
|
|
401
|
+
command: str,
|
|
402
|
+
target_subpath: Path | None,
|
|
403
|
+
model: str | None = None,
|
|
404
|
+
) -> None:
|
|
405
|
+
"""Execute Claude CLI in interactive mode by replacing current process.
|
|
406
|
+
|
|
407
|
+
Implementation details:
|
|
408
|
+
- Verifies Claude CLI is available
|
|
409
|
+
- Changes to worktree directory (and to subpath if provided)
|
|
410
|
+
- Builds command arguments with the specified command
|
|
411
|
+
- Replaces current process using os.execvp
|
|
412
|
+
|
|
413
|
+
Note:
|
|
414
|
+
This function never returns - the process is replaced by Claude CLI.
|
|
415
|
+
|
|
416
|
+
The target_subpath is trusted to exist because it was computed from
|
|
417
|
+
the source worktree's directory structure. Since the new worktree
|
|
418
|
+
shares git history with the source, the path should exist.
|
|
419
|
+
"""
|
|
420
|
+
# Verify Claude is available
|
|
421
|
+
if not self.is_claude_available():
|
|
422
|
+
raise RuntimeError("Claude CLI not found\nInstall from: https://claude.com/download")
|
|
423
|
+
|
|
424
|
+
# Change to worktree directory (optionally to subpath)
|
|
425
|
+
# Trust the computed subpath exists - it was derived from the source worktree
|
|
426
|
+
# which has the same git history. If it doesn't exist, os.chdir will raise
|
|
427
|
+
# FileNotFoundError which is the appropriate error.
|
|
428
|
+
if target_subpath is not None:
|
|
429
|
+
target_dir = worktree_path / target_subpath
|
|
430
|
+
os.chdir(target_dir)
|
|
431
|
+
else:
|
|
432
|
+
os.chdir(worktree_path)
|
|
433
|
+
|
|
434
|
+
# Build command arguments
|
|
435
|
+
cmd_args = ["claude", "--permission-mode", "acceptEdits"]
|
|
436
|
+
if dangerous:
|
|
437
|
+
cmd_args.append("--dangerously-skip-permissions")
|
|
438
|
+
if model is not None:
|
|
439
|
+
cmd_args.extend(["--model", model])
|
|
440
|
+
# Only append command if non-empty (allows launching Claude for planning)
|
|
441
|
+
if command:
|
|
442
|
+
cmd_args.append(command)
|
|
443
|
+
|
|
444
|
+
# Redirect stdin/stdout/stderr to /dev/tty only if they are not already TTYs.
|
|
445
|
+
# This ensures Claude gets terminal access when running as subprocess with
|
|
446
|
+
# captured stdout (e.g., shell integration), while avoiding unnecessary
|
|
447
|
+
# redirection when already running in a terminal (which can break tools
|
|
448
|
+
# like Bun that expect specific TTY capabilities).
|
|
449
|
+
if not (os.isatty(1) and os.isatty(2)):
|
|
450
|
+
try:
|
|
451
|
+
tty_fd = os.open("/dev/tty", os.O_RDWR)
|
|
452
|
+
os.dup2(tty_fd, 0) # stdin
|
|
453
|
+
os.dup2(tty_fd, 1) # stdout
|
|
454
|
+
os.dup2(tty_fd, 2) # stderr
|
|
455
|
+
os.close(tty_fd)
|
|
456
|
+
except OSError:
|
|
457
|
+
logger.debug(
|
|
458
|
+
"Unable to redirect stdin/stdout/stderr to /dev/tty; "
|
|
459
|
+
"falling back to inherited descriptors"
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
# Replace current process with Claude
|
|
463
|
+
os.execvp("claude", cmd_args)
|
|
464
|
+
# Never returns - process is replaced
|
|
465
|
+
|
|
466
|
+
def execute_prompt(
|
|
467
|
+
self,
|
|
468
|
+
prompt: str,
|
|
469
|
+
*,
|
|
470
|
+
model: str = "haiku",
|
|
471
|
+
tools: list[str] | None = None,
|
|
472
|
+
cwd: Path | None = None,
|
|
473
|
+
) -> PromptResult:
|
|
474
|
+
"""Execute a single prompt and return the result.
|
|
475
|
+
|
|
476
|
+
Implementation details:
|
|
477
|
+
- Uses subprocess.run with --print and --output-format text
|
|
478
|
+
- Returns PromptResult with success status and output
|
|
479
|
+
"""
|
|
480
|
+
cmd = [
|
|
481
|
+
"claude",
|
|
482
|
+
"--print",
|
|
483
|
+
"--output-format",
|
|
484
|
+
"text",
|
|
485
|
+
"--model",
|
|
486
|
+
model,
|
|
487
|
+
]
|
|
488
|
+
if tools is not None:
|
|
489
|
+
cmd.extend(["--allowedTools", ",".join(tools)])
|
|
490
|
+
cmd.append(prompt)
|
|
491
|
+
|
|
492
|
+
result = subprocess.run(
|
|
493
|
+
cmd,
|
|
494
|
+
capture_output=True,
|
|
495
|
+
text=True,
|
|
496
|
+
cwd=cwd,
|
|
497
|
+
check=False,
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
if result.returncode != 0:
|
|
501
|
+
return PromptResult(
|
|
502
|
+
success=False,
|
|
503
|
+
output="",
|
|
504
|
+
error=result.stderr.strip() if result.stderr else f"Exit code {result.returncode}",
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
return PromptResult(
|
|
508
|
+
success=True,
|
|
509
|
+
output=result.stdout.strip(),
|
|
510
|
+
error=None,
|
|
511
|
+
)
|