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.
Files changed (143) hide show
  1. teddy_cli-0.1.0.dist-info/LICENSE +677 -0
  2. teddy_cli-0.1.0.dist-info/METADATA +33 -0
  3. teddy_cli-0.1.0.dist-info/RECORD +143 -0
  4. teddy_cli-0.1.0.dist-info/WHEEL +4 -0
  5. teddy_cli-0.1.0.dist-info/entry_points.txt +3 -0
  6. teddy_executor/__init__.py +1 -0
  7. teddy_executor/__main__.py +335 -0
  8. teddy_executor/adapters/__init__.py +0 -0
  9. teddy_executor/adapters/inbound/__init__.py +0 -0
  10. teddy_executor/adapters/inbound/cli_formatter.py +107 -0
  11. teddy_executor/adapters/inbound/cli_helpers.py +249 -0
  12. teddy_executor/adapters/inbound/console_plan_reviewer.py +69 -0
  13. teddy_executor/adapters/inbound/session_cli_handlers.py +366 -0
  14. teddy_executor/adapters/inbound/textual_plan_reviewer.py +78 -0
  15. teddy_executor/adapters/inbound/textual_plan_reviewer_app.py +367 -0
  16. teddy_executor/adapters/inbound/textual_plan_reviewer_editor.py +281 -0
  17. teddy_executor/adapters/inbound/textual_plan_reviewer_execution.py +213 -0
  18. teddy_executor/adapters/inbound/textual_plan_reviewer_helpers.py +308 -0
  19. teddy_executor/adapters/inbound/textual_plan_reviewer_logic.py +345 -0
  20. teddy_executor/adapters/inbound/textual_plan_reviewer_previews.py +227 -0
  21. teddy_executor/adapters/inbound/textual_plan_reviewer_widgets.py +246 -0
  22. teddy_executor/adapters/outbound/__init__.py +7 -0
  23. teddy_executor/adapters/outbound/console_interactor.py +212 -0
  24. teddy_executor/adapters/outbound/console_interactor_ask_loop.py +121 -0
  25. teddy_executor/adapters/outbound/console_interactor_helpers.py +95 -0
  26. teddy_executor/adapters/outbound/console_tooling.py +62 -0
  27. teddy_executor/adapters/outbound/filesystem_helpers.py +61 -0
  28. teddy_executor/adapters/outbound/litellm_adapter.py +462 -0
  29. teddy_executor/adapters/outbound/local_file_system_adapter.py +300 -0
  30. teddy_executor/adapters/outbound/local_repo_tree_generator.py +96 -0
  31. teddy_executor/adapters/outbound/openrouter_hydrator.py +89 -0
  32. teddy_executor/adapters/outbound/shell_adapter.py +344 -0
  33. teddy_executor/adapters/outbound/shell_command_builder.py +105 -0
  34. teddy_executor/adapters/outbound/system_environment_adapter.py +62 -0
  35. teddy_executor/adapters/outbound/system_environment_inspector.py +54 -0
  36. teddy_executor/adapters/outbound/system_time_adapter.py +22 -0
  37. teddy_executor/adapters/outbound/web_scraper_adapter.py +346 -0
  38. teddy_executor/adapters/outbound/web_searcher_adapter.py +122 -0
  39. teddy_executor/adapters/outbound/yaml_config_adapter.py +105 -0
  40. teddy_executor/container.py +333 -0
  41. teddy_executor/core/__init__.py +0 -0
  42. teddy_executor/core/domain/__init__.py +0 -0
  43. teddy_executor/core/domain/models/__init__.py +44 -0
  44. teddy_executor/core/domain/models/action_ports.py +28 -0
  45. teddy_executor/core/domain/models/change_set.py +10 -0
  46. teddy_executor/core/domain/models/exceptions.py +40 -0
  47. teddy_executor/core/domain/models/execution_report.py +65 -0
  48. teddy_executor/core/domain/models/orchestrator_ports.py +26 -0
  49. teddy_executor/core/domain/models/plan.py +85 -0
  50. teddy_executor/core/domain/models/planning_ports.py +43 -0
  51. teddy_executor/core/domain/models/project_context.py +56 -0
  52. teddy_executor/core/domain/models/report_assembly_data.py +18 -0
  53. teddy_executor/core/domain/models/session.py +17 -0
  54. teddy_executor/core/domain/models/shell_output.py +12 -0
  55. teddy_executor/core/domain/models/web_search_results.py +26 -0
  56. teddy_executor/core/ports/__init__.py +0 -0
  57. teddy_executor/core/ports/inbound/__init__.py +0 -0
  58. teddy_executor/core/ports/inbound/edit_simulator.py +33 -0
  59. teddy_executor/core/ports/inbound/get_context_use_case.py +32 -0
  60. teddy_executor/core/ports/inbound/init.py +15 -0
  61. teddy_executor/core/ports/inbound/plan_parser.py +52 -0
  62. teddy_executor/core/ports/inbound/plan_reviewer.py +44 -0
  63. teddy_executor/core/ports/inbound/plan_validator.py +26 -0
  64. teddy_executor/core/ports/inbound/planning_use_case.py +30 -0
  65. teddy_executor/core/ports/inbound/run_plan_use_case.py +60 -0
  66. teddy_executor/core/ports/outbound/__init__.py +34 -0
  67. teddy_executor/core/ports/outbound/config_service.py +29 -0
  68. teddy_executor/core/ports/outbound/environment_inspector.py +30 -0
  69. teddy_executor/core/ports/outbound/execution_report_assembler.py +19 -0
  70. teddy_executor/core/ports/outbound/file_system_manager.py +131 -0
  71. teddy_executor/core/ports/outbound/llm_client.py +90 -0
  72. teddy_executor/core/ports/outbound/markdown_report_formatter.py +26 -0
  73. teddy_executor/core/ports/outbound/prompt_manager.py +55 -0
  74. teddy_executor/core/ports/outbound/repo_tree_generator.py +17 -0
  75. teddy_executor/core/ports/outbound/session_loop_guard.py +16 -0
  76. teddy_executor/core/ports/outbound/session_manager.py +97 -0
  77. teddy_executor/core/ports/outbound/session_repository.py +65 -0
  78. teddy_executor/core/ports/outbound/shell_executor.py +24 -0
  79. teddy_executor/core/ports/outbound/system_environment.py +25 -0
  80. teddy_executor/core/ports/outbound/time_service.py +28 -0
  81. teddy_executor/core/ports/outbound/user_interactor.py +126 -0
  82. teddy_executor/core/ports/outbound/web_scraper.py +24 -0
  83. teddy_executor/core/ports/outbound/web_searcher.py +25 -0
  84. teddy_executor/core/services/__init__.py +0 -0
  85. teddy_executor/core/services/action_changeset_builder.py +90 -0
  86. teddy_executor/core/services/action_diff_manager.py +110 -0
  87. teddy_executor/core/services/action_dispatcher.py +142 -0
  88. teddy_executor/core/services/action_executor.py +209 -0
  89. teddy_executor/core/services/action_factory.py +197 -0
  90. teddy_executor/core/services/action_parser_complex.py +216 -0
  91. teddy_executor/core/services/action_parser_strategies.py +84 -0
  92. teddy_executor/core/services/context_service.py +437 -0
  93. teddy_executor/core/services/edit_simulator.py +128 -0
  94. teddy_executor/core/services/execution_orchestrator.py +295 -0
  95. teddy_executor/core/services/execution_report_assembler.py +62 -0
  96. teddy_executor/core/services/init_service.py +80 -0
  97. teddy_executor/core/services/markdown_plan_parser.py +309 -0
  98. teddy_executor/core/services/markdown_report_formatter.py +143 -0
  99. teddy_executor/core/services/parser_infrastructure.py +222 -0
  100. teddy_executor/core/services/parser_metadata.py +153 -0
  101. teddy_executor/core/services/parser_reporting.py +267 -0
  102. teddy_executor/core/services/plan_validator.py +82 -0
  103. teddy_executor/core/services/planning_service.py +242 -0
  104. teddy_executor/core/services/prompt_manager.py +146 -0
  105. teddy_executor/core/services/session_lifecycle_manager.py +228 -0
  106. teddy_executor/core/services/session_loop_guard.py +46 -0
  107. teddy_executor/core/services/session_orchestrator.py +538 -0
  108. teddy_executor/core/services/session_planner.py +43 -0
  109. teddy_executor/core/services/session_pruning_service.py +438 -0
  110. teddy_executor/core/services/session_replanner.py +105 -0
  111. teddy_executor/core/services/session_repository.py +194 -0
  112. teddy_executor/core/services/session_service.py +529 -0
  113. teddy_executor/core/services/templates/execution_report.md.j2 +290 -0
  114. teddy_executor/core/services/validation_rules/__init__.py +4 -0
  115. teddy_executor/core/services/validation_rules/edit.py +207 -0
  116. teddy_executor/core/services/validation_rules/edit_matcher.py +247 -0
  117. teddy_executor/core/services/validation_rules/edit_matcher_heuristics.py +84 -0
  118. teddy_executor/core/services/validation_rules/execute.py +37 -0
  119. teddy_executor/core/services/validation_rules/filesystem.py +73 -0
  120. teddy_executor/core/services/validation_rules/helpers.py +178 -0
  121. teddy_executor/core/services/validation_rules/message.py +29 -0
  122. teddy_executor/core/utils/__init__.py +1 -0
  123. teddy_executor/core/utils/diff.py +57 -0
  124. teddy_executor/core/utils/io.py +75 -0
  125. teddy_executor/core/utils/markdown.py +131 -0
  126. teddy_executor/core/utils/serialization.py +39 -0
  127. teddy_executor/core/utils/string.py +351 -0
  128. teddy_executor/prompts.py +45 -0
  129. teddy_executor/registries/__init__.py +1 -0
  130. teddy_executor/registries/infrastructure.py +147 -0
  131. teddy_executor/registries/reviewer.py +57 -0
  132. teddy_executor/registries/validators.py +47 -0
  133. teddy_executor/resources/__init__.py +1 -0
  134. teddy_executor/resources/config/.gitignore +2 -0
  135. teddy_executor/resources/config/__init__.py +1 -0
  136. teddy_executor/resources/config/config.yaml +49 -0
  137. teddy_executor/resources/config/init.context +5 -0
  138. teddy_executor/resources/config/prompts/architect.xml +462 -0
  139. teddy_executor/resources/config/prompts/assistant.xml +336 -0
  140. teddy_executor/resources/config/prompts/debugger.xml +456 -0
  141. teddy_executor/resources/config/prompts/developer.xml +481 -0
  142. teddy_executor/resources/config/prompts/pathfinder.xml +502 -0
  143. 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)