teddy-cli 0.1.0__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.
- teddy_cli-0.1.0.dist-info/LICENSE +677 -0
- teddy_cli-0.1.0.dist-info/METADATA +33 -0
- teddy_cli-0.1.0.dist-info/RECORD +143 -0
- teddy_cli-0.1.0.dist-info/WHEEL +4 -0
- teddy_cli-0.1.0.dist-info/entry_points.txt +3 -0
- teddy_executor/__init__.py +1 -0
- teddy_executor/__main__.py +335 -0
- teddy_executor/adapters/__init__.py +0 -0
- teddy_executor/adapters/inbound/__init__.py +0 -0
- teddy_executor/adapters/inbound/cli_formatter.py +107 -0
- teddy_executor/adapters/inbound/cli_helpers.py +249 -0
- teddy_executor/adapters/inbound/console_plan_reviewer.py +69 -0
- teddy_executor/adapters/inbound/session_cli_handlers.py +366 -0
- teddy_executor/adapters/inbound/textual_plan_reviewer.py +78 -0
- teddy_executor/adapters/inbound/textual_plan_reviewer_app.py +367 -0
- teddy_executor/adapters/inbound/textual_plan_reviewer_editor.py +281 -0
- teddy_executor/adapters/inbound/textual_plan_reviewer_execution.py +213 -0
- teddy_executor/adapters/inbound/textual_plan_reviewer_helpers.py +308 -0
- teddy_executor/adapters/inbound/textual_plan_reviewer_logic.py +345 -0
- teddy_executor/adapters/inbound/textual_plan_reviewer_previews.py +227 -0
- teddy_executor/adapters/inbound/textual_plan_reviewer_widgets.py +246 -0
- teddy_executor/adapters/outbound/__init__.py +7 -0
- teddy_executor/adapters/outbound/console_interactor.py +212 -0
- teddy_executor/adapters/outbound/console_interactor_ask_loop.py +121 -0
- teddy_executor/adapters/outbound/console_interactor_helpers.py +95 -0
- teddy_executor/adapters/outbound/console_tooling.py +62 -0
- teddy_executor/adapters/outbound/filesystem_helpers.py +61 -0
- teddy_executor/adapters/outbound/litellm_adapter.py +462 -0
- teddy_executor/adapters/outbound/local_file_system_adapter.py +300 -0
- teddy_executor/adapters/outbound/local_repo_tree_generator.py +96 -0
- teddy_executor/adapters/outbound/openrouter_hydrator.py +89 -0
- teddy_executor/adapters/outbound/shell_adapter.py +344 -0
- teddy_executor/adapters/outbound/shell_command_builder.py +105 -0
- teddy_executor/adapters/outbound/system_environment_adapter.py +62 -0
- teddy_executor/adapters/outbound/system_environment_inspector.py +54 -0
- teddy_executor/adapters/outbound/system_time_adapter.py +22 -0
- teddy_executor/adapters/outbound/web_scraper_adapter.py +346 -0
- teddy_executor/adapters/outbound/web_searcher_adapter.py +122 -0
- teddy_executor/adapters/outbound/yaml_config_adapter.py +105 -0
- teddy_executor/container.py +333 -0
- teddy_executor/core/__init__.py +0 -0
- teddy_executor/core/domain/__init__.py +0 -0
- teddy_executor/core/domain/models/__init__.py +44 -0
- teddy_executor/core/domain/models/action_ports.py +28 -0
- teddy_executor/core/domain/models/change_set.py +10 -0
- teddy_executor/core/domain/models/exceptions.py +40 -0
- teddy_executor/core/domain/models/execution_report.py +65 -0
- teddy_executor/core/domain/models/orchestrator_ports.py +26 -0
- teddy_executor/core/domain/models/plan.py +85 -0
- teddy_executor/core/domain/models/planning_ports.py +43 -0
- teddy_executor/core/domain/models/project_context.py +56 -0
- teddy_executor/core/domain/models/report_assembly_data.py +18 -0
- teddy_executor/core/domain/models/session.py +17 -0
- teddy_executor/core/domain/models/shell_output.py +12 -0
- teddy_executor/core/domain/models/web_search_results.py +26 -0
- teddy_executor/core/ports/__init__.py +0 -0
- teddy_executor/core/ports/inbound/__init__.py +0 -0
- teddy_executor/core/ports/inbound/edit_simulator.py +33 -0
- teddy_executor/core/ports/inbound/get_context_use_case.py +32 -0
- teddy_executor/core/ports/inbound/init.py +15 -0
- teddy_executor/core/ports/inbound/plan_parser.py +52 -0
- teddy_executor/core/ports/inbound/plan_reviewer.py +44 -0
- teddy_executor/core/ports/inbound/plan_validator.py +26 -0
- teddy_executor/core/ports/inbound/planning_use_case.py +30 -0
- teddy_executor/core/ports/inbound/run_plan_use_case.py +60 -0
- teddy_executor/core/ports/outbound/__init__.py +34 -0
- teddy_executor/core/ports/outbound/config_service.py +29 -0
- teddy_executor/core/ports/outbound/environment_inspector.py +30 -0
- teddy_executor/core/ports/outbound/execution_report_assembler.py +19 -0
- teddy_executor/core/ports/outbound/file_system_manager.py +131 -0
- teddy_executor/core/ports/outbound/llm_client.py +90 -0
- teddy_executor/core/ports/outbound/markdown_report_formatter.py +26 -0
- teddy_executor/core/ports/outbound/prompt_manager.py +55 -0
- teddy_executor/core/ports/outbound/repo_tree_generator.py +17 -0
- teddy_executor/core/ports/outbound/session_loop_guard.py +16 -0
- teddy_executor/core/ports/outbound/session_manager.py +97 -0
- teddy_executor/core/ports/outbound/session_repository.py +65 -0
- teddy_executor/core/ports/outbound/shell_executor.py +24 -0
- teddy_executor/core/ports/outbound/system_environment.py +25 -0
- teddy_executor/core/ports/outbound/time_service.py +28 -0
- teddy_executor/core/ports/outbound/user_interactor.py +126 -0
- teddy_executor/core/ports/outbound/web_scraper.py +24 -0
- teddy_executor/core/ports/outbound/web_searcher.py +25 -0
- teddy_executor/core/services/__init__.py +0 -0
- teddy_executor/core/services/action_changeset_builder.py +90 -0
- teddy_executor/core/services/action_diff_manager.py +110 -0
- teddy_executor/core/services/action_dispatcher.py +142 -0
- teddy_executor/core/services/action_executor.py +209 -0
- teddy_executor/core/services/action_factory.py +197 -0
- teddy_executor/core/services/action_parser_complex.py +216 -0
- teddy_executor/core/services/action_parser_strategies.py +84 -0
- teddy_executor/core/services/context_service.py +437 -0
- teddy_executor/core/services/edit_simulator.py +128 -0
- teddy_executor/core/services/execution_orchestrator.py +295 -0
- teddy_executor/core/services/execution_report_assembler.py +62 -0
- teddy_executor/core/services/init_service.py +80 -0
- teddy_executor/core/services/markdown_plan_parser.py +309 -0
- teddy_executor/core/services/markdown_report_formatter.py +143 -0
- teddy_executor/core/services/parser_infrastructure.py +222 -0
- teddy_executor/core/services/parser_metadata.py +153 -0
- teddy_executor/core/services/parser_reporting.py +267 -0
- teddy_executor/core/services/plan_validator.py +82 -0
- teddy_executor/core/services/planning_service.py +242 -0
- teddy_executor/core/services/prompt_manager.py +146 -0
- teddy_executor/core/services/session_lifecycle_manager.py +228 -0
- teddy_executor/core/services/session_loop_guard.py +46 -0
- teddy_executor/core/services/session_orchestrator.py +538 -0
- teddy_executor/core/services/session_planner.py +43 -0
- teddy_executor/core/services/session_pruning_service.py +438 -0
- teddy_executor/core/services/session_replanner.py +105 -0
- teddy_executor/core/services/session_repository.py +194 -0
- teddy_executor/core/services/session_service.py +529 -0
- teddy_executor/core/services/templates/execution_report.md.j2 +290 -0
- teddy_executor/core/services/validation_rules/__init__.py +4 -0
- teddy_executor/core/services/validation_rules/edit.py +207 -0
- teddy_executor/core/services/validation_rules/edit_matcher.py +247 -0
- teddy_executor/core/services/validation_rules/edit_matcher_heuristics.py +84 -0
- teddy_executor/core/services/validation_rules/execute.py +37 -0
- teddy_executor/core/services/validation_rules/filesystem.py +73 -0
- teddy_executor/core/services/validation_rules/helpers.py +178 -0
- teddy_executor/core/services/validation_rules/message.py +29 -0
- teddy_executor/core/utils/__init__.py +1 -0
- teddy_executor/core/utils/diff.py +57 -0
- teddy_executor/core/utils/io.py +75 -0
- teddy_executor/core/utils/markdown.py +131 -0
- teddy_executor/core/utils/serialization.py +39 -0
- teddy_executor/core/utils/string.py +351 -0
- teddy_executor/prompts.py +45 -0
- teddy_executor/registries/__init__.py +1 -0
- teddy_executor/registries/infrastructure.py +147 -0
- teddy_executor/registries/reviewer.py +57 -0
- teddy_executor/registries/validators.py +47 -0
- teddy_executor/resources/__init__.py +1 -0
- teddy_executor/resources/config/.gitignore +2 -0
- teddy_executor/resources/config/__init__.py +1 -0
- teddy_executor/resources/config/config.yaml +49 -0
- teddy_executor/resources/config/init.context +5 -0
- teddy_executor/resources/config/prompts/architect.xml +462 -0
- teddy_executor/resources/config/prompts/assistant.xml +336 -0
- teddy_executor/resources/config/prompts/debugger.xml +456 -0
- teddy_executor/resources/config/prompts/developer.xml +481 -0
- teddy_executor/resources/config/prompts/pathfinder.xml +502 -0
- teddy_executor/resources/config/prompts/prototyper.xml +425 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from punq import Container
|
|
8
|
+
|
|
9
|
+
from teddy_executor.core.domain.models import (
|
|
10
|
+
ExecutionReport,
|
|
11
|
+
RunStatus,
|
|
12
|
+
RunSummary,
|
|
13
|
+
)
|
|
14
|
+
from teddy_executor.core.domain.models.plan import Plan
|
|
15
|
+
from teddy_executor.core.ports.inbound.plan_parser import IPlanParser, InvalidPlanError
|
|
16
|
+
from teddy_executor.core.ports.inbound.run_plan_use_case import IRunPlanUseCase
|
|
17
|
+
from teddy_executor.core.ports.outbound.markdown_report_formatter import (
|
|
18
|
+
IMarkdownReportFormatter,
|
|
19
|
+
)
|
|
20
|
+
from teddy_executor.core.services.plan_validator import ValidationError
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def find_project_root() -> Path:
|
|
26
|
+
"""
|
|
27
|
+
Locates the project root by climbing up from the CWD until a .teddy directory is found.
|
|
28
|
+
Falls back to CWD if not found.
|
|
29
|
+
"""
|
|
30
|
+
current = Path.cwd().resolve()
|
|
31
|
+
for parent in [current] + list(current.parents):
|
|
32
|
+
if (parent / ".teddy").is_dir():
|
|
33
|
+
return parent
|
|
34
|
+
return current
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def echo_and_copy(
|
|
38
|
+
content: str,
|
|
39
|
+
no_copy: bool = False,
|
|
40
|
+
confirmation_message: str = "Output copied to clipboard.",
|
|
41
|
+
content_to_copy: Optional[str] = None,
|
|
42
|
+
):
|
|
43
|
+
"""Prints content to stdout and copies it to the clipboard unless disabled."""
|
|
44
|
+
import threading
|
|
45
|
+
import os
|
|
46
|
+
|
|
47
|
+
if content:
|
|
48
|
+
typer.echo(content)
|
|
49
|
+
|
|
50
|
+
if not no_copy:
|
|
51
|
+
to_copy = content_to_copy if content_to_copy is not None else content
|
|
52
|
+
if os.getenv("TEDDY_DEBUG"):
|
|
53
|
+
typer.echo("DEBUG: Attempting clipboard copy...", err=True)
|
|
54
|
+
try:
|
|
55
|
+
# Completely detached thread. We do NOT join it at all to ensure
|
|
56
|
+
# the main process can exit even if the clipboard provider hangs.
|
|
57
|
+
def _copy():
|
|
58
|
+
import pyperclip
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
pyperclip.copy(to_copy)
|
|
62
|
+
except Exception as e:
|
|
63
|
+
logger.debug("Background clipboard copy failed: %s", e)
|
|
64
|
+
|
|
65
|
+
thread = threading.Thread(target=_copy, daemon=True)
|
|
66
|
+
thread.start()
|
|
67
|
+
# Give the thread a small window to complete. 0.1s is enough for
|
|
68
|
+
# most healthy clipboard providers (pbcopy/xclip) without being
|
|
69
|
+
# a noticeable delay for the user.
|
|
70
|
+
thread.join(timeout=0.1)
|
|
71
|
+
typer.secho(f"\n{confirmation_message}", fg=typer.colors.GREEN, err=True)
|
|
72
|
+
except Exception as e:
|
|
73
|
+
logger.debug("Main thread clipboard handler failed: %s", e)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def get_plan_content(plan_content_str: Optional[str], plan_file: Optional[Path]) -> str:
|
|
77
|
+
"""
|
|
78
|
+
Retrieves the plan content from one of three sources, in order of priority:
|
|
79
|
+
1. A direct string via --plan-content.
|
|
80
|
+
2. A file path.
|
|
81
|
+
3. The system clipboard.
|
|
82
|
+
Exits with an error if the final source is invalid or empty.
|
|
83
|
+
"""
|
|
84
|
+
if plan_content_str:
|
|
85
|
+
return plan_content_str
|
|
86
|
+
|
|
87
|
+
if plan_file:
|
|
88
|
+
if not plan_file.is_file():
|
|
89
|
+
typer.echo(f"Error: Plan file not found at '{plan_file}'", err=True)
|
|
90
|
+
raise typer.Exit(code=1)
|
|
91
|
+
return plan_file.read_text(encoding="utf-8")
|
|
92
|
+
|
|
93
|
+
import pyperclip
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
plan_from_clipboard = pyperclip.paste()
|
|
97
|
+
if not plan_from_clipboard.strip():
|
|
98
|
+
typer.echo(
|
|
99
|
+
"Error: No plan provided via file or --plan-content, and clipboard is empty.",
|
|
100
|
+
err=True,
|
|
101
|
+
)
|
|
102
|
+
raise typer.Exit(code=1)
|
|
103
|
+
return plan_from_clipboard
|
|
104
|
+
except Exception as e:
|
|
105
|
+
typer.echo(f"Error accessing clipboard: {e}", err=True)
|
|
106
|
+
raise typer.Exit(code=1)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def create_failure_report(
|
|
110
|
+
e: Exception,
|
|
111
|
+
start_time: datetime,
|
|
112
|
+
container: Container,
|
|
113
|
+
plan_content: Optional[str] = None,
|
|
114
|
+
) -> ExecutionReport:
|
|
115
|
+
"""
|
|
116
|
+
Creates a rich ExecutionReport for a failure that occurred before or during execution.
|
|
117
|
+
"""
|
|
118
|
+
error_messages = [str(e)]
|
|
119
|
+
|
|
120
|
+
if isinstance(e, InvalidPlanError) and plan_content:
|
|
121
|
+
try:
|
|
122
|
+
plan_parser = container.resolve(IPlanParser)
|
|
123
|
+
temp_plan = plan_parser.parse(plan_content)
|
|
124
|
+
|
|
125
|
+
errors_to_report = getattr(e, "validation_errors", [])
|
|
126
|
+
if errors_to_report:
|
|
127
|
+
return handle_validation_failure(
|
|
128
|
+
temp_plan, errors_to_report, start_time
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
return ExecutionReport(
|
|
132
|
+
plan_title=temp_plan.title,
|
|
133
|
+
run_summary=RunSummary(
|
|
134
|
+
status=RunStatus.VALIDATION_FAILED,
|
|
135
|
+
start_time=start_time,
|
|
136
|
+
end_time=datetime.now(timezone.utc),
|
|
137
|
+
error=str(e),
|
|
138
|
+
),
|
|
139
|
+
validation_result=error_messages,
|
|
140
|
+
)
|
|
141
|
+
except Exception as report_err:
|
|
142
|
+
logger.debug("Failed to create rich failure report: %s", report_err)
|
|
143
|
+
|
|
144
|
+
return ExecutionReport(
|
|
145
|
+
plan_title="Execution Error",
|
|
146
|
+
run_summary=RunSummary(
|
|
147
|
+
status=RunStatus.FAILURE
|
|
148
|
+
if isinstance(e, NotImplementedError)
|
|
149
|
+
else RunStatus.VALIDATION_FAILED,
|
|
150
|
+
start_time=start_time,
|
|
151
|
+
end_time=datetime.now(timezone.utc),
|
|
152
|
+
error=str(e),
|
|
153
|
+
),
|
|
154
|
+
validation_result=error_messages,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def handle_validation_failure(
|
|
159
|
+
plan: Plan, validation_errors: List[ValidationError], start_time: datetime
|
|
160
|
+
) -> ExecutionReport:
|
|
161
|
+
"""Creates an ExecutionReport for a validation failure."""
|
|
162
|
+
failed_resources: dict[str, str] = {}
|
|
163
|
+
error_messages: list[str] = []
|
|
164
|
+
for error in validation_errors:
|
|
165
|
+
error_messages.append(error.message)
|
|
166
|
+
if error.file_path:
|
|
167
|
+
try:
|
|
168
|
+
path = Path(error.file_path)
|
|
169
|
+
if path.exists():
|
|
170
|
+
failed_resources[error.file_path] = path.read_text(encoding="utf-8")
|
|
171
|
+
except OSError as os_err:
|
|
172
|
+
logger.debug(
|
|
173
|
+
"Failed to read failed resource %s: %s", error.file_path, os_err
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
return ExecutionReport(
|
|
177
|
+
plan_title=plan.title,
|
|
178
|
+
rationale=plan.rationale,
|
|
179
|
+
original_actions=plan.actions,
|
|
180
|
+
run_summary=RunSummary(
|
|
181
|
+
status=RunStatus.VALIDATION_FAILED,
|
|
182
|
+
start_time=start_time,
|
|
183
|
+
end_time=datetime.now(timezone.utc),
|
|
184
|
+
),
|
|
185
|
+
validation_result=error_messages,
|
|
186
|
+
failed_resources=failed_resources if failed_resources else None,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def execute_valid_plan(
|
|
191
|
+
container: Container,
|
|
192
|
+
plan: Plan,
|
|
193
|
+
interactive_mode: bool,
|
|
194
|
+
plan_meta: Optional[dict] = None,
|
|
195
|
+
) -> ExecutionReport:
|
|
196
|
+
"""Executes a plan that has already been parsed and validated."""
|
|
197
|
+
import sys
|
|
198
|
+
|
|
199
|
+
orchestrator = container.resolve(IRunPlanUseCase)
|
|
200
|
+
print(f"DEBUG CLI: Resolved orchestrator: {type(orchestrator)}", file=sys.stderr)
|
|
201
|
+
meta = plan_meta or {}
|
|
202
|
+
execution_report = orchestrator.execute(
|
|
203
|
+
plan=plan,
|
|
204
|
+
interactive=interactive_mode,
|
|
205
|
+
plan_path=meta.get("plan_path"),
|
|
206
|
+
plan_content=meta.get("plan_content"),
|
|
207
|
+
)
|
|
208
|
+
return execution_report
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def handle_report_output(
|
|
212
|
+
container: Container,
|
|
213
|
+
report: Optional[ExecutionReport],
|
|
214
|
+
no_copy: bool,
|
|
215
|
+
silent: bool = False,
|
|
216
|
+
exit_on_failure: bool = True,
|
|
217
|
+
) -> None:
|
|
218
|
+
"""Formats the report, echoes/copies it, and exits with non-zero if failed."""
|
|
219
|
+
if report:
|
|
220
|
+
report_formatter = container.resolve(IMarkdownReportFormatter)
|
|
221
|
+
formatted_report = report_formatter.format(report)
|
|
222
|
+
|
|
223
|
+
if silent:
|
|
224
|
+
# In silent mode (sessions), we don't print to stdout or copy to clipboard
|
|
225
|
+
# (R-10-12: The report is already saved to file in the session dir)
|
|
226
|
+
echo_and_copy(
|
|
227
|
+
"",
|
|
228
|
+
content_to_copy=formatted_report,
|
|
229
|
+
no_copy=True,
|
|
230
|
+
)
|
|
231
|
+
else:
|
|
232
|
+
echo_and_copy(
|
|
233
|
+
formatted_report,
|
|
234
|
+
no_copy=no_copy,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
if exit_on_failure and report.run_summary.status in (
|
|
238
|
+
RunStatus.FAILURE,
|
|
239
|
+
RunStatus.VALIDATION_FAILED,
|
|
240
|
+
):
|
|
241
|
+
raise typer.Exit(code=1)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def apply_ui_mode_override(container: Container, ui_mode_bool: bool) -> None:
|
|
245
|
+
"""Applies a UI mode override to the container."""
|
|
246
|
+
from teddy_executor.container import register_reviewer
|
|
247
|
+
|
|
248
|
+
mode = "tui" if ui_mode_bool else "console"
|
|
249
|
+
register_reviewer(container, ui_mode=mode)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Optional, TYPE_CHECKING
|
|
3
|
+
from teddy_executor.core.ports.inbound.plan_reviewer import IPlanReviewer
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from teddy_executor.core.domain.models.plan import (
|
|
7
|
+
Plan,
|
|
8
|
+
ActionData,
|
|
9
|
+
)
|
|
10
|
+
from teddy_executor.core.domain.models.project_context import ProjectContext
|
|
11
|
+
from teddy_executor.core.ports.outbound import (
|
|
12
|
+
IUserInteractor,
|
|
13
|
+
IFileSystemManager,
|
|
14
|
+
IConfigService,
|
|
15
|
+
)
|
|
16
|
+
from teddy_executor.core.ports.inbound.edit_simulator import (
|
|
17
|
+
IEditSimulator,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
from teddy_executor.core.services.action_changeset_builder import ActionChangeSetBuilder
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ConsolePlanReviewer(IPlanReviewer):
|
|
25
|
+
"""
|
|
26
|
+
Inbound adapter for reviewing plans via the standard console (sequential Y/N).
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
user_interactor: IUserInteractor,
|
|
32
|
+
file_system_manager: IFileSystemManager,
|
|
33
|
+
config_service: IConfigService,
|
|
34
|
+
edit_simulator: IEditSimulator,
|
|
35
|
+
):
|
|
36
|
+
self._user_interactor = user_interactor
|
|
37
|
+
self._changeset_builder = ActionChangeSetBuilder(
|
|
38
|
+
file_system_manager, config_service, edit_simulator
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def review(
|
|
42
|
+
self, plan: "Plan", project_context: Optional["ProjectContext"] = None
|
|
43
|
+
) -> Optional["Plan"]:
|
|
44
|
+
"""
|
|
45
|
+
Prints the plan header and returns immediately to proceed to actions.
|
|
46
|
+
"""
|
|
47
|
+
import typer
|
|
48
|
+
|
|
49
|
+
header = f'\n▶ Reviewing Plan: "{plan.title}"'
|
|
50
|
+
typer.secho(header, fg=typer.colors.CYAN, bold=True, err=True)
|
|
51
|
+
return plan
|
|
52
|
+
|
|
53
|
+
def review_action(
|
|
54
|
+
self,
|
|
55
|
+
action: "ActionData",
|
|
56
|
+
total_actions: int,
|
|
57
|
+
agent_name: Optional[str] = None,
|
|
58
|
+
) -> tuple[bool, str]:
|
|
59
|
+
_ = total_actions
|
|
60
|
+
_ = agent_name
|
|
61
|
+
prompt = ActionChangeSetBuilder.format_action_prompt(action)
|
|
62
|
+
|
|
63
|
+
change_set = self._changeset_builder.create_change_set(action)
|
|
64
|
+
|
|
65
|
+
approved, message = self._user_interactor.confirm_action(
|
|
66
|
+
action=action, action_prompt=prompt, change_set=change_set
|
|
67
|
+
)
|
|
68
|
+
action.selected = approved
|
|
69
|
+
return approved, message
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Dict, Optional, Sequence
|
|
3
|
+
import typer
|
|
4
|
+
from punq import Container
|
|
5
|
+
|
|
6
|
+
from teddy_executor.core.ports.inbound.get_context_use_case import IGetContextUseCase
|
|
7
|
+
from teddy_executor.core.ports.inbound.init import IInitUseCase
|
|
8
|
+
from teddy_executor.core.ports.inbound.planning_use_case import IPlanningUseCase
|
|
9
|
+
from teddy_executor.core.ports.inbound.run_plan_use_case import IRunPlanUseCase
|
|
10
|
+
from teddy_executor.core.ports.outbound.session_manager import ISessionManager
|
|
11
|
+
from teddy_executor.core.domain.models.session import SessionOptions
|
|
12
|
+
from teddy_executor.core.ports.outbound.user_interactor import IUserInteractor
|
|
13
|
+
from teddy_executor.core.ports.outbound.session_loop_guard import ISessionLoopGuard
|
|
14
|
+
from teddy_executor.core.ports.outbound.config_service import IConfigService
|
|
15
|
+
from teddy_executor.core.ports.outbound.session_repository import ISessionRepository
|
|
16
|
+
from teddy_executor.core.utils.string import slugify
|
|
17
|
+
from teddy_executor.adapters.inbound.cli_formatter import format_project_context
|
|
18
|
+
from teddy_executor.adapters.inbound.cli_helpers import (
|
|
19
|
+
echo_and_copy,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _determine_session_name(name: Optional[str], message: Optional[str]) -> str:
|
|
24
|
+
"""Determine session name (slugify message if name is missing)."""
|
|
25
|
+
if name:
|
|
26
|
+
return name
|
|
27
|
+
if message:
|
|
28
|
+
return slugify(message)
|
|
29
|
+
return "session-auto"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _orchestrate_session_loop(
|
|
33
|
+
container: Container,
|
|
34
|
+
session_name: str,
|
|
35
|
+
interactive: bool,
|
|
36
|
+
no_copy: bool,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Shared turn loop for start and resume commands."""
|
|
39
|
+
from teddy_executor.adapters.inbound.cli_helpers import handle_report_output
|
|
40
|
+
|
|
41
|
+
orchestrator = container.resolve(IRunPlanUseCase)
|
|
42
|
+
session_manager = container.resolve(ISessionManager)
|
|
43
|
+
|
|
44
|
+
# Resolve initial state for process-relative guardrails
|
|
45
|
+
latest_turn_path = session_manager.get_latest_turn(session_name)
|
|
46
|
+
try:
|
|
47
|
+
# get_latest_turn returns a path string; the turn ID is the folder name
|
|
48
|
+
initial_turn = int(Path(latest_turn_path).name) if latest_turn_path else 0
|
|
49
|
+
except (ValueError, TypeError):
|
|
50
|
+
# Handle non-numeric turn names or MagicMocks in tests
|
|
51
|
+
initial_turn = 0
|
|
52
|
+
|
|
53
|
+
# Resolve initial cost for process-relative guardrails
|
|
54
|
+
initial_cost = session_manager.get_cumulative_cost(session_name)
|
|
55
|
+
|
|
56
|
+
loop_guard = container.resolve(
|
|
57
|
+
ISessionLoopGuard, initial_turn=initial_turn, initial_cost=initial_cost
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
turn_count = 0
|
|
61
|
+
while True:
|
|
62
|
+
turn_count += 1
|
|
63
|
+
session_name, report = orchestrator.resume(
|
|
64
|
+
session_name=session_name,
|
|
65
|
+
interactive=interactive,
|
|
66
|
+
)
|
|
67
|
+
if report is None:
|
|
68
|
+
break
|
|
69
|
+
|
|
70
|
+
# In session mode, we do NOT exit on validation failure
|
|
71
|
+
# because the orchestrator triggers an automatic re-plan.
|
|
72
|
+
handle_report_output(
|
|
73
|
+
container, report, no_copy, silent=True, exit_on_failure=False
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
cumulative_cost = float(report.metadata.get("cumulative_cost", 0.0))
|
|
77
|
+
if not loop_guard.should_continue(turn_count, cumulative_cost, interactive):
|
|
78
|
+
break
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def handle_new_session( # noqa: PLR0913
|
|
82
|
+
container: Container,
|
|
83
|
+
name: Optional[str],
|
|
84
|
+
agent: str,
|
|
85
|
+
interactive: bool = True,
|
|
86
|
+
no_copy: bool = False,
|
|
87
|
+
message: Optional[str] = None,
|
|
88
|
+
additional_context: Optional[list[str]] = None,
|
|
89
|
+
model: Optional[str] = None,
|
|
90
|
+
provider: Optional[str] = None,
|
|
91
|
+
api_key: Optional[str] = None,
|
|
92
|
+
):
|
|
93
|
+
"""Logic for the 'start' command."""
|
|
94
|
+
try:
|
|
95
|
+
# 0. Ensure project is initialized
|
|
96
|
+
container.resolve(IInitUseCase).ensure_initialized()
|
|
97
|
+
|
|
98
|
+
# 1. Pre-flight checks (Fail-fast before user interaction)
|
|
99
|
+
typer.echo("Checking configurations...", err=True)
|
|
100
|
+
_run_cli_preflight_check(container, agent=agent)
|
|
101
|
+
_echo_config_success(container, agent, model=model)
|
|
102
|
+
|
|
103
|
+
session_manager: ISessionManager = container.resolve(ISessionManager)
|
|
104
|
+
user_interactor: IUserInteractor = container.resolve(IUserInteractor)
|
|
105
|
+
|
|
106
|
+
# 2. Resolve message first if missing
|
|
107
|
+
if message is None:
|
|
108
|
+
message = user_interactor.ask_question("What are we working on?")
|
|
109
|
+
if not message:
|
|
110
|
+
raise EOFError("No terminal input provided for initial message.")
|
|
111
|
+
|
|
112
|
+
# 2. Determine session name (slugify message if name is missing)
|
|
113
|
+
actual_name = _determine_session_name(name, message)
|
|
114
|
+
options = SessionOptions(
|
|
115
|
+
name=actual_name,
|
|
116
|
+
agent_name=agent,
|
|
117
|
+
initial_request=message,
|
|
118
|
+
additional_context=additional_context or [],
|
|
119
|
+
model=model,
|
|
120
|
+
provider=provider,
|
|
121
|
+
api_key=api_key,
|
|
122
|
+
)
|
|
123
|
+
session_dir = session_manager.create_session(options)
|
|
124
|
+
typer.echo(f"Session created at: {session_dir}")
|
|
125
|
+
|
|
126
|
+
# Streamlined Initialization: Trigger resume (which triggers planning for EMPTY state)
|
|
127
|
+
_orchestrate_session_loop(
|
|
128
|
+
container=container,
|
|
129
|
+
session_name=Path(session_dir).name,
|
|
130
|
+
interactive=interactive,
|
|
131
|
+
no_copy=no_copy,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
except Exception as e:
|
|
135
|
+
from teddy_executor.core.domain.models.exceptions import ConfigurationError
|
|
136
|
+
|
|
137
|
+
if isinstance(e, ConfigurationError):
|
|
138
|
+
from teddy_executor.core.ports.outbound.config_service import IConfigService
|
|
139
|
+
|
|
140
|
+
config_service = container.resolve(IConfigService)
|
|
141
|
+
config_path = config_service.get_config_path()
|
|
142
|
+
typer.echo(f"Error: {e}", err=True)
|
|
143
|
+
typer.echo(f"Please update your configuration at: {config_path}", err=True)
|
|
144
|
+
else:
|
|
145
|
+
typer.echo(f"Error: {e}", err=True)
|
|
146
|
+
raise typer.Exit(code=1)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _echo_config_success(
|
|
150
|
+
container: Container,
|
|
151
|
+
agent: Optional[str] = None,
|
|
152
|
+
model: Optional[str] = None,
|
|
153
|
+
actual_model: Optional[str] = None,
|
|
154
|
+
) -> None:
|
|
155
|
+
"""Retrieves and echoes the active model and agent configuration on success.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
container: The DI container with resolved services.
|
|
159
|
+
agent: Optional agent name to display.
|
|
160
|
+
model: Optional model override from CLI. If provided, this takes
|
|
161
|
+
precedence over the config file value.
|
|
162
|
+
actual_model: Optional actual serving model from meta.yaml. If provided,
|
|
163
|
+
this takes precedence over the model parameter.
|
|
164
|
+
"""
|
|
165
|
+
config_service = container.resolve(IConfigService)
|
|
166
|
+
if actual_model:
|
|
167
|
+
resolved_model = actual_model
|
|
168
|
+
elif model:
|
|
169
|
+
resolved_model = model
|
|
170
|
+
else:
|
|
171
|
+
resolved_model = config_service.get_setting("llm.model", "unknown")
|
|
172
|
+
msg = f"API key valid! Model: {resolved_model}"
|
|
173
|
+
if agent:
|
|
174
|
+
msg += f" | Agent: {agent}"
|
|
175
|
+
typer.echo(msg, err=True)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _run_cli_preflight_check(container: Container, agent: Optional[str] = None) -> None:
|
|
179
|
+
"""Ensures system is configured before starting/resuming a session."""
|
|
180
|
+
from teddy_executor.core.ports.outbound.llm_client import ILlmClient
|
|
181
|
+
from teddy_executor.core.domain.models.exceptions import ConfigurationError
|
|
182
|
+
from teddy_executor.core.ports.outbound.prompt_manager import IPromptManager
|
|
183
|
+
|
|
184
|
+
llm_client = container.resolve(ILlmClient)
|
|
185
|
+
# Perform local validation only to ensure fast CLI startup.
|
|
186
|
+
# Remote connectivity is checked lazily by the PlanningService.
|
|
187
|
+
errors = llm_client.validate_config(include_remote=False)
|
|
188
|
+
|
|
189
|
+
if agent:
|
|
190
|
+
prompt_manager = container.resolve(IPromptManager)
|
|
191
|
+
if not prompt_manager.get_prompt_content(agent):
|
|
192
|
+
available = prompt_manager.get_available_agents()
|
|
193
|
+
agent_error = f"Agent prompt '{agent}' not found. Available agents: " + (
|
|
194
|
+
", ".join(available) if available else ""
|
|
195
|
+
)
|
|
196
|
+
if not errors:
|
|
197
|
+
# Only an agent error -> plain ValueError (no config hint)
|
|
198
|
+
raise ValueError(agent_error)
|
|
199
|
+
# Prepend agent error before other errors
|
|
200
|
+
errors.insert(0, agent_error)
|
|
201
|
+
|
|
202
|
+
if not errors:
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
error_msg = f"Configuration Error: {', '.join(errors)}"
|
|
206
|
+
raise ConfigurationError(error_msg)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def detect_session_context() -> Optional[Dict[str, Sequence[str]]]:
|
|
210
|
+
"""Helper to detect turn and session context files."""
|
|
211
|
+
cwd = Path.cwd()
|
|
212
|
+
turn_context = cwd / "turn.context"
|
|
213
|
+
session_context = cwd.parent / "session.context"
|
|
214
|
+
meta_yaml = cwd / "meta.yaml"
|
|
215
|
+
|
|
216
|
+
if turn_context.exists() and session_context.exists() and meta_yaml.exists():
|
|
217
|
+
return {
|
|
218
|
+
"Turn": [str(turn_context)],
|
|
219
|
+
"Session": [str(session_context)],
|
|
220
|
+
}
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def handle_plan_generation(container: Container, message: Optional[str]):
|
|
225
|
+
"""Logic for the 'plan' command."""
|
|
226
|
+
try:
|
|
227
|
+
# Note: 'plan' command uses the default 'pathfinder' agent if not in a session
|
|
228
|
+
_run_cli_preflight_check(container, agent="pathfinder")
|
|
229
|
+
_echo_config_success(container)
|
|
230
|
+
|
|
231
|
+
planning_service: IPlanningUseCase = container.resolve(IPlanningUseCase)
|
|
232
|
+
context_files = detect_session_context()
|
|
233
|
+
cwd = Path.cwd()
|
|
234
|
+
|
|
235
|
+
plan_path, _ = planning_service.generate_plan(
|
|
236
|
+
user_message=message, turn_dir=str(cwd), context_files=context_files
|
|
237
|
+
)
|
|
238
|
+
typer.echo(f"Plan generated at: {plan_path}")
|
|
239
|
+
except Exception as e:
|
|
240
|
+
from teddy_executor.core.domain.models.exceptions import ConfigurationError
|
|
241
|
+
|
|
242
|
+
if isinstance(e, ConfigurationError):
|
|
243
|
+
from teddy_executor.core.ports.outbound.config_service import IConfigService
|
|
244
|
+
|
|
245
|
+
config_service = container.resolve(IConfigService)
|
|
246
|
+
config_path = config_service.get_config_path()
|
|
247
|
+
typer.echo(f"Error: {e}", err=True)
|
|
248
|
+
typer.echo(f"Please update your configuration at: {config_path}", err=True)
|
|
249
|
+
else:
|
|
250
|
+
typer.echo(f"Error: {e}", err=True)
|
|
251
|
+
raise typer.Exit(code=1)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def handle_context_gathering(container: Container, no_copy: bool):
|
|
255
|
+
"""Logic for the 'context' command."""
|
|
256
|
+
context_service: IGetContextUseCase = container.resolve(IGetContextUseCase)
|
|
257
|
+
context_files = detect_session_context()
|
|
258
|
+
# Performance: Formatting context for Markdown doesn't use tokens, so we skip them.
|
|
259
|
+
context_result = context_service.get_context(
|
|
260
|
+
context_files=context_files, include_tokens=False
|
|
261
|
+
)
|
|
262
|
+
formatted_context = format_project_context(context_result)
|
|
263
|
+
echo_and_copy(formatted_context, no_copy=no_copy)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _resolve_session_name(
|
|
267
|
+
container: Container,
|
|
268
|
+
path: Optional[str] = None,
|
|
269
|
+
) -> str:
|
|
270
|
+
"""Resolves the session name from a path, CWD, or auto-detection."""
|
|
271
|
+
session_manager = container.resolve(ISessionManager)
|
|
272
|
+
if path:
|
|
273
|
+
return session_manager.resolve_session_from_path(path)
|
|
274
|
+
try:
|
|
275
|
+
return session_manager.resolve_session_from_path(str(Path.cwd().resolve()))
|
|
276
|
+
except ValueError:
|
|
277
|
+
return session_manager.get_latest_session_name()
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _sync_and_display_session_meta(
|
|
281
|
+
container: Container,
|
|
282
|
+
session_name: str,
|
|
283
|
+
model: Optional[str] = None,
|
|
284
|
+
provider: Optional[str] = None,
|
|
285
|
+
api_key: Optional[str] = None,
|
|
286
|
+
) -> None:
|
|
287
|
+
"""Reads latest turn meta.yaml, syncs config overrides, and clears actual_model."""
|
|
288
|
+
from teddy_executor.core.ports.outbound.config_service import IConfigService
|
|
289
|
+
|
|
290
|
+
session_manager = container.resolve(ISessionManager)
|
|
291
|
+
repository: ISessionRepository = container.resolve(ISessionRepository)
|
|
292
|
+
config_service: IConfigService = container.resolve(IConfigService)
|
|
293
|
+
|
|
294
|
+
latest_turn_path = session_manager.get_latest_turn(session_name)
|
|
295
|
+
meta = repository.load_meta(latest_turn_path)
|
|
296
|
+
# Clear actual_model so display falls through to current model (override or config)
|
|
297
|
+
meta.pop("actual_model", None)
|
|
298
|
+
_echo_config_success(container, agent=meta.get("agent_name"), model=model)
|
|
299
|
+
|
|
300
|
+
# Sync latest turn's meta.yaml with current config model/overrides
|
|
301
|
+
config_model = config_service.get_setting("llm.model", "unknown")
|
|
302
|
+
changed = False
|
|
303
|
+
if model:
|
|
304
|
+
meta["model"] = model
|
|
305
|
+
changed = True
|
|
306
|
+
elif config_model != "unknown" and meta.get("model") != config_model:
|
|
307
|
+
meta["model"] = config_model
|
|
308
|
+
changed = True
|
|
309
|
+
if provider:
|
|
310
|
+
meta["provider"] = provider
|
|
311
|
+
changed = True
|
|
312
|
+
if api_key:
|
|
313
|
+
meta["api_key"] = api_key
|
|
314
|
+
changed = True
|
|
315
|
+
if changed:
|
|
316
|
+
repository.save_meta(f"{latest_turn_path}/meta.yaml", meta)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def handle_resume_session( # noqa: PLR0913
|
|
320
|
+
container: Container,
|
|
321
|
+
path: Optional[str] = None,
|
|
322
|
+
interactive: bool = True,
|
|
323
|
+
no_copy: bool = False,
|
|
324
|
+
model: Optional[str] = None,
|
|
325
|
+
provider: Optional[str] = None,
|
|
326
|
+
api_key: Optional[str] = None,
|
|
327
|
+
):
|
|
328
|
+
"""Logic for the 'resume' command."""
|
|
329
|
+
try:
|
|
330
|
+
# 1. Pre-flight checks
|
|
331
|
+
typer.echo("Checking configurations...", err=True)
|
|
332
|
+
_run_cli_preflight_check(container)
|
|
333
|
+
|
|
334
|
+
# 2. Resolve session name
|
|
335
|
+
session_name = _resolve_session_name(container, path)
|
|
336
|
+
|
|
337
|
+
# 3. Display session path
|
|
338
|
+
session_relative_path = str(Path(".teddy") / "sessions" / session_name)
|
|
339
|
+
typer.echo(f"Resuming session: {session_relative_path}")
|
|
340
|
+
|
|
341
|
+
# 4. Display actual_model and sync config overrides
|
|
342
|
+
_sync_and_display_session_meta(
|
|
343
|
+
container, session_name, model=model, provider=provider, api_key=api_key
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
# 5. Enter the session loop
|
|
347
|
+
_orchestrate_session_loop(
|
|
348
|
+
container=container,
|
|
349
|
+
session_name=session_name,
|
|
350
|
+
interactive=interactive,
|
|
351
|
+
no_copy=no_copy,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
except Exception as e:
|
|
355
|
+
from teddy_executor.core.domain.models.exceptions import ConfigurationError
|
|
356
|
+
|
|
357
|
+
if isinstance(e, ConfigurationError):
|
|
358
|
+
from teddy_executor.core.ports.outbound.config_service import IConfigService
|
|
359
|
+
|
|
360
|
+
config_service = container.resolve(IConfigService)
|
|
361
|
+
config_path = config_service.get_config_path()
|
|
362
|
+
typer.echo(f"Error: {e}", err=True)
|
|
363
|
+
typer.echo(f"Please update your configuration at: {config_path}", err=True)
|
|
364
|
+
else:
|
|
365
|
+
typer.echo(f"Error: {e}", err=True)
|
|
366
|
+
raise typer.Exit(code=1)
|