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
|
+
"""Output filtering for Claude CLI stream-json format.
|
|
2
|
+
|
|
3
|
+
This module provides functions to parse and filter Claude CLI output in stream-json
|
|
4
|
+
format, extracting relevant text content and tool summaries while suppressing
|
|
5
|
+
verbose/noisy tool invocations.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def extract_text_content(message: dict) -> str | None:
|
|
13
|
+
"""Extract Claude's text response from assistant message.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
message: Assistant message dict from stream-json
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
Extracted text content, or None if no text found
|
|
20
|
+
|
|
21
|
+
Example:
|
|
22
|
+
>>> msg = {"type": "assistant_message", "content": [{"type": "text", "text": "Hello"}]}
|
|
23
|
+
>>> extract_text_content(msg)
|
|
24
|
+
'Hello'
|
|
25
|
+
"""
|
|
26
|
+
content = message.get("content", [])
|
|
27
|
+
if not isinstance(content, list):
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
text_parts: list[str] = []
|
|
31
|
+
for item in content:
|
|
32
|
+
if isinstance(item, dict) and item.get("type") == "text":
|
|
33
|
+
text = item.get("text")
|
|
34
|
+
if isinstance(text, str) and text.strip():
|
|
35
|
+
text_parts.append(text.strip())
|
|
36
|
+
|
|
37
|
+
if not text_parts:
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
return "\n".join(text_parts)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def summarize_tool_use(tool_use: dict, worktree_path: Path) -> str | None:
|
|
44
|
+
"""Create brief summary for important tools, None for suppressible tools.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
tool_use: Tool use dict from stream-json content
|
|
48
|
+
worktree_path: Path to worktree for relativizing file paths
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Brief summary string for important tools, None for suppressible tools
|
|
52
|
+
|
|
53
|
+
Example:
|
|
54
|
+
>>> tool = {"name": "Edit", "input": {"file_path": "/repo/src/file.py"}}
|
|
55
|
+
>>> summarize_tool_use(tool, Path("/repo"))
|
|
56
|
+
'Editing src/file.py...'
|
|
57
|
+
"""
|
|
58
|
+
tool_name = tool_use.get("name")
|
|
59
|
+
if not isinstance(tool_name, str):
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
params = tool_use.get("input", {})
|
|
63
|
+
if not isinstance(params, dict):
|
|
64
|
+
params = {}
|
|
65
|
+
|
|
66
|
+
# Suppress common/noisy tools
|
|
67
|
+
if tool_name in ["Read", "Glob", "Grep"]:
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
# Bash commands
|
|
71
|
+
if tool_name == "Bash":
|
|
72
|
+
command = params.get("command", "")
|
|
73
|
+
if not isinstance(command, str):
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
# Check for pytest
|
|
77
|
+
if "pytest" in command:
|
|
78
|
+
return "Running tests..."
|
|
79
|
+
|
|
80
|
+
# Check for CI commands
|
|
81
|
+
if "fast-ci" in command or "all-ci" in command:
|
|
82
|
+
return "Running CI checks..."
|
|
83
|
+
|
|
84
|
+
# Generic bash command
|
|
85
|
+
return f"Running: {command[:50]}..."
|
|
86
|
+
|
|
87
|
+
# Slash commands
|
|
88
|
+
if tool_name == "SlashCommand":
|
|
89
|
+
cmd = params.get("command", "")
|
|
90
|
+
if not isinstance(cmd, str):
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
if "/gt:pr-submit" in cmd or "/erk:git-pr-push" in cmd:
|
|
94
|
+
return "Creating pull request..."
|
|
95
|
+
|
|
96
|
+
if "/fast-ci" in cmd or "/all-ci" in cmd:
|
|
97
|
+
return "Running CI checks..."
|
|
98
|
+
|
|
99
|
+
return f"Running {cmd}..."
|
|
100
|
+
|
|
101
|
+
# File operations
|
|
102
|
+
if tool_name == "Edit":
|
|
103
|
+
filepath = params.get("file_path", "")
|
|
104
|
+
if isinstance(filepath, str):
|
|
105
|
+
relative = make_relative_to_worktree(filepath, worktree_path)
|
|
106
|
+
return f"Editing {relative}..."
|
|
107
|
+
|
|
108
|
+
if tool_name == "Write":
|
|
109
|
+
filepath = params.get("file_path", "")
|
|
110
|
+
if isinstance(filepath, str):
|
|
111
|
+
relative = make_relative_to_worktree(filepath, worktree_path)
|
|
112
|
+
return f"Writing {relative}..."
|
|
113
|
+
|
|
114
|
+
# Default: show tool name for unknown tools
|
|
115
|
+
return f"Using {tool_name}..."
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def make_relative_to_worktree(filepath: str, worktree_path: Path) -> str:
|
|
119
|
+
"""Convert absolute path to worktree-relative path.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
filepath: Absolute or relative file path
|
|
123
|
+
worktree_path: Path to worktree root
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
Path relative to worktree if possible, otherwise original filepath
|
|
127
|
+
|
|
128
|
+
Example:
|
|
129
|
+
>>> make_relative_to_worktree("/repo/src/file.py", Path("/repo"))
|
|
130
|
+
'src/file.py'
|
|
131
|
+
"""
|
|
132
|
+
path = Path(filepath)
|
|
133
|
+
|
|
134
|
+
# Check if path is absolute and relative to worktree
|
|
135
|
+
if path.is_absolute():
|
|
136
|
+
if path.exists() and path.is_relative_to(worktree_path):
|
|
137
|
+
return str(path.relative_to(worktree_path))
|
|
138
|
+
|
|
139
|
+
return filepath
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def extract_pr_url(tool_result_content: str) -> str | None:
|
|
143
|
+
"""Extract PR URL from exec command JSON output.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
tool_result_content: Content string from tool_result
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
PR URL if found in JSON, None otherwise
|
|
150
|
+
|
|
151
|
+
Example:
|
|
152
|
+
>>> content = '{"success": true, "pr_url": "https://github.com/user/repo/pull/123"}'
|
|
153
|
+
>>> extract_pr_url(content)
|
|
154
|
+
'https://github.com/user/repo/pull/123'
|
|
155
|
+
"""
|
|
156
|
+
if not isinstance(tool_result_content, str):
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
# Parse JSON safely - JSON parsing requires exception handling
|
|
160
|
+
data: dict | None = None
|
|
161
|
+
if tool_result_content.strip():
|
|
162
|
+
try:
|
|
163
|
+
parsed = json.loads(tool_result_content)
|
|
164
|
+
if isinstance(parsed, dict):
|
|
165
|
+
data = parsed
|
|
166
|
+
except json.JSONDecodeError:
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
if data is None:
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
pr_url = data.get("pr_url")
|
|
173
|
+
if isinstance(pr_url, str):
|
|
174
|
+
return pr_url
|
|
175
|
+
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def extract_pr_metadata(tool_result_content: str) -> dict[str, str | int | None]:
|
|
180
|
+
"""Extract PR metadata from exec command JSON output.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
tool_result_content: Content string from tool_result
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
Dict with pr_url, pr_number, pr_title, and issue_number (all may be None)
|
|
187
|
+
|
|
188
|
+
Example:
|
|
189
|
+
>>> content = '{"success": true, "pr_url": "https://...", "pr_number": 123, '
|
|
190
|
+
>>> content += '"pr_title": "Fix bug", "issue_number": 456}'
|
|
191
|
+
>>> extract_pr_metadata(content)
|
|
192
|
+
{'pr_url': 'https://...', 'pr_number': 123, 'pr_title': 'Fix bug', 'issue_number': 456}
|
|
193
|
+
"""
|
|
194
|
+
if not isinstance(tool_result_content, str):
|
|
195
|
+
return {"pr_url": None, "pr_number": None, "pr_title": None, "issue_number": None}
|
|
196
|
+
|
|
197
|
+
# Parse JSON safely - JSON parsing requires exception handling
|
|
198
|
+
data: dict | None = None
|
|
199
|
+
if tool_result_content.strip():
|
|
200
|
+
try:
|
|
201
|
+
parsed = json.loads(tool_result_content)
|
|
202
|
+
if isinstance(parsed, dict):
|
|
203
|
+
data = parsed
|
|
204
|
+
except json.JSONDecodeError:
|
|
205
|
+
return {"pr_url": None, "pr_number": None, "pr_title": None, "issue_number": None}
|
|
206
|
+
|
|
207
|
+
if data is None:
|
|
208
|
+
return {"pr_url": None, "pr_number": None, "pr_title": None, "issue_number": None}
|
|
209
|
+
|
|
210
|
+
pr_url = data.get("pr_url")
|
|
211
|
+
pr_number = data.get("pr_number")
|
|
212
|
+
pr_title = data.get("pr_title")
|
|
213
|
+
issue_number = data.get("issue_number")
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
"pr_url": pr_url if isinstance(pr_url, str) else None,
|
|
217
|
+
"pr_number": pr_number if isinstance(pr_number, int) else None,
|
|
218
|
+
"pr_title": pr_title if isinstance(pr_title, str) else None,
|
|
219
|
+
"issue_number": issue_number if isinstance(issue_number, int) else None,
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def extract_pr_metadata_from_text(text: str) -> dict[str, str | int | None]:
|
|
224
|
+
"""Extract PR metadata from agent text output using pattern matching.
|
|
225
|
+
|
|
226
|
+
This is simpler and more robust than parsing nested JSON from tool results.
|
|
227
|
+
The agent's text output contains PR info in human-readable format like:
|
|
228
|
+
- "PR #1311" or "**PR #1311**"
|
|
229
|
+
- "https://github.com/.../pull/1311" or "https://app.graphite.com/.../1311"
|
|
230
|
+
- "issue #1308" or "Linked to issue #1308"
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
text: Agent text output containing PR information
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
Dict with pr_url, pr_number, and issue_number (pr_title always None)
|
|
237
|
+
|
|
238
|
+
Example:
|
|
239
|
+
>>> text = "**PR #123** created\\n- **Link**: https://github.com/o/r/pull/123"
|
|
240
|
+
>>> extract_pr_metadata_from_text(text)
|
|
241
|
+
{'pr_url': 'https://github.com/o/r/pull/123', 'pr_number': 123, ...}
|
|
242
|
+
"""
|
|
243
|
+
import re
|
|
244
|
+
|
|
245
|
+
result: dict[str, str | int | None] = {
|
|
246
|
+
"pr_url": None,
|
|
247
|
+
"pr_number": None,
|
|
248
|
+
"pr_title": None,
|
|
249
|
+
"issue_number": None,
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if not isinstance(text, str):
|
|
253
|
+
return result
|
|
254
|
+
|
|
255
|
+
# Extract PR number and title from various patterns:
|
|
256
|
+
# - "PR #123: Title" or "PR #123 - Title"
|
|
257
|
+
# - "#123 - Title" or '#123 - "Title"'
|
|
258
|
+
# - "**PR Updated**: #123 - Title"
|
|
259
|
+
pr_with_title_match = re.search(
|
|
260
|
+
r"#(\d+)\s*[-:]\s*[\"']?(.+?)[\"']?(?:\n|$)", text, re.IGNORECASE
|
|
261
|
+
)
|
|
262
|
+
if pr_with_title_match:
|
|
263
|
+
result["pr_number"] = int(pr_with_title_match.group(1))
|
|
264
|
+
result["pr_title"] = pr_with_title_match.group(2).strip().strip("\"'")
|
|
265
|
+
else:
|
|
266
|
+
# Fallback: just extract PR number without title
|
|
267
|
+
pr_num_match = re.search(r"#(\d+)", text)
|
|
268
|
+
if pr_num_match:
|
|
269
|
+
result["pr_number"] = int(pr_num_match.group(1))
|
|
270
|
+
|
|
271
|
+
# Extract GitHub PR URL
|
|
272
|
+
github_url_match = re.search(r"https://github\.com/[^/]+/[^/]+/pull/(\d+)", text)
|
|
273
|
+
if github_url_match:
|
|
274
|
+
result["pr_url"] = github_url_match.group(0)
|
|
275
|
+
# Also extract pr_number from URL if not found earlier
|
|
276
|
+
if result["pr_number"] is None:
|
|
277
|
+
result["pr_number"] = int(github_url_match.group(1))
|
|
278
|
+
|
|
279
|
+
# Extract Graphite URL as fallback
|
|
280
|
+
if result["pr_url"] is None:
|
|
281
|
+
graphite_url_match = re.search(
|
|
282
|
+
r"https://app\.graphite\.com/github/pr/[^/]+/[^/]+/(\d+)", text
|
|
283
|
+
)
|
|
284
|
+
if graphite_url_match:
|
|
285
|
+
result["pr_url"] = graphite_url_match.group(0)
|
|
286
|
+
if result["pr_number"] is None:
|
|
287
|
+
result["pr_number"] = int(graphite_url_match.group(1))
|
|
288
|
+
|
|
289
|
+
# Extract issue number from patterns like "issue #123" or "Linked to issue #123"
|
|
290
|
+
# or "#1308 (will auto-close"
|
|
291
|
+
issue_match = re.search(r"issue\s*#(\d+)", text, re.IGNORECASE)
|
|
292
|
+
if issue_match:
|
|
293
|
+
result["issue_number"] = int(issue_match.group(1))
|
|
294
|
+
else:
|
|
295
|
+
# Try "Closes #123" pattern
|
|
296
|
+
closes_match = re.search(r"Closes\s*#(\d+)", text, re.IGNORECASE)
|
|
297
|
+
if closes_match:
|
|
298
|
+
result["issue_number"] = int(closes_match.group(1))
|
|
299
|
+
|
|
300
|
+
return result
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def determine_spinner_status(tool_use: dict | None, command: str, worktree_path: Path) -> str:
|
|
304
|
+
"""Map current activity to spinner status message.
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
tool_use: Current tool use dict, or None if no tool running
|
|
308
|
+
command: The slash command being executed
|
|
309
|
+
worktree_path: Path to worktree for relativizing paths
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
Status message for spinner
|
|
313
|
+
|
|
314
|
+
Example:
|
|
315
|
+
>>> determine_spinner_status(None, "/erk:plan-implement", Path("/repo"))
|
|
316
|
+
'Running /erk:plan-implement...'
|
|
317
|
+
"""
|
|
318
|
+
if tool_use is None:
|
|
319
|
+
return f"Running {command}..."
|
|
320
|
+
|
|
321
|
+
# First try to get a detailed summary
|
|
322
|
+
summary = summarize_tool_use(tool_use, worktree_path)
|
|
323
|
+
if summary:
|
|
324
|
+
return summary
|
|
325
|
+
|
|
326
|
+
# For suppressed tools (Read, Glob, Grep), provide a generic but distinct message
|
|
327
|
+
tool_name = tool_use.get("name")
|
|
328
|
+
if isinstance(tool_name, str):
|
|
329
|
+
if tool_name == "Read":
|
|
330
|
+
return "Reading files..."
|
|
331
|
+
if tool_name == "Glob":
|
|
332
|
+
return "Searching for files..."
|
|
333
|
+
if tool_name == "Grep":
|
|
334
|
+
return "Searching code..."
|
|
335
|
+
# Fallback for unknown tools
|
|
336
|
+
return f"Using {tool_name}..."
|
|
337
|
+
|
|
338
|
+
return f"Running {command}..."
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Planner box management for remote planning with Claude Code."""
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Planner registry ABC re-export.
|
|
2
|
+
|
|
3
|
+
ABC is defined in erk_shared.core. This module re-exports for backward compatibility.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
# Re-export from erk_shared.core
|
|
7
|
+
from erk_shared.core.planner_registry import PlannerRegistry as PlannerRegistry
|
|
8
|
+
from erk_shared.core.planner_registry import RegisteredPlanner as RegisteredPlanner
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Fake implementation of PlannerRegistry for testing.
|
|
2
|
+
|
|
3
|
+
Stores planner data in memory without touching filesystem.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
|
|
9
|
+
from erk.core.planner.registry_abc import PlannerRegistry
|
|
10
|
+
from erk.core.planner.types import RegisteredPlanner
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FakePlannerRegistry(PlannerRegistry):
|
|
14
|
+
"""In-memory implementation for testing.
|
|
15
|
+
|
|
16
|
+
Provides mutation tracking via read-only properties for assertions.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
planners: list[RegisteredPlanner] | None = None,
|
|
22
|
+
default_planner: str | None = None,
|
|
23
|
+
) -> None:
|
|
24
|
+
"""Initialize the fake registry.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
planners: Initial list of planners
|
|
28
|
+
default_planner: Name of the default planner (must exist in planners)
|
|
29
|
+
"""
|
|
30
|
+
self._planners: dict[str, RegisteredPlanner] = {}
|
|
31
|
+
self._default_planner: str | None = default_planner
|
|
32
|
+
|
|
33
|
+
# Track mutations for assertions
|
|
34
|
+
self._registered: list[RegisteredPlanner] = []
|
|
35
|
+
self._unregistered: list[str] = []
|
|
36
|
+
self._marked_configured: list[str] = []
|
|
37
|
+
self._updated_last_connected: list[tuple[str, datetime]] = []
|
|
38
|
+
self._set_defaults: list[str] = []
|
|
39
|
+
|
|
40
|
+
if planners:
|
|
41
|
+
for planner in planners:
|
|
42
|
+
self._planners[planner.name] = planner
|
|
43
|
+
|
|
44
|
+
def list_planners(self) -> list[RegisteredPlanner]:
|
|
45
|
+
"""List all registered planners."""
|
|
46
|
+
return list(self._planners.values())
|
|
47
|
+
|
|
48
|
+
def get(self, name: str) -> RegisteredPlanner | None:
|
|
49
|
+
"""Get a planner by name."""
|
|
50
|
+
return self._planners.get(name)
|
|
51
|
+
|
|
52
|
+
def get_default(self) -> RegisteredPlanner | None:
|
|
53
|
+
"""Get the default planner."""
|
|
54
|
+
if self._default_planner is None:
|
|
55
|
+
return None
|
|
56
|
+
return self._planners.get(self._default_planner)
|
|
57
|
+
|
|
58
|
+
def get_default_name(self) -> str | None:
|
|
59
|
+
"""Get the name of the default planner."""
|
|
60
|
+
return self._default_planner
|
|
61
|
+
|
|
62
|
+
def set_default(self, name: str) -> None:
|
|
63
|
+
"""Set the default planner."""
|
|
64
|
+
if name not in self._planners:
|
|
65
|
+
raise ValueError(f"No planner named '{name}' exists")
|
|
66
|
+
self._default_planner = name
|
|
67
|
+
self._set_defaults.append(name)
|
|
68
|
+
|
|
69
|
+
def register(self, planner: RegisteredPlanner) -> None:
|
|
70
|
+
"""Register a new planner."""
|
|
71
|
+
if planner.name in self._planners:
|
|
72
|
+
raise ValueError(f"Planner '{planner.name}' already exists")
|
|
73
|
+
self._planners[planner.name] = planner
|
|
74
|
+
self._registered.append(planner)
|
|
75
|
+
|
|
76
|
+
def unregister(self, name: str) -> None:
|
|
77
|
+
"""Remove a planner from the registry."""
|
|
78
|
+
if name not in self._planners:
|
|
79
|
+
raise ValueError(f"No planner named '{name}' exists")
|
|
80
|
+
del self._planners[name]
|
|
81
|
+
|
|
82
|
+
# Clear default if we're removing the default planner
|
|
83
|
+
if self._default_planner == name:
|
|
84
|
+
self._default_planner = None
|
|
85
|
+
|
|
86
|
+
self._unregistered.append(name)
|
|
87
|
+
|
|
88
|
+
def mark_configured(self, name: str) -> None:
|
|
89
|
+
"""Mark a planner as configured."""
|
|
90
|
+
if name not in self._planners:
|
|
91
|
+
raise ValueError(f"No planner named '{name}' exists")
|
|
92
|
+
old_planner = self._planners[name]
|
|
93
|
+
self._planners[name] = replace(old_planner, configured=True)
|
|
94
|
+
self._marked_configured.append(name)
|
|
95
|
+
|
|
96
|
+
def update_last_connected(self, name: str, timestamp: datetime) -> None:
|
|
97
|
+
"""Update the last connected timestamp for a planner."""
|
|
98
|
+
if name not in self._planners:
|
|
99
|
+
raise ValueError(f"No planner named '{name}' exists")
|
|
100
|
+
old_planner = self._planners[name]
|
|
101
|
+
self._planners[name] = replace(old_planner, last_connected_at=timestamp)
|
|
102
|
+
self._updated_last_connected.append((name, timestamp))
|
|
103
|
+
|
|
104
|
+
# Read-only mutation tracking properties for test assertions
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def registered_planners(self) -> list[RegisteredPlanner]:
|
|
108
|
+
"""Planners registered during test (for assertions)."""
|
|
109
|
+
return list(self._registered)
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def unregistered_names(self) -> list[str]:
|
|
113
|
+
"""Names of planners unregistered during test (for assertions)."""
|
|
114
|
+
return list(self._unregistered)
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def marked_configured_names(self) -> list[str]:
|
|
118
|
+
"""Names of planners marked as configured during test (for assertions)."""
|
|
119
|
+
return list(self._marked_configured)
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def updated_connections(self) -> list[tuple[str, datetime]]:
|
|
123
|
+
"""(name, timestamp) pairs for last_connected updates (for assertions)."""
|
|
124
|
+
return list(self._updated_last_connected)
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def set_default_history(self) -> list[str]:
|
|
128
|
+
"""History of set_default calls (for assertions)."""
|
|
129
|
+
return list(self._set_defaults)
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Real implementation of PlannerRegistry using TOML file storage.
|
|
2
|
+
|
|
3
|
+
Stores planner configuration in ~/.erk/planners.toml.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import tomllib
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import tomlkit
|
|
11
|
+
|
|
12
|
+
from erk.core.planner.registry_abc import PlannerRegistry
|
|
13
|
+
from erk.core.planner.types import RegisteredPlanner
|
|
14
|
+
|
|
15
|
+
SCHEMA_VERSION = 1
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RealPlannerRegistry(PlannerRegistry):
|
|
19
|
+
"""Production implementation that reads/writes ~/.erk/planners.toml."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, config_path: Path) -> None:
|
|
22
|
+
"""Initialize the registry.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
config_path: Path to the planners.toml config file.
|
|
26
|
+
Typically obtained from erk_installation.get_planners_config_path().
|
|
27
|
+
"""
|
|
28
|
+
self._config_path = config_path
|
|
29
|
+
|
|
30
|
+
def _load_data(self) -> dict:
|
|
31
|
+
"""Load data from TOML file.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Parsed TOML data, or empty structure if file doesn't exist
|
|
35
|
+
"""
|
|
36
|
+
if not self._config_path.exists():
|
|
37
|
+
return {"schema_version": SCHEMA_VERSION, "planners": {}}
|
|
38
|
+
|
|
39
|
+
content = self._config_path.read_text(encoding="utf-8")
|
|
40
|
+
return tomllib.loads(content)
|
|
41
|
+
|
|
42
|
+
def _save_data(self, data: dict) -> None:
|
|
43
|
+
"""Save data to TOML file.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
data: Data structure to save
|
|
47
|
+
"""
|
|
48
|
+
# Ensure parent directory exists
|
|
49
|
+
self._config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
|
|
51
|
+
# Use tomlkit to preserve formatting
|
|
52
|
+
doc = tomlkit.document()
|
|
53
|
+
doc["schema_version"] = data.get("schema_version", SCHEMA_VERSION)
|
|
54
|
+
|
|
55
|
+
if "default_planner" in data and data["default_planner"] is not None:
|
|
56
|
+
doc["default_planner"] = data["default_planner"]
|
|
57
|
+
|
|
58
|
+
# Add planners table
|
|
59
|
+
planners_table = tomlkit.table()
|
|
60
|
+
for name, planner_data in data.get("planners", {}).items():
|
|
61
|
+
planner_table = tomlkit.table()
|
|
62
|
+
planner_table["gh_name"] = planner_data["gh_name"]
|
|
63
|
+
planner_table["repository"] = planner_data["repository"]
|
|
64
|
+
planner_table["configured"] = planner_data["configured"]
|
|
65
|
+
planner_table["registered_at"] = planner_data["registered_at"]
|
|
66
|
+
if planner_data.get("last_connected_at") is not None:
|
|
67
|
+
planner_table["last_connected_at"] = planner_data["last_connected_at"]
|
|
68
|
+
planners_table[name] = planner_table
|
|
69
|
+
|
|
70
|
+
doc["planners"] = planners_table
|
|
71
|
+
|
|
72
|
+
self._config_path.write_text(tomlkit.dumps(doc), encoding="utf-8")
|
|
73
|
+
|
|
74
|
+
def _planner_from_dict(self, name: str, data: dict) -> RegisteredPlanner:
|
|
75
|
+
"""Convert a dict to a RegisteredPlanner.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
name: Planner name
|
|
79
|
+
data: Dict with planner data
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
RegisteredPlanner instance
|
|
83
|
+
"""
|
|
84
|
+
return RegisteredPlanner(
|
|
85
|
+
name=name,
|
|
86
|
+
gh_name=data["gh_name"],
|
|
87
|
+
repository=data["repository"],
|
|
88
|
+
configured=data["configured"],
|
|
89
|
+
registered_at=datetime.fromisoformat(data["registered_at"]),
|
|
90
|
+
last_connected_at=(
|
|
91
|
+
datetime.fromisoformat(data["last_connected_at"])
|
|
92
|
+
if data.get("last_connected_at")
|
|
93
|
+
else None
|
|
94
|
+
),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def _planner_to_dict(self, planner: RegisteredPlanner) -> dict:
|
|
98
|
+
"""Convert a RegisteredPlanner to a dict.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
planner: RegisteredPlanner instance
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Dict representation
|
|
105
|
+
"""
|
|
106
|
+
result = {
|
|
107
|
+
"gh_name": planner.gh_name,
|
|
108
|
+
"repository": planner.repository,
|
|
109
|
+
"configured": planner.configured,
|
|
110
|
+
"registered_at": planner.registered_at.isoformat(),
|
|
111
|
+
}
|
|
112
|
+
if planner.last_connected_at is not None:
|
|
113
|
+
result["last_connected_at"] = planner.last_connected_at.isoformat()
|
|
114
|
+
return result
|
|
115
|
+
|
|
116
|
+
def list_planners(self) -> list[RegisteredPlanner]:
|
|
117
|
+
"""List all registered planners."""
|
|
118
|
+
data = self._load_data()
|
|
119
|
+
planners = data.get("planners", {})
|
|
120
|
+
return [self._planner_from_dict(name, pdata) for name, pdata in planners.items()]
|
|
121
|
+
|
|
122
|
+
def get(self, name: str) -> RegisteredPlanner | None:
|
|
123
|
+
"""Get a planner by name."""
|
|
124
|
+
data = self._load_data()
|
|
125
|
+
planners = data.get("planners", {})
|
|
126
|
+
if name not in planners:
|
|
127
|
+
return None
|
|
128
|
+
return self._planner_from_dict(name, planners[name])
|
|
129
|
+
|
|
130
|
+
def get_default(self) -> RegisteredPlanner | None:
|
|
131
|
+
"""Get the default planner."""
|
|
132
|
+
data = self._load_data()
|
|
133
|
+
default_name = data.get("default_planner")
|
|
134
|
+
if default_name is None:
|
|
135
|
+
return None
|
|
136
|
+
return self.get(default_name)
|
|
137
|
+
|
|
138
|
+
def get_default_name(self) -> str | None:
|
|
139
|
+
"""Get the name of the default planner."""
|
|
140
|
+
data = self._load_data()
|
|
141
|
+
return data.get("default_planner")
|
|
142
|
+
|
|
143
|
+
def set_default(self, name: str) -> None:
|
|
144
|
+
"""Set the default planner."""
|
|
145
|
+
data = self._load_data()
|
|
146
|
+
planners = data.get("planners", {})
|
|
147
|
+
if name not in planners:
|
|
148
|
+
raise ValueError(f"No planner named '{name}' exists")
|
|
149
|
+
data["default_planner"] = name
|
|
150
|
+
self._save_data(data)
|
|
151
|
+
|
|
152
|
+
def register(self, planner: RegisteredPlanner) -> None:
|
|
153
|
+
"""Register a new planner."""
|
|
154
|
+
data = self._load_data()
|
|
155
|
+
planners = data.get("planners", {})
|
|
156
|
+
if planner.name in planners:
|
|
157
|
+
raise ValueError(f"Planner '{planner.name}' already exists")
|
|
158
|
+
planners[planner.name] = self._planner_to_dict(planner)
|
|
159
|
+
data["planners"] = planners
|
|
160
|
+
self._save_data(data)
|
|
161
|
+
|
|
162
|
+
def unregister(self, name: str) -> None:
|
|
163
|
+
"""Remove a planner from the registry."""
|
|
164
|
+
data = self._load_data()
|
|
165
|
+
planners = data.get("planners", {})
|
|
166
|
+
if name not in planners:
|
|
167
|
+
raise ValueError(f"No planner named '{name}' exists")
|
|
168
|
+
del planners[name]
|
|
169
|
+
data["planners"] = planners
|
|
170
|
+
|
|
171
|
+
# Clear default if we're removing the default planner
|
|
172
|
+
if data.get("default_planner") == name:
|
|
173
|
+
data["default_planner"] = None
|
|
174
|
+
|
|
175
|
+
self._save_data(data)
|
|
176
|
+
|
|
177
|
+
def mark_configured(self, name: str) -> None:
|
|
178
|
+
"""Mark a planner as configured."""
|
|
179
|
+
data = self._load_data()
|
|
180
|
+
planners = data.get("planners", {})
|
|
181
|
+
if name not in planners:
|
|
182
|
+
raise ValueError(f"No planner named '{name}' exists")
|
|
183
|
+
planners[name]["configured"] = True
|
|
184
|
+
data["planners"] = planners
|
|
185
|
+
self._save_data(data)
|
|
186
|
+
|
|
187
|
+
def update_last_connected(self, name: str, timestamp: datetime) -> None:
|
|
188
|
+
"""Update the last connected timestamp for a planner."""
|
|
189
|
+
data = self._load_data()
|
|
190
|
+
planners = data.get("planners", {})
|
|
191
|
+
if name not in planners:
|
|
192
|
+
raise ValueError(f"No planner named '{name}' exists")
|
|
193
|
+
planners[name]["last_connected_at"] = timestamp.isoformat()
|
|
194
|
+
data["planners"] = planners
|
|
195
|
+
self._save_data(data)
|