titan-cli 0.1.4__py3-none-any.whl → 0.1.6__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/core/config.py +3 -1
- titan_cli/core/workflows/__init__.py +2 -1
- titan_cli/core/workflows/project_step_source.py +95 -32
- titan_cli/core/workflows/workflow_filter_service.py +16 -8
- titan_cli/core/workflows/workflow_registry.py +12 -1
- titan_cli/core/workflows/workflow_sources.py +1 -1
- titan_cli/engine/__init__.py +5 -1
- titan_cli/engine/results.py +31 -1
- titan_cli/engine/steps/ai_assistant_step.py +47 -12
- titan_cli/engine/workflow_executor.py +13 -3
- titan_cli/ui/tui/screens/plugin_config_wizard.py +16 -0
- titan_cli/ui/tui/screens/workflow_execution.py +28 -50
- titan_cli/ui/tui/screens/workflows.py +8 -4
- titan_cli/ui/tui/textual_components.py +342 -185
- titan_cli/ui/tui/textual_workflow_executor.py +39 -3
- titan_cli/ui/tui/theme.py +34 -5
- titan_cli/ui/tui/widgets/__init__.py +17 -0
- titan_cli/ui/tui/widgets/multiline_input.py +32 -0
- titan_cli/ui/tui/widgets/prompt_choice.py +138 -0
- titan_cli/ui/tui/widgets/prompt_input.py +74 -0
- titan_cli/ui/tui/widgets/prompt_selection_list.py +150 -0
- titan_cli/ui/tui/widgets/prompt_textarea.py +87 -0
- titan_cli/ui/tui/widgets/step_container.py +70 -0
- titan_cli/ui/tui/widgets/styled_option_list.py +107 -0
- titan_cli/ui/tui/widgets/text.py +51 -130
- {titan_cli-0.1.4.dist-info → titan_cli-0.1.6.dist-info}/METADATA +3 -5
- {titan_cli-0.1.4.dist-info → titan_cli-0.1.6.dist-info}/RECORD +61 -46
- titan_plugin_git/clients/git_client.py +140 -5
- titan_plugin_git/plugin.py +13 -0
- titan_plugin_git/steps/ai_commit_message_step.py +39 -34
- titan_plugin_git/steps/branch_steps.py +18 -37
- titan_plugin_git/steps/checkout_step.py +66 -0
- titan_plugin_git/steps/commit_step.py +18 -22
- titan_plugin_git/steps/create_branch_step.py +131 -0
- titan_plugin_git/steps/diff_summary_step.py +180 -0
- titan_plugin_git/steps/pull_step.py +70 -0
- titan_plugin_git/steps/push_step.py +27 -11
- titan_plugin_git/steps/restore_original_branch_step.py +97 -0
- titan_plugin_git/steps/save_current_branch_step.py +82 -0
- titan_plugin_git/steps/status_step.py +32 -25
- titan_plugin_git/workflows/commit-ai.yaml +9 -3
- titan_plugin_github/agents/pr_agent.py +15 -2
- titan_plugin_github/steps/ai_pr_step.py +99 -40
- titan_plugin_github/steps/create_pr_step.py +18 -8
- titan_plugin_github/steps/github_prompt_steps.py +53 -1
- titan_plugin_github/steps/issue_steps.py +31 -18
- titan_plugin_github/steps/preview_step.py +15 -4
- titan_plugin_github/utils.py +5 -4
- titan_plugin_github/workflows/create-pr-ai.yaml +6 -11
- titan_plugin_jira/messages.py +12 -0
- titan_plugin_jira/plugin.py +4 -0
- titan_plugin_jira/steps/ai_analyze_issue_step.py +12 -7
- titan_plugin_jira/steps/get_issue_step.py +17 -13
- titan_plugin_jira/steps/list_versions_step.py +133 -0
- titan_plugin_jira/steps/prompt_select_issue_step.py +20 -8
- titan_plugin_jira/steps/search_jql_step.py +191 -0
- titan_plugin_jira/steps/search_saved_query_step.py +26 -24
- titan_plugin_jira/utils/__init__.py +1 -1
- {titan_cli-0.1.4.dist-info → titan_cli-0.1.6.dist-info}/LICENSE +0 -0
- {titan_cli-0.1.4.dist-info → titan_cli-0.1.6.dist-info}/WHEEL +0 -0
- {titan_cli-0.1.4.dist-info → titan_cli-0.1.6.dist-info}/entry_points.txt +0 -0
titan_cli/core/config.py
CHANGED
|
@@ -4,7 +4,7 @@ from typing import Optional, List
|
|
|
4
4
|
import tomli
|
|
5
5
|
from .models import TitanConfigModel
|
|
6
6
|
from .plugins.plugin_registry import PluginRegistry
|
|
7
|
-
from .workflows import WorkflowRegistry, ProjectStepSource
|
|
7
|
+
from .workflows import WorkflowRegistry, ProjectStepSource, UserStepSource
|
|
8
8
|
from .secrets import SecretManager
|
|
9
9
|
from .errors import ConfigParseError, ConfigWriteError
|
|
10
10
|
|
|
@@ -75,10 +75,12 @@ class TitanConfig:
|
|
|
75
75
|
# Use current working directory for workflows
|
|
76
76
|
workflow_path = Path.cwd()
|
|
77
77
|
project_step_source = ProjectStepSource(project_root=workflow_path)
|
|
78
|
+
user_step_source = UserStepSource()
|
|
78
79
|
self._workflow_registry = WorkflowRegistry(
|
|
79
80
|
project_root=workflow_path,
|
|
80
81
|
plugin_registry=self.registry,
|
|
81
82
|
project_step_source=project_step_source,
|
|
83
|
+
user_step_source=user_step_source,
|
|
82
84
|
config=self
|
|
83
85
|
)
|
|
84
86
|
|
|
@@ -10,7 +10,7 @@ Similar to plugins system, but for workflows:
|
|
|
10
10
|
from .workflow_registry import WorkflowRegistry, ParsedWorkflow
|
|
11
11
|
from .workflow_sources import WorkflowInfo
|
|
12
12
|
from .workflow_exceptions import WorkflowNotFoundError, WorkflowExecutionError
|
|
13
|
-
from .project_step_source import ProjectStepSource
|
|
13
|
+
from .project_step_source import ProjectStepSource, UserStepSource
|
|
14
14
|
|
|
15
15
|
__all__ = [
|
|
16
16
|
"WorkflowRegistry",
|
|
@@ -19,4 +19,5 @@ __all__ = [
|
|
|
19
19
|
"WorkflowNotFoundError",
|
|
20
20
|
"WorkflowExecutionError",
|
|
21
21
|
"ProjectStepSource",
|
|
22
|
+
"UserStepSource",
|
|
22
23
|
]
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import importlib.util
|
|
2
|
+
import sys
|
|
2
3
|
from pathlib import Path
|
|
3
4
|
from typing import Callable, Dict, Any, Optional, List
|
|
4
5
|
from dataclasses import dataclass
|
|
@@ -17,21 +18,21 @@ class StepInfo:
|
|
|
17
18
|
name: str
|
|
18
19
|
path: Path
|
|
19
20
|
|
|
20
|
-
class
|
|
21
|
+
class BaseStepSource:
|
|
21
22
|
"""
|
|
22
|
-
|
|
23
|
+
Base class for discovering and loading Python step functions.
|
|
23
24
|
"""
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
EXCLUDED_FILES = {"__init__.py", "__pycache__"}
|
|
26
|
+
|
|
27
|
+
def __init__(self, steps_dir: Path):
|
|
28
|
+
self._steps_dir = steps_dir
|
|
27
29
|
self._step_info_cache: Optional[List[StepInfo]] = None
|
|
28
30
|
self._step_function_cache: Dict[str, StepFunction] = {}
|
|
29
31
|
|
|
30
|
-
EXCLUDED_FILES = {"__init__.py", "__pycache__"}
|
|
31
|
-
|
|
32
32
|
def discover(self) -> List[StepInfo]:
|
|
33
33
|
"""
|
|
34
34
|
Discovers all available step files in the project's .titan/steps directory.
|
|
35
|
+
Supports subdirectories (e.g., .titan/steps/jira/step.py).
|
|
35
36
|
"""
|
|
36
37
|
if self._step_info_cache is not None:
|
|
37
38
|
return self._step_info_cache
|
|
@@ -41,46 +42,108 @@ class ProjectStepSource:
|
|
|
41
42
|
return []
|
|
42
43
|
|
|
43
44
|
discovered = []
|
|
44
|
-
for step_file in self._steps_dir.glob("
|
|
45
|
-
if step_file.name not in self.EXCLUDED_FILES:
|
|
45
|
+
for step_file in self._steps_dir.glob("**/*.py"):
|
|
46
|
+
if step_file.name not in self.EXCLUDED_FILES and not any(part.startswith("__") for part in step_file.parts):
|
|
46
47
|
step_name = step_file.stem
|
|
47
48
|
discovered.append(StepInfo(name=step_name, path=step_file))
|
|
48
|
-
|
|
49
|
+
|
|
49
50
|
self._step_info_cache = discovered
|
|
50
51
|
return discovered
|
|
51
52
|
|
|
52
53
|
def get_step(self, step_name: str) -> Optional[StepFunction]:
|
|
53
54
|
"""
|
|
54
55
|
Retrieves a step function by name, loading it from its file if necessary.
|
|
56
|
+
Searches all Python files in the directory (including subdirectories) for the function.
|
|
55
57
|
"""
|
|
56
58
|
if step_name in self._step_function_cache:
|
|
57
59
|
return self._step_function_cache[step_name]
|
|
58
60
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
step_info = next((s for s in discovered_steps if s.name == step_name), None)
|
|
61
|
+
if not self._steps_dir.is_dir():
|
|
62
|
+
return None
|
|
62
63
|
|
|
63
|
-
|
|
64
|
+
# Search all Python files (including subdirectories) for the function
|
|
65
|
+
for step_file in self._steps_dir.glob("**/*.py"):
|
|
66
|
+
if step_file.name in self.EXCLUDED_FILES or any(part.startswith("__") for part in step_file.parts):
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
# Use a unique module name to avoid conflicts
|
|
71
|
+
module_name = f"_titan_step_{step_file.stem}_{id(step_file)}"
|
|
72
|
+
spec = importlib.util.spec_from_file_location(module_name, step_file)
|
|
73
|
+
if spec and spec.loader:
|
|
74
|
+
module = importlib.util.module_from_spec(spec)
|
|
75
|
+
spec.loader.exec_module(module)
|
|
76
|
+
|
|
77
|
+
# Look for the function in this module
|
|
78
|
+
step_func = getattr(module, step_name, None)
|
|
79
|
+
if callable(step_func):
|
|
80
|
+
self._step_function_cache[step_name] = step_func
|
|
81
|
+
return step_func
|
|
82
|
+
|
|
83
|
+
except Exception:
|
|
84
|
+
# Continue searching other files
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ProjectStepSource(BaseStepSource):
|
|
91
|
+
"""
|
|
92
|
+
Discovers and loads Python step functions from a project's .titan/steps/ directory.
|
|
93
|
+
Supports relative imports by properly setting module __package__ attribute.
|
|
94
|
+
"""
|
|
95
|
+
def __init__(self, project_root: Path):
|
|
96
|
+
steps_dir = project_root / ".titan" / "steps"
|
|
97
|
+
super().__init__(steps_dir)
|
|
98
|
+
self._project_root = project_root
|
|
99
|
+
self._titan_dir = project_root / ".titan"
|
|
100
|
+
|
|
101
|
+
# Add .titan directory to sys.path to enable absolute imports
|
|
102
|
+
titan_dir_str = str(self._titan_dir)
|
|
103
|
+
if titan_dir_str not in sys.path:
|
|
104
|
+
sys.path.insert(0, titan_dir_str)
|
|
105
|
+
|
|
106
|
+
def get_step(self, step_name: str) -> Optional[StepFunction]:
|
|
107
|
+
"""
|
|
108
|
+
Retrieves a step function by name.
|
|
109
|
+
Project steps should use absolute imports from .titan/ as the root.
|
|
110
|
+
"""
|
|
111
|
+
if step_name in self._step_function_cache:
|
|
112
|
+
return self._step_function_cache[step_name]
|
|
113
|
+
|
|
114
|
+
if not self._steps_dir.is_dir():
|
|
64
115
|
return None
|
|
65
116
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
#
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
117
|
+
# Search all Python files for the function
|
|
118
|
+
for step_file in self._steps_dir.glob("**/*.py"):
|
|
119
|
+
if step_file.name in self.EXCLUDED_FILES or any(part.startswith("__") for part in step_file.parts):
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
# Use a unique module name to avoid conflicts
|
|
124
|
+
module_name = f"_titan_step_{step_file.stem}_{id(step_file)}"
|
|
125
|
+
spec = importlib.util.spec_from_file_location(module_name, step_file)
|
|
126
|
+
if spec and spec.loader:
|
|
127
|
+
module = importlib.util.module_from_spec(spec)
|
|
128
|
+
spec.loader.exec_module(module)
|
|
129
|
+
|
|
130
|
+
# Look for the function
|
|
131
|
+
step_func = getattr(module, step_name, None)
|
|
132
|
+
if callable(step_func):
|
|
133
|
+
self._step_function_cache[step_name] = step_func
|
|
134
|
+
return step_func
|
|
135
|
+
|
|
136
|
+
except Exception:
|
|
137
|
+
# Continue searching other files
|
|
138
|
+
continue
|
|
84
139
|
|
|
85
140
|
return None
|
|
86
141
|
|
|
142
|
+
|
|
143
|
+
class UserStepSource(BaseStepSource):
|
|
144
|
+
"""
|
|
145
|
+
Discovers and loads Python step functions from user's ~/.titan/steps/ directory.
|
|
146
|
+
"""
|
|
147
|
+
def __init__(self):
|
|
148
|
+
steps_dir = Path.home() / ".titan" / "steps"
|
|
149
|
+
super().__init__(steps_dir)
|
|
@@ -36,15 +36,23 @@ class WorkflowFilterService:
|
|
|
36
36
|
plugin_name = wf_info.source.split(":", 1)[1]
|
|
37
37
|
return plugin_name.capitalize()
|
|
38
38
|
|
|
39
|
-
#
|
|
40
|
-
if wf_info.source
|
|
39
|
+
# User workflows always go to "Personal" category
|
|
40
|
+
if wf_info.source == "user":
|
|
41
|
+
return "Personal"
|
|
42
|
+
|
|
43
|
+
# For project workflows, check which plugin they use
|
|
44
|
+
if wf_info.source == "project":
|
|
41
45
|
if wf_info.required_plugins:
|
|
42
|
-
#
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
# Filter out core/project pseudo-plugins
|
|
47
|
+
real_plugins = [p for p in wf_info.required_plugins if p not in ["core", "project"]]
|
|
48
|
+
# If workflow uses multiple plugins, treat as custom/project workflow
|
|
49
|
+
if len(real_plugins) > 1:
|
|
50
|
+
return "Project"
|
|
51
|
+
elif len(real_plugins) == 1:
|
|
52
|
+
# Single plugin dependency
|
|
53
|
+
return real_plugins[0].capitalize()
|
|
54
|
+
# No plugin dependencies, it's a custom workflow
|
|
55
|
+
return "Custom"
|
|
48
56
|
|
|
49
57
|
# Fallback for other sources
|
|
50
58
|
return wf_info.source.capitalize()
|
|
@@ -7,7 +7,7 @@ from dataclasses import dataclass
|
|
|
7
7
|
from copy import deepcopy
|
|
8
8
|
|
|
9
9
|
from titan_cli.core.plugins.plugin_registry import PluginRegistry
|
|
10
|
-
from titan_cli.core.workflows.project_step_source import ProjectStepSource, StepFunction
|
|
10
|
+
from titan_cli.core.workflows.project_step_source import ProjectStepSource, UserStepSource, StepFunction
|
|
11
11
|
|
|
12
12
|
from .workflow_sources import (
|
|
13
13
|
WorkflowSource,
|
|
@@ -48,6 +48,7 @@ class WorkflowRegistry:
|
|
|
48
48
|
project_root: Path,
|
|
49
49
|
plugin_registry: PluginRegistry,
|
|
50
50
|
project_step_source: ProjectStepSource,
|
|
51
|
+
user_step_source: UserStepSource = None,
|
|
51
52
|
config: Any = None
|
|
52
53
|
):
|
|
53
54
|
"""
|
|
@@ -57,11 +58,13 @@ class WorkflowRegistry:
|
|
|
57
58
|
project_root: Root path of the current project.
|
|
58
59
|
plugin_registry: Registry of installed plugins.
|
|
59
60
|
project_step_source: Source for discovering project-specific steps.
|
|
61
|
+
user_step_source: Source for discovering user-specific steps (~/.titan/steps/).
|
|
60
62
|
config: TitanConfig instance (optional, for filtering by enabled plugins).
|
|
61
63
|
"""
|
|
62
64
|
self.project_root = project_root
|
|
63
65
|
self.plugin_registry = plugin_registry
|
|
64
66
|
self._project_step_source = project_step_source
|
|
67
|
+
self._user_step_source = user_step_source
|
|
65
68
|
self._config = config
|
|
66
69
|
|
|
67
70
|
# Define the base path for system workflows, assuming it's in the root of the package
|
|
@@ -416,4 +419,12 @@ class WorkflowRegistry:
|
|
|
416
419
|
"""
|
|
417
420
|
return self._project_step_source.get_step(step_name)
|
|
418
421
|
|
|
422
|
+
def get_user_step(self, step_name: str) -> Optional[StepFunction]:
|
|
423
|
+
"""
|
|
424
|
+
Retrieves a loaded user step function by its name from the user step source.
|
|
425
|
+
"""
|
|
426
|
+
if self._user_step_source:
|
|
427
|
+
return self._user_step_source.get_step(step_name)
|
|
428
|
+
return None
|
|
429
|
+
|
|
419
430
|
|
|
@@ -45,7 +45,7 @@ def _parse_workflow_info(file: Path, source_name: str, plugin_registry: PluginRe
|
|
|
45
45
|
steps = config.get("steps", [])
|
|
46
46
|
if isinstance(steps, list):
|
|
47
47
|
for step in steps:
|
|
48
|
-
if isinstance(step, dict) and "plugin" in step and step["plugin"] not in ["core", "project"]:
|
|
48
|
+
if isinstance(step, dict) and "plugin" in step and step["plugin"] not in ["core", "project", "user"]:
|
|
49
49
|
required_plugins.add(step["plugin"])
|
|
50
50
|
|
|
51
51
|
# Check 'extends' field for plugin dependencies
|
titan_cli/engine/__init__.py
CHANGED
|
@@ -5,7 +5,7 @@ This module provides the execution engine for composing and running workflows
|
|
|
5
5
|
using the Atomic Steps Pattern.
|
|
6
6
|
|
|
7
7
|
Core components:
|
|
8
|
-
- WorkflowResult types (Success, Error, Skip)
|
|
8
|
+
- WorkflowResult types (Success, Error, Skip, Exit)
|
|
9
9
|
- WorkflowContext for dependency injection
|
|
10
10
|
- WorkflowContextBuilder for fluent API
|
|
11
11
|
- WorkflowExecutor for executing YAML workflows (see workflow_executor.py)
|
|
@@ -16,9 +16,11 @@ from .results import (
|
|
|
16
16
|
Success,
|
|
17
17
|
Error,
|
|
18
18
|
Skip,
|
|
19
|
+
Exit,
|
|
19
20
|
is_success,
|
|
20
21
|
is_error,
|
|
21
22
|
is_skip,
|
|
23
|
+
is_exit,
|
|
22
24
|
)
|
|
23
25
|
from .context import WorkflowContext
|
|
24
26
|
from .builder import WorkflowContextBuilder
|
|
@@ -29,10 +31,12 @@ __all__ = [
|
|
|
29
31
|
"Success",
|
|
30
32
|
"Error",
|
|
31
33
|
"Skip",
|
|
34
|
+
"Exit",
|
|
32
35
|
# Helper functions
|
|
33
36
|
"is_success",
|
|
34
37
|
"is_error",
|
|
35
38
|
"is_skip",
|
|
39
|
+
"is_exit",
|
|
36
40
|
# Context & builder
|
|
37
41
|
"WorkflowContext",
|
|
38
42
|
"WorkflowContextBuilder",
|
titan_cli/engine/results.py
CHANGED
|
@@ -68,8 +68,33 @@ class Skip:
|
|
|
68
68
|
metadata: Optional[dict[str, Any]] = None
|
|
69
69
|
|
|
70
70
|
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class Exit:
|
|
73
|
+
"""
|
|
74
|
+
Exit the entire workflow early (not an error).
|
|
75
|
+
|
|
76
|
+
Use when the workflow should stop because it's not needed:
|
|
77
|
+
- No changes to commit
|
|
78
|
+
- Nothing to do
|
|
79
|
+
- Preconditions not met
|
|
80
|
+
|
|
81
|
+
This exits the ENTIRE workflow, not just the current step.
|
|
82
|
+
|
|
83
|
+
Attributes:
|
|
84
|
+
message: Why the workflow is exiting (required)
|
|
85
|
+
metadata: Metadata to auto-merge into ctx.data
|
|
86
|
+
|
|
87
|
+
Examples:
|
|
88
|
+
>>> if not has_changes:
|
|
89
|
+
>>> return Exit("No changes to commit")
|
|
90
|
+
>>> return Exit("Already up to date", metadata={"status": "clean"})
|
|
91
|
+
"""
|
|
92
|
+
message: str
|
|
93
|
+
metadata: Optional[dict[str, Any]] = None
|
|
94
|
+
|
|
95
|
+
|
|
71
96
|
# Type alias for workflow results
|
|
72
|
-
WorkflowResult = Union[Success, Error, Skip]
|
|
97
|
+
WorkflowResult = Union[Success, Error, Skip, Exit]
|
|
73
98
|
|
|
74
99
|
|
|
75
100
|
# ============================================================================
|
|
@@ -89,3 +114,8 @@ def is_error(result: WorkflowResult) -> bool:
|
|
|
89
114
|
def is_skip(result: WorkflowResult) -> bool:
|
|
90
115
|
"""Check if result is Skip."""
|
|
91
116
|
return isinstance(result, Skip)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def is_exit(result: WorkflowResult) -> bool:
|
|
120
|
+
"""Check if result is Exit."""
|
|
121
|
+
return isinstance(result, Exit)
|
|
@@ -8,7 +8,7 @@ Can be used after linting, testing, builds, or any step that produces
|
|
|
8
8
|
errors or context that could benefit from AI assistance.
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
|
-
import json
|
|
11
|
+
import json
|
|
12
12
|
|
|
13
13
|
from titan_cli.core.workflows.models import WorkflowStepModel
|
|
14
14
|
from titan_cli.engine.context import WorkflowContext
|
|
@@ -43,6 +43,9 @@ def execute_ai_assistant_step(step: WorkflowStepModel, ctx: WorkflowContext) ->
|
|
|
43
43
|
if not ctx.textual:
|
|
44
44
|
return Error(msg.AIAssistant.UI_CONTEXT_NOT_AVAILABLE)
|
|
45
45
|
|
|
46
|
+
# Begin step container - use step name from workflow
|
|
47
|
+
ctx.textual.begin_step(step.name or "AI Code Assistant")
|
|
48
|
+
|
|
46
49
|
# Get parameters
|
|
47
50
|
context_key = step.params.get("context_key")
|
|
48
51
|
prompt_template = step.params.get("prompt_template", "{context}")
|
|
@@ -53,17 +56,32 @@ def execute_ai_assistant_step(step: WorkflowStepModel, ctx: WorkflowContext) ->
|
|
|
53
56
|
# Validate cli_preference
|
|
54
57
|
VALID_CLI_PREFERENCES = {"auto", "claude", "gemini"}
|
|
55
58
|
if cli_preference not in VALID_CLI_PREFERENCES:
|
|
59
|
+
ctx.textual.error_text(f"Invalid cli_preference: {cli_preference}. Must be one of {VALID_CLI_PREFERENCES}")
|
|
60
|
+
ctx.textual.end_step("error")
|
|
56
61
|
return Error(f"Invalid cli_preference: {cli_preference}. Must be one of {VALID_CLI_PREFERENCES}")
|
|
57
62
|
|
|
58
63
|
# Validate required parameters
|
|
59
64
|
if not context_key:
|
|
65
|
+
ctx.textual.error_text(msg.AIAssistant.CONTEXT_KEY_REQUIRED)
|
|
66
|
+
ctx.textual.end_step("error")
|
|
60
67
|
return Error(msg.AIAssistant.CONTEXT_KEY_REQUIRED)
|
|
61
68
|
|
|
62
69
|
# Get context data
|
|
63
70
|
context_data = ctx.data.get(context_key)
|
|
64
71
|
if not context_data:
|
|
65
|
-
# No context to work with - skip silently
|
|
66
|
-
|
|
72
|
+
# No context to work with - skip silently with user-friendly message
|
|
73
|
+
# Infer what we're skipping based on step name
|
|
74
|
+
step_name = step.name or "AI Code Assistant"
|
|
75
|
+
if "lint" in step_name.lower():
|
|
76
|
+
friendly_msg = "No linting issues found - skipping AI assistance"
|
|
77
|
+
elif "test" in step_name.lower():
|
|
78
|
+
friendly_msg = "No test failures found - skipping AI assistance"
|
|
79
|
+
else:
|
|
80
|
+
friendly_msg = "No issues to fix - skipping AI assistance"
|
|
81
|
+
|
|
82
|
+
ctx.textual.dim_text(friendly_msg)
|
|
83
|
+
ctx.textual.end_step("skip")
|
|
84
|
+
return Skip(friendly_msg)
|
|
67
85
|
|
|
68
86
|
# Clear the context data immediately to prevent contamination of subsequent steps
|
|
69
87
|
if context_key in ctx.data:
|
|
@@ -78,8 +96,12 @@ def execute_ai_assistant_step(step: WorkflowStepModel, ctx: WorkflowContext) ->
|
|
|
78
96
|
context_str = json.dumps(context_data, indent=2)
|
|
79
97
|
prompt = prompt_template.format(context=context_str)
|
|
80
98
|
except KeyError as e:
|
|
99
|
+
ctx.textual.error_text(msg.AIAssistant.INVALID_PROMPT_TEMPLATE.format(e=e))
|
|
100
|
+
ctx.textual.end_step("error")
|
|
81
101
|
return Error(msg.AIAssistant.INVALID_PROMPT_TEMPLATE.format(e=e))
|
|
82
102
|
except Exception as e:
|
|
103
|
+
ctx.textual.error_text(msg.AIAssistant.FAILED_TO_BUILD_PROMPT.format(e=e))
|
|
104
|
+
ctx.textual.end_step("error")
|
|
83
105
|
return Error(msg.AIAssistant.FAILED_TO_BUILD_PROMPT.format(e=e))
|
|
84
106
|
|
|
85
107
|
# Ask for confirmation if needed
|
|
@@ -91,7 +113,11 @@ def execute_ai_assistant_step(step: WorkflowStepModel, ctx: WorkflowContext) ->
|
|
|
91
113
|
)
|
|
92
114
|
if not should_launch:
|
|
93
115
|
if fail_on_decline:
|
|
116
|
+
ctx.textual.warning_text(msg.AIAssistant.DECLINED_ASSISTANCE_STOPPED)
|
|
117
|
+
ctx.textual.end_step("error")
|
|
94
118
|
return Error(msg.AIAssistant.DECLINED_ASSISTANCE_STOPPED)
|
|
119
|
+
ctx.textual.dim_text(msg.AIAssistant.DECLINED_ASSISTANCE_SKIPPED)
|
|
120
|
+
ctx.textual.end_step("skip")
|
|
95
121
|
return Skip(msg.AIAssistant.DECLINED_ASSISTANCE_SKIPPED)
|
|
96
122
|
|
|
97
123
|
# Determine which CLI to use
|
|
@@ -116,8 +142,8 @@ def execute_ai_assistant_step(step: WorkflowStepModel, ctx: WorkflowContext) ->
|
|
|
116
142
|
available_launchers[cli_name] = launcher
|
|
117
143
|
|
|
118
144
|
if not available_launchers:
|
|
119
|
-
|
|
120
|
-
ctx.textual.
|
|
145
|
+
ctx.textual.warning_text(msg.AIAssistant.NO_ASSISTANT_CLI_FOUND)
|
|
146
|
+
ctx.textual.end_step("skip")
|
|
121
147
|
return Skip(msg.AIAssistant.NO_ASSISTANT_CLI_FOUND)
|
|
122
148
|
|
|
123
149
|
if len(available_launchers) == 1:
|
|
@@ -125,7 +151,7 @@ def execute_ai_assistant_step(step: WorkflowStepModel, ctx: WorkflowContext) ->
|
|
|
125
151
|
else:
|
|
126
152
|
# Show available CLIs with numbers
|
|
127
153
|
ctx.textual.text("") # spacing
|
|
128
|
-
ctx.textual.
|
|
154
|
+
ctx.textual.bold_primary_text(msg.AIAssistant.SELECT_ASSISTANT_CLI)
|
|
129
155
|
|
|
130
156
|
cli_options = list(available_launchers.keys())
|
|
131
157
|
for idx, cli_name in enumerate(cli_options, 1):
|
|
@@ -136,6 +162,8 @@ def execute_ai_assistant_step(step: WorkflowStepModel, ctx: WorkflowContext) ->
|
|
|
136
162
|
choice_str = ctx.textual.ask_text("Select option (or press Enter to cancel):", default="")
|
|
137
163
|
|
|
138
164
|
if not choice_str or choice_str.strip() == "":
|
|
165
|
+
ctx.textual.dim_text(msg.AIAssistant.DECLINED_ASSISTANCE_SKIPPED)
|
|
166
|
+
ctx.textual.end_step("skip")
|
|
139
167
|
return Skip(msg.AIAssistant.DECLINED_ASSISTANCE_SKIPPED)
|
|
140
168
|
|
|
141
169
|
try:
|
|
@@ -143,28 +171,31 @@ def execute_ai_assistant_step(step: WorkflowStepModel, ctx: WorkflowContext) ->
|
|
|
143
171
|
if 0 <= choice_idx < len(cli_options):
|
|
144
172
|
cli_to_launch = cli_options[choice_idx]
|
|
145
173
|
else:
|
|
146
|
-
ctx.textual.
|
|
174
|
+
ctx.textual.error_text("Invalid option selected")
|
|
175
|
+
ctx.textual.end_step("skip")
|
|
147
176
|
return Skip(msg.AIAssistant.DECLINED_ASSISTANCE_SKIPPED)
|
|
148
177
|
except ValueError:
|
|
149
|
-
ctx.textual.
|
|
178
|
+
ctx.textual.error_text("Invalid input - must be a number")
|
|
179
|
+
ctx.textual.end_step("skip")
|
|
150
180
|
return Skip(msg.AIAssistant.DECLINED_ASSISTANCE_SKIPPED)
|
|
151
181
|
|
|
152
182
|
# Validate selection
|
|
153
183
|
if cli_to_launch not in available_launchers:
|
|
184
|
+
ctx.textual.error_text(f"Unknown CLI to launch: {cli_to_launch}")
|
|
185
|
+
ctx.textual.end_step("error")
|
|
154
186
|
return Error(f"Unknown CLI to launch: {cli_to_launch}")
|
|
155
187
|
|
|
156
188
|
cli_name = CLI_REGISTRY[cli_to_launch].get("display_name", cli_to_launch)
|
|
157
189
|
|
|
158
190
|
# Launch the CLI
|
|
159
|
-
from titan_cli.ui.tui.widgets import Panel
|
|
160
191
|
ctx.textual.text("") # spacing
|
|
161
|
-
ctx.textual.
|
|
192
|
+
ctx.textual.primary_text(msg.AIAssistant.LAUNCHING_ASSISTANT.format(cli_name=cli_name))
|
|
162
193
|
|
|
163
194
|
# Show prompt preview
|
|
164
195
|
prompt_preview_text = msg.AIAssistant.PROMPT_PREVIEW.format(
|
|
165
196
|
prompt_preview=f"{prompt[:100]}..." if len(prompt) > 100 else prompt
|
|
166
197
|
)
|
|
167
|
-
ctx.textual.
|
|
198
|
+
ctx.textual.dim_text(prompt_preview_text)
|
|
168
199
|
ctx.textual.text("") # spacing
|
|
169
200
|
|
|
170
201
|
project_root = ctx.get("project_root", ".")
|
|
@@ -177,9 +208,13 @@ def execute_ai_assistant_step(step: WorkflowStepModel, ctx: WorkflowContext) ->
|
|
|
177
208
|
)
|
|
178
209
|
|
|
179
210
|
ctx.textual.text("") # spacing
|
|
180
|
-
ctx.textual.
|
|
211
|
+
ctx.textual.success_text(msg.AIAssistant.BACK_IN_TITAN)
|
|
181
212
|
|
|
182
213
|
if exit_code != 0:
|
|
214
|
+
ctx.textual.warning_text(msg.AIAssistant.ASSISTANT_EXITED_WITH_CODE.format(cli_name=cli_name, exit_code=exit_code))
|
|
215
|
+
ctx.textual.end_step("error")
|
|
183
216
|
return Error(msg.AIAssistant.ASSISTANT_EXITED_WITH_CODE.format(cli_name=cli_name, exit_code=exit_code))
|
|
184
217
|
|
|
218
|
+
ctx.textual.success_text(msg.AIAssistant.ASSISTANT_EXITED_WITH_CODE.format(cli_name=cli_name, exit_code=exit_code))
|
|
219
|
+
ctx.textual.end_step("success")
|
|
185
220
|
return Success(msg.AIAssistant.ASSISTANT_EXITED_WITH_CODE.format(cli_name=cli_name, exit_code=exit_code), metadata={"ai_exit_code": exit_code})
|
|
@@ -4,7 +4,7 @@ from typing import Any, Dict, Optional
|
|
|
4
4
|
from titan_cli.core.workflows import ParsedWorkflow
|
|
5
5
|
from titan_cli.core.workflows.workflow_exceptions import WorkflowExecutionError
|
|
6
6
|
from titan_cli.engine.context import WorkflowContext
|
|
7
|
-
from titan_cli.engine.results import WorkflowResult, Success, Error, is_error, is_skip
|
|
7
|
+
from titan_cli.engine.results import WorkflowResult, Success, Error, is_error, is_skip, is_exit
|
|
8
8
|
from titan_cli.core.workflows.workflow_registry import WorkflowRegistry
|
|
9
9
|
from titan_cli.core.plugins.plugin_registry import PluginRegistry
|
|
10
10
|
from titan_cli.core.workflows.models import WorkflowStepModel
|
|
@@ -75,7 +75,12 @@ class WorkflowExecutor:
|
|
|
75
75
|
step_result = Error(f"An unexpected error occurred in step '{step_name}': {e}", e)
|
|
76
76
|
|
|
77
77
|
# Handle step result
|
|
78
|
-
if
|
|
78
|
+
if is_exit(step_result):
|
|
79
|
+
# Exit immediately - workflow is done (not an error)
|
|
80
|
+
if step_result.metadata:
|
|
81
|
+
ctx.data.update(step_result.metadata)
|
|
82
|
+
return Success(step_result.message, step_result.metadata)
|
|
83
|
+
elif is_error(step_result):
|
|
79
84
|
if step_config.on_error == "fail":
|
|
80
85
|
return Error(f"Workflow failed at step '{step_name}'", step_result.exception)
|
|
81
86
|
# else: on_error == "continue" - continue to next step
|
|
@@ -128,6 +133,11 @@ class WorkflowExecutor:
|
|
|
128
133
|
step_func = self._workflow_registry.get_project_step(step_func_name)
|
|
129
134
|
if not step_func:
|
|
130
135
|
return Error(f"Project step '{step_func_name}' not found in '.titan/steps/'.", WorkflowExecutionError(f"Project step '{step_func_name}' not found"))
|
|
136
|
+
elif plugin_name == "user":
|
|
137
|
+
# Handle virtual 'user' plugin for user-specific steps
|
|
138
|
+
step_func = self._workflow_registry.get_user_step(step_func_name)
|
|
139
|
+
if not step_func:
|
|
140
|
+
return Error(f"User step '{step_func_name}' not found in '~/.titan/steps/'.", WorkflowExecutionError(f"User step '{step_func_name}' not found"))
|
|
131
141
|
elif plugin_name == "core":
|
|
132
142
|
# Handle virtual 'core' plugin for built-in core steps
|
|
133
143
|
step_func = self.CORE_STEPS.get(step_func_name)
|
|
@@ -160,7 +170,7 @@ class WorkflowExecutor:
|
|
|
160
170
|
# Plugin and project steps receive only ctx (params are in ctx.data)
|
|
161
171
|
return step_func(ctx)
|
|
162
172
|
except Exception as e:
|
|
163
|
-
error_source = f"plugin '{plugin_name}'" if plugin_name not in ("project", "core") else f"{plugin_name} step"
|
|
173
|
+
error_source = f"plugin '{plugin_name}'" if plugin_name not in ("project", "user", "core") else f"{plugin_name} step"
|
|
164
174
|
return Error(f"Error executing step '{step_func_name}' from {error_source}: {e}", e)
|
|
165
175
|
|
|
166
176
|
|
|
@@ -567,6 +567,22 @@ class PluginConfigWizardScreen(BaseScreen):
|
|
|
567
567
|
# Save secrets
|
|
568
568
|
project_name = self.config.get_project_name()
|
|
569
569
|
for secret_key, secret_value in secrets_to_save.items():
|
|
570
|
+
# Clean up old secrets for this key to avoid conflicts
|
|
571
|
+
# This prevents accumulation of stale tokens from previous configurations
|
|
572
|
+
if self.plugin_name == "jira" and "api_token" in secret_key:
|
|
573
|
+
# Delete all possible old JIRA token variants
|
|
574
|
+
old_keys = [
|
|
575
|
+
secret_key, # Generic: jira_api_token
|
|
576
|
+
f"{project_name}_{secret_key}" if project_name else None, # Project-specific
|
|
577
|
+
]
|
|
578
|
+
for old_key in old_keys:
|
|
579
|
+
if old_key:
|
|
580
|
+
try:
|
|
581
|
+
self.config.secrets.delete(old_key, scope="user")
|
|
582
|
+
except Exception:
|
|
583
|
+
pass # Ignore errors if key doesn't exist
|
|
584
|
+
|
|
585
|
+
# Save the new secret
|
|
570
586
|
keychain_key = f"{project_name}_{secret_key}" if project_name else secret_key
|
|
571
587
|
self.config.secrets.set(keychain_key, secret_value, scope="user")
|
|
572
588
|
|