titan-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.
- titan_cli/__init__.py +3 -0
- titan_cli/__main__.py +4 -0
- titan_cli/ai/__init__.py +0 -0
- titan_cli/ai/agents/__init__.py +15 -0
- titan_cli/ai/agents/base.py +152 -0
- titan_cli/ai/client.py +170 -0
- titan_cli/ai/constants.py +56 -0
- titan_cli/ai/exceptions.py +48 -0
- titan_cli/ai/models.py +34 -0
- titan_cli/ai/oauth_helper.py +120 -0
- titan_cli/ai/providers/__init__.py +9 -0
- titan_cli/ai/providers/anthropic.py +117 -0
- titan_cli/ai/providers/base.py +75 -0
- titan_cli/ai/providers/gemini.py +278 -0
- titan_cli/cli.py +59 -0
- titan_cli/clients/__init__.py +1 -0
- titan_cli/clients/gcloud_client.py +52 -0
- titan_cli/core/__init__.py +3 -0
- titan_cli/core/config.py +274 -0
- titan_cli/core/discovery.py +51 -0
- titan_cli/core/errors.py +81 -0
- titan_cli/core/models.py +52 -0
- titan_cli/core/plugins/available.py +36 -0
- titan_cli/core/plugins/models.py +67 -0
- titan_cli/core/plugins/plugin_base.py +108 -0
- titan_cli/core/plugins/plugin_registry.py +163 -0
- titan_cli/core/secrets.py +141 -0
- titan_cli/core/workflows/__init__.py +22 -0
- titan_cli/core/workflows/models.py +88 -0
- titan_cli/core/workflows/project_step_source.py +86 -0
- titan_cli/core/workflows/workflow_exceptions.py +17 -0
- titan_cli/core/workflows/workflow_filter_service.py +137 -0
- titan_cli/core/workflows/workflow_registry.py +419 -0
- titan_cli/core/workflows/workflow_sources.py +307 -0
- titan_cli/engine/__init__.py +39 -0
- titan_cli/engine/builder.py +159 -0
- titan_cli/engine/context.py +82 -0
- titan_cli/engine/mock_context.py +176 -0
- titan_cli/engine/results.py +91 -0
- titan_cli/engine/steps/ai_assistant_step.py +185 -0
- titan_cli/engine/steps/command_step.py +93 -0
- titan_cli/engine/utils/__init__.py +3 -0
- titan_cli/engine/utils/venv.py +31 -0
- titan_cli/engine/workflow_executor.py +187 -0
- titan_cli/external_cli/__init__.py +0 -0
- titan_cli/external_cli/configs.py +17 -0
- titan_cli/external_cli/launcher.py +65 -0
- titan_cli/messages.py +121 -0
- titan_cli/ui/tui/__init__.py +205 -0
- titan_cli/ui/tui/__previews__/statusbar_preview.py +88 -0
- titan_cli/ui/tui/app.py +113 -0
- titan_cli/ui/tui/icons.py +70 -0
- titan_cli/ui/tui/screens/__init__.py +24 -0
- titan_cli/ui/tui/screens/ai_config.py +498 -0
- titan_cli/ui/tui/screens/ai_config_wizard.py +882 -0
- titan_cli/ui/tui/screens/base.py +110 -0
- titan_cli/ui/tui/screens/cli_launcher.py +151 -0
- titan_cli/ui/tui/screens/global_setup_wizard.py +363 -0
- titan_cli/ui/tui/screens/main_menu.py +162 -0
- titan_cli/ui/tui/screens/plugin_config_wizard.py +550 -0
- titan_cli/ui/tui/screens/plugin_management.py +377 -0
- titan_cli/ui/tui/screens/project_setup_wizard.py +686 -0
- titan_cli/ui/tui/screens/workflow_execution.py +592 -0
- titan_cli/ui/tui/screens/workflows.py +249 -0
- titan_cli/ui/tui/textual_components.py +537 -0
- titan_cli/ui/tui/textual_workflow_executor.py +405 -0
- titan_cli/ui/tui/theme.py +102 -0
- titan_cli/ui/tui/widgets/__init__.py +40 -0
- titan_cli/ui/tui/widgets/button.py +108 -0
- titan_cli/ui/tui/widgets/header.py +116 -0
- titan_cli/ui/tui/widgets/panel.py +81 -0
- titan_cli/ui/tui/widgets/status_bar.py +115 -0
- titan_cli/ui/tui/widgets/table.py +77 -0
- titan_cli/ui/tui/widgets/text.py +177 -0
- titan_cli/utils/__init__.py +0 -0
- titan_cli/utils/autoupdate.py +155 -0
- titan_cli-0.1.0.dist-info/METADATA +149 -0
- titan_cli-0.1.0.dist-info/RECORD +146 -0
- titan_cli-0.1.0.dist-info/WHEEL +4 -0
- titan_cli-0.1.0.dist-info/entry_points.txt +9 -0
- titan_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- titan_plugin_git/__init__.py +1 -0
- titan_plugin_git/clients/__init__.py +8 -0
- titan_plugin_git/clients/git_client.py +772 -0
- titan_plugin_git/exceptions.py +40 -0
- titan_plugin_git/messages.py +112 -0
- titan_plugin_git/models.py +39 -0
- titan_plugin_git/plugin.py +118 -0
- titan_plugin_git/steps/__init__.py +1 -0
- titan_plugin_git/steps/ai_commit_message_step.py +171 -0
- titan_plugin_git/steps/branch_steps.py +104 -0
- titan_plugin_git/steps/commit_step.py +80 -0
- titan_plugin_git/steps/push_step.py +63 -0
- titan_plugin_git/steps/status_step.py +59 -0
- titan_plugin_git/workflows/__previews__/__init__.py +1 -0
- titan_plugin_git/workflows/__previews__/commit_ai_preview.py +124 -0
- titan_plugin_git/workflows/commit-ai.yaml +28 -0
- titan_plugin_github/__init__.py +11 -0
- titan_plugin_github/agents/__init__.py +6 -0
- titan_plugin_github/agents/config_loader.py +130 -0
- titan_plugin_github/agents/issue_generator.py +353 -0
- titan_plugin_github/agents/pr_agent.py +528 -0
- titan_plugin_github/clients/__init__.py +8 -0
- titan_plugin_github/clients/github_client.py +1105 -0
- titan_plugin_github/config/__init__.py +0 -0
- titan_plugin_github/config/pr_agent.toml +85 -0
- titan_plugin_github/exceptions.py +28 -0
- titan_plugin_github/messages.py +88 -0
- titan_plugin_github/models.py +330 -0
- titan_plugin_github/plugin.py +131 -0
- titan_plugin_github/steps/__init__.py +12 -0
- titan_plugin_github/steps/ai_pr_step.py +172 -0
- titan_plugin_github/steps/create_pr_step.py +86 -0
- titan_plugin_github/steps/github_prompt_steps.py +171 -0
- titan_plugin_github/steps/issue_steps.py +143 -0
- titan_plugin_github/steps/preview_step.py +40 -0
- titan_plugin_github/utils.py +82 -0
- titan_plugin_github/workflows/__previews__/__init__.py +1 -0
- titan_plugin_github/workflows/__previews__/create_pr_ai_preview.py +140 -0
- titan_plugin_github/workflows/create-issue-ai.yaml +32 -0
- titan_plugin_github/workflows/create-pr-ai.yaml +49 -0
- titan_plugin_jira/__init__.py +8 -0
- titan_plugin_jira/agents/__init__.py +6 -0
- titan_plugin_jira/agents/config_loader.py +154 -0
- titan_plugin_jira/agents/jira_agent.py +553 -0
- titan_plugin_jira/agents/prompts.py +364 -0
- titan_plugin_jira/agents/response_parser.py +435 -0
- titan_plugin_jira/agents/token_tracker.py +223 -0
- titan_plugin_jira/agents/validators.py +246 -0
- titan_plugin_jira/clients/jira_client.py +745 -0
- titan_plugin_jira/config/jira_agent.toml +92 -0
- titan_plugin_jira/config/templates/issue_analysis.md.j2 +78 -0
- titan_plugin_jira/exceptions.py +37 -0
- titan_plugin_jira/formatters/__init__.py +6 -0
- titan_plugin_jira/formatters/markdown_formatter.py +245 -0
- titan_plugin_jira/messages.py +115 -0
- titan_plugin_jira/models.py +89 -0
- titan_plugin_jira/plugin.py +264 -0
- titan_plugin_jira/steps/ai_analyze_issue_step.py +105 -0
- titan_plugin_jira/steps/get_issue_step.py +82 -0
- titan_plugin_jira/steps/prompt_select_issue_step.py +80 -0
- titan_plugin_jira/steps/search_saved_query_step.py +238 -0
- titan_plugin_jira/utils/__init__.py +13 -0
- titan_plugin_jira/utils/issue_sorter.py +140 -0
- titan_plugin_jira/utils/saved_queries.py +150 -0
- titan_plugin_jira/workflows/analyze-jira-issues.yaml +34 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import List, Optional, Set, Protocol
|
|
6
|
+
import yaml
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PluginRegistryProtocol(Protocol):
|
|
11
|
+
"""Protocol defining the interface that PluginRegistry must implement for workflow sources."""
|
|
12
|
+
|
|
13
|
+
def list_installed(self) -> List[str]:
|
|
14
|
+
"""List successfully loaded plugins."""
|
|
15
|
+
...
|
|
16
|
+
|
|
17
|
+
def get_plugin(self, name: str):
|
|
18
|
+
"""Get plugin instance by name."""
|
|
19
|
+
...
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class WorkflowInfo:
|
|
23
|
+
"""Metadata about a discovered, but not yet parsed, workflow."""
|
|
24
|
+
name: str
|
|
25
|
+
description: str
|
|
26
|
+
source: str # "project", "user", "system", "plugin:github"
|
|
27
|
+
path: Path
|
|
28
|
+
category: Optional[str] = None
|
|
29
|
+
required_plugins: Set[str] = field(default_factory=set)
|
|
30
|
+
|
|
31
|
+
def _parse_workflow_info(file: Path, source_name: str, plugin_registry: PluginRegistryProtocol) -> WorkflowInfo:
|
|
32
|
+
"""
|
|
33
|
+
Helper to extract metadata and plugin dependencies from a workflow file.
|
|
34
|
+
Does not resolve 'extends' or nested 'workflow' calls to keep discovery fast.
|
|
35
|
+
"""
|
|
36
|
+
try:
|
|
37
|
+
with open(file, 'r', encoding='utf-8') as f:
|
|
38
|
+
config = yaml.safe_load(f) or {}
|
|
39
|
+
except Exception:
|
|
40
|
+
config = {}
|
|
41
|
+
|
|
42
|
+
required_plugins: Set[str] = set()
|
|
43
|
+
|
|
44
|
+
# Check direct plugin dependencies in steps
|
|
45
|
+
steps = config.get("steps", [])
|
|
46
|
+
if isinstance(steps, list):
|
|
47
|
+
for step in steps:
|
|
48
|
+
if isinstance(step, dict) and "plugin" in step and step["plugin"] not in ["core", "project"]:
|
|
49
|
+
required_plugins.add(step["plugin"])
|
|
50
|
+
|
|
51
|
+
# Check 'extends' field for plugin dependencies
|
|
52
|
+
extends_ref = config.get("extends")
|
|
53
|
+
if extends_ref and isinstance(extends_ref, str):
|
|
54
|
+
if extends_ref.startswith("plugin:"):
|
|
55
|
+
# Extract plugin name from "plugin:git/commit-ai" -> "git"
|
|
56
|
+
plugin_part = extends_ref.split(':', 1)[1]
|
|
57
|
+
plugin_name = plugin_part.split('/', 1)[0]
|
|
58
|
+
required_plugins.add(plugin_name)
|
|
59
|
+
|
|
60
|
+
return WorkflowInfo(
|
|
61
|
+
name=file.stem,
|
|
62
|
+
description=config.get("description", "No description available."),
|
|
63
|
+
source=source_name,
|
|
64
|
+
path=file,
|
|
65
|
+
category=config.get("category"),
|
|
66
|
+
required_plugins=required_plugins
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class WorkflowSource(ABC):
|
|
71
|
+
"""
|
|
72
|
+
Abstract base class for a source of workflows.
|
|
73
|
+
This pattern allows discovering workflows from the project, user's home,
|
|
74
|
+
system-wide, or from plugins, in a uniform way.
|
|
75
|
+
"""
|
|
76
|
+
def __init__(self, plugin_registry: PluginRegistryProtocol):
|
|
77
|
+
self._plugin_registry = plugin_registry
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
@abstractmethod
|
|
81
|
+
def name(self) -> str:
|
|
82
|
+
"""The unique name of the source (e.g., 'project', 'user', 'plugin:github')."""
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
@abstractmethod
|
|
86
|
+
def discover(self) -> List[WorkflowInfo]:
|
|
87
|
+
"""Discover all available workflows from this source."""
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
@abstractmethod
|
|
91
|
+
def find(self, name: str) -> Optional[Path]:
|
|
92
|
+
"""Find a specific workflow file by its name within this source."""
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
@abstractmethod
|
|
96
|
+
def contains(self, path: Path) -> bool:
|
|
97
|
+
"""Check if a given file path belongs to this source."""
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
class ProjectWorkflowSource(WorkflowSource):
|
|
101
|
+
"""
|
|
102
|
+
Represents workflows defined within the current project
|
|
103
|
+
at the conventional '.titan/workflows/' directory.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
def __init__(self, path: Path, plugin_registry: PluginRegistryProtocol):
|
|
107
|
+
super().__init__(plugin_registry)
|
|
108
|
+
self._path = path.resolve()
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def name(self) -> str:
|
|
112
|
+
return "project"
|
|
113
|
+
|
|
114
|
+
def discover(self) -> List[WorkflowInfo]:
|
|
115
|
+
"""Discovers all .yaml or .yml files in the project's workflow directory."""
|
|
116
|
+
if not self._path.is_dir():
|
|
117
|
+
return []
|
|
118
|
+
|
|
119
|
+
workflows = []
|
|
120
|
+
for file in self._path.glob("*.yaml"):
|
|
121
|
+
workflows.append(self._to_workflow_info(file))
|
|
122
|
+
for file in self._path.glob("*.yml"):
|
|
123
|
+
if file.stem not in [w.name for w in workflows]: # Avoid duplicates if both .yaml and .yml exist
|
|
124
|
+
workflows.append(self._to_workflow_info(file))
|
|
125
|
+
return workflows
|
|
126
|
+
|
|
127
|
+
def find(self, name: str) -> Optional[Path]:
|
|
128
|
+
"""Finds a workflow by name in the project directory."""
|
|
129
|
+
yaml_file = self._path / f"{name}.yaml"
|
|
130
|
+
if yaml_file.is_file():
|
|
131
|
+
return yaml_file
|
|
132
|
+
|
|
133
|
+
yml_file = self._path / f"{name}.yml"
|
|
134
|
+
if yml_file.is_file():
|
|
135
|
+
return yml_file
|
|
136
|
+
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
def contains(self, path: Path) -> bool:
|
|
140
|
+
"""Checks if the given path is within this project's workflow directory."""
|
|
141
|
+
try:
|
|
142
|
+
path.resolve().relative_to(self._path)
|
|
143
|
+
return True
|
|
144
|
+
except ValueError:
|
|
145
|
+
return False
|
|
146
|
+
|
|
147
|
+
def _to_workflow_info(self, file: Path) -> WorkflowInfo:
|
|
148
|
+
"""Helper to extract metadata from a workflow file."""
|
|
149
|
+
return _parse_workflow_info(file, self.name, self._plugin_registry)
|
|
150
|
+
|
|
151
|
+
class UserWorkflowSource(WorkflowSource):
|
|
152
|
+
"""
|
|
153
|
+
Represents workflows defined by the user in their home directory
|
|
154
|
+
at '~/.titan/workflows/'.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
def __init__(self, path: Path, plugin_registry: PluginRegistryProtocol):
|
|
158
|
+
super().__init__(plugin_registry)
|
|
159
|
+
self._path = path.expanduser().resolve()
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def name(self) -> str:
|
|
163
|
+
return "user"
|
|
164
|
+
|
|
165
|
+
def discover(self) -> List[WorkflowInfo]:
|
|
166
|
+
if not self._path.is_dir():
|
|
167
|
+
return []
|
|
168
|
+
|
|
169
|
+
workflows = []
|
|
170
|
+
for file in self._path.glob("*.yaml"):
|
|
171
|
+
workflows.append(self._to_workflow_info(file))
|
|
172
|
+
for file in self._path.glob("*.yml"):
|
|
173
|
+
if file.stem not in [w.name for w in workflows]:
|
|
174
|
+
workflows.append(self._to_workflow_info(file))
|
|
175
|
+
return workflows
|
|
176
|
+
|
|
177
|
+
def find(self, name: str) -> Optional[Path]:
|
|
178
|
+
yaml_file = self._path / f"{name}.yaml"
|
|
179
|
+
if yaml_file.is_file():
|
|
180
|
+
return yaml_file
|
|
181
|
+
yml_file = self._path / f"{name}.yml"
|
|
182
|
+
if yml_file.is_file():
|
|
183
|
+
return yml_file
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
def contains(self, path: Path) -> bool:
|
|
187
|
+
try:
|
|
188
|
+
path.resolve().relative_to(self._path)
|
|
189
|
+
return True
|
|
190
|
+
except ValueError:
|
|
191
|
+
return False
|
|
192
|
+
|
|
193
|
+
def _to_workflow_info(self, file: Path) -> WorkflowInfo:
|
|
194
|
+
return _parse_workflow_info(file, self.name, self._plugin_registry)
|
|
195
|
+
|
|
196
|
+
class SystemWorkflowSource(WorkflowSource):
|
|
197
|
+
"""
|
|
198
|
+
Represents workflows bundled with the Titan CLI itself,
|
|
199
|
+
typically found in a 'workflows' directory within the installed package.
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
def __init__(self, path: Path, plugin_registry: PluginRegistryProtocol):
|
|
203
|
+
super().__init__(plugin_registry)
|
|
204
|
+
self._path = path.resolve()
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def name(self) -> str:
|
|
208
|
+
return "system"
|
|
209
|
+
|
|
210
|
+
def discover(self) -> List[WorkflowInfo]:
|
|
211
|
+
if not self._path.is_dir():
|
|
212
|
+
return []
|
|
213
|
+
|
|
214
|
+
workflows = []
|
|
215
|
+
for file in self._path.glob("*.yaml"):
|
|
216
|
+
workflows.append(self._to_workflow_info(file))
|
|
217
|
+
for file in self._path.glob("*.yml"):
|
|
218
|
+
if file.stem not in [w.name for w in workflows]:
|
|
219
|
+
workflows.append(self._to_workflow_info(file))
|
|
220
|
+
return workflows
|
|
221
|
+
|
|
222
|
+
def find(self, name: str) -> Optional[Path]:
|
|
223
|
+
yaml_file = self._path / f"{name}.yaml"
|
|
224
|
+
if yaml_file.is_file():
|
|
225
|
+
return yaml_file
|
|
226
|
+
yml_file = self._path / f"{name}.yml"
|
|
227
|
+
if yml_file.is_file():
|
|
228
|
+
return yml_file
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
def contains(self, path: Path) -> bool:
|
|
232
|
+
try:
|
|
233
|
+
path.resolve().relative_to(self._path)
|
|
234
|
+
return True
|
|
235
|
+
except ValueError:
|
|
236
|
+
return False
|
|
237
|
+
|
|
238
|
+
def _to_workflow_info(self, file: Path) -> WorkflowInfo:
|
|
239
|
+
return _parse_workflow_info(file, self.name, self._plugin_registry)
|
|
240
|
+
|
|
241
|
+
class PluginWorkflowSource(WorkflowSource):
|
|
242
|
+
"""
|
|
243
|
+
Represents workflows provided by installed Titan plugins.
|
|
244
|
+
Discovers workflows via the `workflows_path` property of `TitanPlugin` instances.
|
|
245
|
+
"""
|
|
246
|
+
|
|
247
|
+
def __init__(self, plugin_registry: PluginRegistryProtocol): # Remove registry here
|
|
248
|
+
super().__init__(plugin_registry)
|
|
249
|
+
self._plugin_registry = plugin_registry
|
|
250
|
+
|
|
251
|
+
@property
|
|
252
|
+
def name(self) -> str:
|
|
253
|
+
return "plugin"
|
|
254
|
+
|
|
255
|
+
def discover(self) -> List[WorkflowInfo]:
|
|
256
|
+
workflows = []
|
|
257
|
+
for plugin_name in self._plugin_registry.list_installed():
|
|
258
|
+
plugin_instance = self._plugin_registry.get_plugin(plugin_name)
|
|
259
|
+
if plugin_instance and plugin_instance.workflows_path:
|
|
260
|
+
plugin_workflows_dir = plugin_instance.workflows_path
|
|
261
|
+
if plugin_workflows_dir.is_dir():
|
|
262
|
+
for file in plugin_workflows_dir.glob("*.yaml"):
|
|
263
|
+
workflows.append(self._to_workflow_info(file, plugin_name))
|
|
264
|
+
for file in plugin_workflows_dir.glob("*.yml"):
|
|
265
|
+
if file.stem not in [w.name for w in workflows]:
|
|
266
|
+
workflows.append(self._to_workflow_info(file, plugin_name))
|
|
267
|
+
return workflows
|
|
268
|
+
|
|
269
|
+
def find(self, name: str) -> Optional[Path]:
|
|
270
|
+
# Handle qualified names like "github/create-pr"
|
|
271
|
+
if "/" in name:
|
|
272
|
+
plugin_name_ref, workflow_name = name.split('/', 1)
|
|
273
|
+
plugin_instance = self._plugin_registry.get_plugin(plugin_name_ref)
|
|
274
|
+
if plugin_instance and plugin_instance.workflows_path:
|
|
275
|
+
plugin_workflows_dir = plugin_instance.workflows_path
|
|
276
|
+
yaml_file = plugin_workflows_dir / f"{workflow_name}.yaml"
|
|
277
|
+
if yaml_file.is_file():
|
|
278
|
+
return yaml_file
|
|
279
|
+
yml_file = plugin_workflows_dir / f"{workflow_name}.yml"
|
|
280
|
+
if yml_file.is_file():
|
|
281
|
+
return yml_file
|
|
282
|
+
return None # If qualified name is used, only search that plugin
|
|
283
|
+
|
|
284
|
+
# Fallback to original behavior for unqualified names
|
|
285
|
+
for plugin_name in self._plugin_registry.list_installed():
|
|
286
|
+
plugin_instance = self._plugin_registry.get_plugin(plugin_name)
|
|
287
|
+
if plugin_instance and plugin_instance.workflows_path:
|
|
288
|
+
plugin_workflows_dir = plugin_instance.workflows_path
|
|
289
|
+
yaml_file = plugin_workflows_dir / f"{name}.yaml"
|
|
290
|
+
if yaml_file.is_file():
|
|
291
|
+
return yaml_file
|
|
292
|
+
yml_file = plugin_workflows_dir / f"{name}.yml"
|
|
293
|
+
if yml_file.is_file():
|
|
294
|
+
return yml_file
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
def contains(self, path: Path) -> bool:
|
|
298
|
+
# This is complex to determine definitively without iterating all plugins.
|
|
299
|
+
# For simplicity, we can assume if a path contains 'plugins' in its parts, it might be a plugin workflow.
|
|
300
|
+
# A more robust check would involve checking against all known plugin_instance.workflows_path.
|
|
301
|
+
return "plugins" in path.parts # Heuristic, might need refinement
|
|
302
|
+
|
|
303
|
+
def _to_workflow_info(self, file: Path, plugin_name: str) -> WorkflowInfo:
|
|
304
|
+
info = _parse_workflow_info(file, f"plugin:{plugin_name}", self._plugin_registry) # Pass plugin_registry
|
|
305
|
+
# For plugin workflows, the name is qualified, e.g., "github/create-pr"
|
|
306
|
+
# but the file.stem is just "create-pr". We'll handle this in the registry.
|
|
307
|
+
return info
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Titan CLI Workflow Engine
|
|
3
|
+
|
|
4
|
+
This module provides the execution engine for composing and running workflows
|
|
5
|
+
using the Atomic Steps Pattern.
|
|
6
|
+
|
|
7
|
+
Core components:
|
|
8
|
+
- WorkflowResult types (Success, Error, Skip)
|
|
9
|
+
- WorkflowContext for dependency injection
|
|
10
|
+
- WorkflowContextBuilder for fluent API
|
|
11
|
+
- WorkflowExecutor for executing YAML workflows (see workflow_executor.py)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .results import (
|
|
15
|
+
WorkflowResult,
|
|
16
|
+
Success,
|
|
17
|
+
Error,
|
|
18
|
+
Skip,
|
|
19
|
+
is_success,
|
|
20
|
+
is_error,
|
|
21
|
+
is_skip,
|
|
22
|
+
)
|
|
23
|
+
from .context import WorkflowContext
|
|
24
|
+
from .builder import WorkflowContextBuilder
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
# Result types
|
|
28
|
+
"WorkflowResult",
|
|
29
|
+
"Success",
|
|
30
|
+
"Error",
|
|
31
|
+
"Skip",
|
|
32
|
+
# Helper functions
|
|
33
|
+
"is_success",
|
|
34
|
+
"is_error",
|
|
35
|
+
"is_skip",
|
|
36
|
+
# Context & builder
|
|
37
|
+
"WorkflowContext",
|
|
38
|
+
"WorkflowContextBuilder",
|
|
39
|
+
]
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WorkflowContextBuilder - Fluent API for building WorkflowContext.
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Optional, Any
|
|
7
|
+
|
|
8
|
+
from titan_cli.core.plugins.plugin_registry import PluginRegistry
|
|
9
|
+
from titan_cli.core.models import AIConfig
|
|
10
|
+
from titan_cli.core.secrets import SecretManager
|
|
11
|
+
from .context import WorkflowContext
|
|
12
|
+
from titan_cli.ai.client import AIClient
|
|
13
|
+
from titan_cli.ai.exceptions import AIConfigurationError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class WorkflowContextBuilder:
|
|
17
|
+
"""
|
|
18
|
+
Fluent builder for WorkflowContext.
|
|
19
|
+
|
|
20
|
+
Example:
|
|
21
|
+
plugin_registry = PluginRegistry()
|
|
22
|
+
secrets = SecretManager()
|
|
23
|
+
ai_config = AIConfig(provider="anthropic", model="claude-3-haiku-20240307")
|
|
24
|
+
ctx = WorkflowContextBuilder(plugin_registry, secrets, ai_config) \\
|
|
25
|
+
.with_ai() \\
|
|
26
|
+
.build()
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
plugin_registry: PluginRegistry,
|
|
32
|
+
secrets: SecretManager,
|
|
33
|
+
ai_config: Optional[AIConfig] = None
|
|
34
|
+
):
|
|
35
|
+
"""
|
|
36
|
+
Initialize builder.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
plugin_registry: The PluginRegistry instance.
|
|
40
|
+
secrets: The SecretManager instance.
|
|
41
|
+
ai_config: Optional AI configuration.
|
|
42
|
+
"""
|
|
43
|
+
self._plugin_registry = plugin_registry
|
|
44
|
+
self._secrets = secrets
|
|
45
|
+
self._ai_config = ai_config
|
|
46
|
+
|
|
47
|
+
# Service clients
|
|
48
|
+
self._ai = None
|
|
49
|
+
self._git = None
|
|
50
|
+
self._github = None
|
|
51
|
+
self._jira = None
|
|
52
|
+
|
|
53
|
+
def with_ai(self, ai_client: Optional[Any] = None) -> WorkflowContextBuilder:
|
|
54
|
+
"""
|
|
55
|
+
Add AI client.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
ai_client: Optional AIClient instance (auto-created if None)
|
|
59
|
+
"""
|
|
60
|
+
if ai_client:
|
|
61
|
+
# DI pure
|
|
62
|
+
self._ai = ai_client
|
|
63
|
+
else:
|
|
64
|
+
# Convenience - auto-create from ai_config
|
|
65
|
+
if self._ai_config:
|
|
66
|
+
try:
|
|
67
|
+
self._ai = AIClient(self._ai_config, self._secrets)
|
|
68
|
+
except AIConfigurationError:
|
|
69
|
+
self._ai = None
|
|
70
|
+
else:
|
|
71
|
+
self._ai = None
|
|
72
|
+
return self
|
|
73
|
+
|
|
74
|
+
def with_git(self, git_client: Optional[Any] = None) -> "WorkflowContextBuilder":
|
|
75
|
+
"""
|
|
76
|
+
Add Git client.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
git_client: Optional GitClient instance (auto-created if None)
|
|
80
|
+
"""
|
|
81
|
+
if git_client:
|
|
82
|
+
self._git = git_client
|
|
83
|
+
else:
|
|
84
|
+
# Auto-create from plugin registry
|
|
85
|
+
git_plugin = self._plugin_registry.get_plugin("git")
|
|
86
|
+
if git_plugin and git_plugin.is_available():
|
|
87
|
+
try:
|
|
88
|
+
self._git = git_plugin.get_client()
|
|
89
|
+
except Exception: # Catch any exception during client retrieval
|
|
90
|
+
self._git = None # Fail silently
|
|
91
|
+
else:
|
|
92
|
+
self._git = None
|
|
93
|
+
return self
|
|
94
|
+
|
|
95
|
+
def with_github(self, github_client: Optional[Any] = None) -> "WorkflowContextBuilder":
|
|
96
|
+
"""
|
|
97
|
+
Add GitHub client.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
github_client: Optional GitHubClient instance (auto-loaded if None)
|
|
101
|
+
"""
|
|
102
|
+
if github_client:
|
|
103
|
+
self._github = github_client
|
|
104
|
+
else:
|
|
105
|
+
# Auto-create from plugin registry
|
|
106
|
+
github_plugin = self._plugin_registry.get_plugin("github")
|
|
107
|
+
if github_plugin and github_plugin.is_available():
|
|
108
|
+
try:
|
|
109
|
+
self._github = github_plugin.get_client()
|
|
110
|
+
except Exception: # Catch any exception during client retrieval
|
|
111
|
+
self._github = None # Fail silently
|
|
112
|
+
else:
|
|
113
|
+
self._github = None
|
|
114
|
+
return self
|
|
115
|
+
|
|
116
|
+
def with_jira(self, jira_client: Optional[Any] = None) -> "WorkflowContextBuilder":
|
|
117
|
+
"""
|
|
118
|
+
Add JIRA client to workflow context.
|
|
119
|
+
|
|
120
|
+
The JIRA client is optional and only used by JIRA plugin steps.
|
|
121
|
+
Other plugin steps will have ctx.jira = None and should ignore it.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
jira_client: Optional JiraClient instance (auto-loaded if None).
|
|
125
|
+
If None, attempts to load from JIRA plugin registry.
|
|
126
|
+
If plugin is not available or fails to load, sets ctx.jira = None.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
Self for method chaining
|
|
130
|
+
|
|
131
|
+
Note:
|
|
132
|
+
Steps from other plugins do not need to handle ctx.jira.
|
|
133
|
+
Only JIRA plugin steps should check for and use ctx.jira.
|
|
134
|
+
"""
|
|
135
|
+
if jira_client:
|
|
136
|
+
self._jira = jira_client
|
|
137
|
+
else:
|
|
138
|
+
# Auto-create from plugin registry
|
|
139
|
+
jira_plugin = self._plugin_registry.get_plugin("jira")
|
|
140
|
+
if jira_plugin and jira_plugin.is_available():
|
|
141
|
+
try:
|
|
142
|
+
self._jira = jira_plugin.get_client()
|
|
143
|
+
except Exception: # Catch any exception during client retrieval
|
|
144
|
+
self._jira = None # Fail silently
|
|
145
|
+
else:
|
|
146
|
+
self._jira = None
|
|
147
|
+
return self
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def build(self) -> WorkflowContext:
|
|
151
|
+
"""Build the WorkflowContext."""
|
|
152
|
+
return WorkflowContext(
|
|
153
|
+
secrets=self._secrets,
|
|
154
|
+
plugin_manager=self._plugin_registry,
|
|
155
|
+
ai=self._ai,
|
|
156
|
+
git=self._git,
|
|
157
|
+
github=self._github,
|
|
158
|
+
jira=self._jira,
|
|
159
|
+
)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WorkflowContext - Dependency injection container for workflows.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Optional, Dict, Any, List
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
from titan_cli.core.secrets import SecretManager
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class WorkflowContext:
|
|
13
|
+
"""
|
|
14
|
+
Context container for workflow execution.
|
|
15
|
+
|
|
16
|
+
Provides:
|
|
17
|
+
- Dependency injection (clients, services)
|
|
18
|
+
- Shared data storage between steps
|
|
19
|
+
- Textual TUI components
|
|
20
|
+
- Access to secrets
|
|
21
|
+
|
|
22
|
+
UI Architecture:
|
|
23
|
+
ctx.textual.text # Textual TUI components
|
|
24
|
+
ctx.textual.panel
|
|
25
|
+
ctx.textual.spacer
|
|
26
|
+
ctx.textual.prompts
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
# Core dependencies
|
|
30
|
+
secrets: SecretManager
|
|
31
|
+
|
|
32
|
+
# Textual TUI components (for TUI mode)
|
|
33
|
+
textual: Optional[Any] = None
|
|
34
|
+
|
|
35
|
+
# Plugin registry
|
|
36
|
+
plugin_manager: Optional[Any] = None
|
|
37
|
+
|
|
38
|
+
# Service clients (populated by builder)
|
|
39
|
+
ai: Optional[Any] = None
|
|
40
|
+
git: Optional[Any] = None
|
|
41
|
+
github: Optional[Any] = None
|
|
42
|
+
jira: Optional[Any] = None
|
|
43
|
+
|
|
44
|
+
# Workflow metadata (set by executor)
|
|
45
|
+
workflow_name: Optional[str] = None
|
|
46
|
+
current_step: Optional[int] = None
|
|
47
|
+
total_steps: Optional[int] = None
|
|
48
|
+
|
|
49
|
+
# Shared data storage between steps
|
|
50
|
+
data: Dict[str, Any] = field(default_factory=dict)
|
|
51
|
+
|
|
52
|
+
# Internal state for workflow execution
|
|
53
|
+
_workflow_stack: List[str] = field(default_factory=list)
|
|
54
|
+
|
|
55
|
+
def set(self, key: str, value: Any) -> None:
|
|
56
|
+
"""Set shared data."""
|
|
57
|
+
self.data[key] = value
|
|
58
|
+
|
|
59
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
60
|
+
"""Get shared data."""
|
|
61
|
+
return self.data.get(key, default)
|
|
62
|
+
|
|
63
|
+
def has(self, key: str) -> bool:
|
|
64
|
+
"""Check if key exists in shared data."""
|
|
65
|
+
return key in self.data
|
|
66
|
+
|
|
67
|
+
def enter_workflow(self, workflow_name: str):
|
|
68
|
+
"""
|
|
69
|
+
Track entering a workflow execution to prevent circular dependencies.
|
|
70
|
+
"""
|
|
71
|
+
if workflow_name in self._workflow_stack:
|
|
72
|
+
raise ValueError(
|
|
73
|
+
f"Circular workflow dependency detected: {' -> '.join(self._workflow_stack)} -> {workflow_name}"
|
|
74
|
+
)
|
|
75
|
+
self._workflow_stack.append(workflow_name)
|
|
76
|
+
|
|
77
|
+
def exit_workflow(self, workflow_name: str):
|
|
78
|
+
"""
|
|
79
|
+
Track exiting a workflow execution.
|
|
80
|
+
"""
|
|
81
|
+
if self._workflow_stack and self._workflow_stack[-1] == workflow_name:
|
|
82
|
+
self._workflow_stack.pop()
|