titan-cli 0.1.3__py3-none-any.whl → 0.1.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- titan_cli/core/config.py +3 -1
- titan_cli/core/plugins/models.py +35 -7
- titan_cli/core/plugins/plugin_registry.py +11 -2
- titan_cli/core/workflows/__init__.py +2 -1
- titan_cli/core/workflows/project_step_source.py +48 -30
- titan_cli/core/workflows/workflow_filter_service.py +14 -8
- titan_cli/core/workflows/workflow_registry.py +12 -1
- titan_cli/core/workflows/workflow_sources.py +1 -1
- titan_cli/engine/steps/ai_assistant_step.py +42 -7
- titan_cli/engine/workflow_executor.py +6 -1
- titan_cli/ui/tui/screens/plugin_config_wizard.py +40 -9
- titan_cli/ui/tui/screens/workflow_execution.py +8 -28
- titan_cli/ui/tui/textual_components.py +59 -6
- titan_cli/ui/tui/textual_workflow_executor.py +9 -1
- titan_cli/ui/tui/widgets/__init__.py +2 -0
- titan_cli/ui/tui/widgets/step_container.py +70 -0
- {titan_cli-0.1.3.dist-info → titan_cli-0.1.5.dist-info}/METADATA +6 -3
- {titan_cli-0.1.3.dist-info → titan_cli-0.1.5.dist-info}/RECORD +42 -40
- {titan_cli-0.1.3.dist-info → titan_cli-0.1.5.dist-info}/WHEEL +1 -1
- titan_plugin_git/clients/git_client.py +82 -4
- titan_plugin_git/plugin.py +3 -0
- titan_plugin_git/steps/ai_commit_message_step.py +33 -28
- titan_plugin_git/steps/branch_steps.py +18 -37
- titan_plugin_git/steps/commit_step.py +18 -22
- titan_plugin_git/steps/diff_summary_step.py +182 -0
- titan_plugin_git/steps/push_step.py +27 -11
- titan_plugin_git/steps/status_step.py +15 -18
- titan_plugin_git/workflows/commit-ai.yaml +5 -0
- titan_plugin_github/agents/pr_agent.py +15 -2
- titan_plugin_github/steps/ai_pr_step.py +12 -21
- titan_plugin_github/steps/create_pr_step.py +17 -7
- titan_plugin_github/steps/github_prompt_steps.py +52 -0
- titan_plugin_github/steps/issue_steps.py +28 -14
- titan_plugin_github/steps/preview_step.py +11 -0
- titan_plugin_github/utils.py +5 -4
- titan_plugin_github/workflows/create-pr-ai.yaml +5 -0
- titan_plugin_jira/steps/ai_analyze_issue_step.py +8 -3
- titan_plugin_jira/steps/get_issue_step.py +16 -12
- titan_plugin_jira/steps/prompt_select_issue_step.py +22 -9
- titan_plugin_jira/steps/search_saved_query_step.py +21 -19
- {titan_cli-0.1.3.dist-info → titan_cli-0.1.5.dist-info}/entry_points.txt +0 -0
- {titan_cli-0.1.3.dist-info → titan_cli-0.1.5.dist-info/licenses}/LICENSE +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
|
|
titan_cli/core/plugins/models.py
CHANGED
|
@@ -30,15 +30,43 @@ class JiraPluginConfig(BaseModel):
|
|
|
30
30
|
Credentials (base_url, email, api_token) should be configured at global level (~/.titan/config.toml).
|
|
31
31
|
Project-specific settings (default_project) can override at project level (.titan/config.toml).
|
|
32
32
|
"""
|
|
33
|
-
base_url: Optional[str] = Field(
|
|
34
|
-
|
|
33
|
+
base_url: Optional[str] = Field(
|
|
34
|
+
None,
|
|
35
|
+
description="JIRA instance URL (e.g., 'https://jira.company.com')",
|
|
36
|
+
json_schema_extra={"config_scope": "global"}
|
|
37
|
+
)
|
|
38
|
+
email: Optional[str] = Field(
|
|
39
|
+
None,
|
|
40
|
+
description="User email for authentication",
|
|
41
|
+
json_schema_extra={"config_scope": "global"}
|
|
42
|
+
)
|
|
35
43
|
# api_token is stored in secrets, not in config.toml
|
|
36
44
|
# It appears in the JSON schema for interactive configuration but is optional in the model
|
|
37
|
-
api_token: Optional[str] = Field(
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
45
|
+
api_token: Optional[str] = Field(
|
|
46
|
+
None,
|
|
47
|
+
description="JIRA API token (Personal Access Token)",
|
|
48
|
+
json_schema_extra={"format": "password", "required_in_schema": True}
|
|
49
|
+
)
|
|
50
|
+
default_project: Optional[str] = Field(
|
|
51
|
+
None,
|
|
52
|
+
description="Default JIRA project key (e.g., 'ECAPP', 'PROJ')",
|
|
53
|
+
json_schema_extra={"config_scope": "project"}
|
|
54
|
+
)
|
|
55
|
+
timeout: int = Field(
|
|
56
|
+
30,
|
|
57
|
+
description="Request timeout in seconds",
|
|
58
|
+
json_schema_extra={"config_scope": "global"}
|
|
59
|
+
)
|
|
60
|
+
enable_cache: bool = Field(
|
|
61
|
+
True,
|
|
62
|
+
description="Enable caching for API responses",
|
|
63
|
+
json_schema_extra={"config_scope": "global"}
|
|
64
|
+
)
|
|
65
|
+
cache_ttl: int = Field(
|
|
66
|
+
300,
|
|
67
|
+
description="Cache time-to-live in seconds",
|
|
68
|
+
json_schema_extra={"config_scope": "global"}
|
|
69
|
+
)
|
|
42
70
|
|
|
43
71
|
@field_validator('base_url')
|
|
44
72
|
@classmethod
|
|
@@ -20,10 +20,19 @@ class PluginRegistry:
|
|
|
20
20
|
logger = logging.getLogger('titan_cli.ui.tui.screens.project_setup_wizard')
|
|
21
21
|
|
|
22
22
|
discovered = entry_points(group='titan.plugins')
|
|
23
|
-
self._discovered_plugin_names = [ep.name for ep in discovered]
|
|
24
|
-
logger.debug(f"PluginRegistry.discover() - Found {len(self._discovered_plugin_names)} plugins: {self._discovered_plugin_names}")
|
|
25
23
|
|
|
24
|
+
# Deduplicate entry points (can happen in dev mode with editable installs)
|
|
25
|
+
seen = {}
|
|
26
|
+
unique_eps = []
|
|
26
27
|
for ep in discovered:
|
|
28
|
+
if ep.name not in seen:
|
|
29
|
+
seen[ep.name] = ep
|
|
30
|
+
unique_eps.append(ep)
|
|
31
|
+
|
|
32
|
+
self._discovered_plugin_names = [ep.name for ep in unique_eps]
|
|
33
|
+
logger.debug(f"PluginRegistry.discover() - Found {len(self._discovered_plugin_names)} plugins: {self._discovered_plugin_names}")
|
|
34
|
+
|
|
35
|
+
for ep in unique_eps:
|
|
27
36
|
try:
|
|
28
37
|
logger.debug(f"Loading plugin: {ep.name}")
|
|
29
38
|
plugin_class = ep.load()
|
|
@@ -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
|
]
|
|
@@ -17,18 +17,17 @@ class StepInfo:
|
|
|
17
17
|
name: str
|
|
18
18
|
path: Path
|
|
19
19
|
|
|
20
|
-
class
|
|
20
|
+
class BaseStepSource:
|
|
21
21
|
"""
|
|
22
|
-
|
|
22
|
+
Base class for discovering and loading Python step functions.
|
|
23
23
|
"""
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
EXCLUDED_FILES = {"__init__.py", "__pycache__"}
|
|
25
|
+
|
|
26
|
+
def __init__(self, steps_dir: Path):
|
|
27
|
+
self._steps_dir = steps_dir
|
|
27
28
|
self._step_info_cache: Optional[List[StepInfo]] = None
|
|
28
29
|
self._step_function_cache: Dict[str, StepFunction] = {}
|
|
29
30
|
|
|
30
|
-
EXCLUDED_FILES = {"__init__.py", "__pycache__"}
|
|
31
|
-
|
|
32
31
|
def discover(self) -> List[StepInfo]:
|
|
33
32
|
"""
|
|
34
33
|
Discovers all available step files in the project's .titan/steps directory.
|
|
@@ -52,35 +51,54 @@ class ProjectStepSource:
|
|
|
52
51
|
def get_step(self, step_name: str) -> Optional[StepFunction]:
|
|
53
52
|
"""
|
|
54
53
|
Retrieves a step function by name, loading it from its file if necessary.
|
|
54
|
+
Searches all Python files in the directory for the function.
|
|
55
55
|
"""
|
|
56
56
|
if step_name in self._step_function_cache:
|
|
57
57
|
return self._step_function_cache[step_name]
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
discovered_steps = self.discover()
|
|
61
|
-
step_info = next((s for s in discovered_steps if s.name == step_name), None)
|
|
62
|
-
|
|
63
|
-
if not step_info:
|
|
59
|
+
if not self._steps_dir.is_dir():
|
|
64
60
|
return None
|
|
65
61
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
#
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
62
|
+
# Search all Python files for the function
|
|
63
|
+
for step_file in self._steps_dir.glob("*.py"):
|
|
64
|
+
if step_file.name in self.EXCLUDED_FILES:
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
# Use a unique module name to avoid conflicts
|
|
69
|
+
module_name = f"_titan_step_{step_file.stem}_{id(step_file)}"
|
|
70
|
+
spec = importlib.util.spec_from_file_location(module_name, step_file)
|
|
71
|
+
if spec and spec.loader:
|
|
72
|
+
module = importlib.util.module_from_spec(spec)
|
|
73
|
+
spec.loader.exec_module(module)
|
|
74
|
+
|
|
75
|
+
# Look for the function in this module
|
|
76
|
+
step_func = getattr(module, step_name, None)
|
|
77
|
+
if callable(step_func):
|
|
78
|
+
self._step_function_cache[step_name] = step_func
|
|
79
|
+
return step_func
|
|
80
|
+
|
|
81
|
+
except Exception:
|
|
82
|
+
# Continue searching other files
|
|
83
|
+
continue
|
|
84
84
|
|
|
85
85
|
return None
|
|
86
86
|
|
|
87
|
+
|
|
88
|
+
class ProjectStepSource(BaseStepSource):
|
|
89
|
+
"""
|
|
90
|
+
Discovers and loads Python step functions from a project's .titan/steps/ directory.
|
|
91
|
+
"""
|
|
92
|
+
def __init__(self, project_root: Path):
|
|
93
|
+
steps_dir = project_root / ".titan" / "steps"
|
|
94
|
+
super().__init__(steps_dir)
|
|
95
|
+
self._project_root = project_root
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class UserStepSource(BaseStepSource):
|
|
99
|
+
"""
|
|
100
|
+
Discovers and loads Python step functions from user's ~/.titan/steps/ directory.
|
|
101
|
+
"""
|
|
102
|
+
def __init__(self):
|
|
103
|
+
steps_dir = Path.home() / ".titan" / "steps"
|
|
104
|
+
super().__init__(steps_dir)
|
|
@@ -36,15 +36,21 @@ 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 real_plugins:
|
|
49
|
+
# Use the first real plugin
|
|
50
|
+
primary_plugin = sorted(real_plugins)[0]
|
|
51
|
+
return primary_plugin.capitalize()
|
|
52
|
+
# No plugin dependencies, it's a custom workflow
|
|
53
|
+
return "Custom"
|
|
48
54
|
|
|
49
55
|
# Fallback for other sources
|
|
50
56
|
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
|
|
@@ -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.text(f"Invalid cli_preference: {cli_preference}. Must be one of {VALID_CLI_PREFERENCES}", markup="red")
|
|
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.text(msg.AIAssistant.CONTEXT_KEY_REQUIRED, markup="red")
|
|
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.text(friendly_msg, markup="dim")
|
|
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.text(msg.AIAssistant.INVALID_PROMPT_TEMPLATE.format(e=e), markup="red")
|
|
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.text(msg.AIAssistant.FAILED_TO_BUILD_PROMPT.format(e=e), markup="red")
|
|
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.text(msg.AIAssistant.DECLINED_ASSISTANCE_STOPPED, markup="yellow")
|
|
117
|
+
ctx.textual.end_step("error")
|
|
94
118
|
return Error(msg.AIAssistant.DECLINED_ASSISTANCE_STOPPED)
|
|
119
|
+
ctx.textual.text(msg.AIAssistant.DECLINED_ASSISTANCE_SKIPPED, markup="dim")
|
|
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.text(msg.AIAssistant.NO_ASSISTANT_CLI_FOUND, markup="yellow")
|
|
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:
|
|
@@ -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.text(msg.AIAssistant.DECLINED_ASSISTANCE_SKIPPED, markup="dim")
|
|
166
|
+
ctx.textual.end_step("skip")
|
|
139
167
|
return Skip(msg.AIAssistant.DECLINED_ASSISTANCE_SKIPPED)
|
|
140
168
|
|
|
141
169
|
try:
|
|
@@ -144,19 +172,22 @@ def execute_ai_assistant_step(step: WorkflowStepModel, ctx: WorkflowContext) ->
|
|
|
144
172
|
cli_to_launch = cli_options[choice_idx]
|
|
145
173
|
else:
|
|
146
174
|
ctx.textual.text("Invalid option selected", markup="red")
|
|
175
|
+
ctx.textual.end_step("skip")
|
|
147
176
|
return Skip(msg.AIAssistant.DECLINED_ASSISTANCE_SKIPPED)
|
|
148
177
|
except ValueError:
|
|
149
178
|
ctx.textual.text("Invalid input - must be a number", markup="red")
|
|
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.text(f"Unknown CLI to launch: {cli_to_launch}", markup="red")
|
|
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
192
|
ctx.textual.text(msg.AIAssistant.LAUNCHING_ASSISTANT.format(cli_name=cli_name), markup="cyan")
|
|
162
193
|
|
|
@@ -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.text(msg.AIAssistant.BACK_IN_TITAN, markup="green")
|
|
181
212
|
|
|
182
213
|
if exit_code != 0:
|
|
214
|
+
ctx.textual.text(msg.AIAssistant.ASSISTANT_EXITED_WITH_CODE.format(cli_name=cli_name, exit_code=exit_code), markup="yellow")
|
|
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.text(msg.AIAssistant.ASSISTANT_EXITED_WITH_CODE.format(cli_name=cli_name, exit_code=exit_code), markup="green")
|
|
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})
|
|
@@ -128,6 +128,11 @@ class WorkflowExecutor:
|
|
|
128
128
|
step_func = self._workflow_registry.get_project_step(step_func_name)
|
|
129
129
|
if not step_func:
|
|
130
130
|
return Error(f"Project step '{step_func_name}' not found in '.titan/steps/'.", WorkflowExecutionError(f"Project step '{step_func_name}' not found"))
|
|
131
|
+
elif plugin_name == "user":
|
|
132
|
+
# Handle virtual 'user' plugin for user-specific steps
|
|
133
|
+
step_func = self._workflow_registry.get_user_step(step_func_name)
|
|
134
|
+
if not step_func:
|
|
135
|
+
return Error(f"User step '{step_func_name}' not found in '~/.titan/steps/'.", WorkflowExecutionError(f"User step '{step_func_name}' not found"))
|
|
131
136
|
elif plugin_name == "core":
|
|
132
137
|
# Handle virtual 'core' plugin for built-in core steps
|
|
133
138
|
step_func = self.CORE_STEPS.get(step_func_name)
|
|
@@ -160,7 +165,7 @@ class WorkflowExecutor:
|
|
|
160
165
|
# Plugin and project steps receive only ctx (params are in ctx.data)
|
|
161
166
|
return step_func(ctx)
|
|
162
167
|
except Exception as e:
|
|
163
|
-
error_source = f"plugin '{plugin_name}'" if plugin_name not in ("project", "core") else f"{plugin_name} step"
|
|
168
|
+
error_source = f"plugin '{plugin_name}'" if plugin_name not in ("project", "user", "core") else f"{plugin_name} step"
|
|
164
169
|
return Error(f"Error executing step '{step_func_name}' from {error_source}: {e}", e)
|
|
165
170
|
|
|
166
171
|
|
|
@@ -494,22 +494,43 @@ class PluginConfigWizardScreen(BaseScreen):
|
|
|
494
494
|
|
|
495
495
|
try:
|
|
496
496
|
project_cfg_path = self.config.project_config_path
|
|
497
|
+
global_cfg_path = self.config._global_config_path
|
|
498
|
+
|
|
497
499
|
if not project_cfg_path:
|
|
498
500
|
self.app.notify("No project config found", severity="error")
|
|
499
501
|
return
|
|
500
502
|
|
|
503
|
+
# Load existing configs
|
|
501
504
|
project_cfg_dict = {}
|
|
502
505
|
if project_cfg_path.exists():
|
|
503
506
|
with open(project_cfg_path, "rb") as f:
|
|
504
507
|
project_cfg_dict = tomli.load(f)
|
|
505
508
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
+
global_cfg_dict = {}
|
|
510
|
+
if global_cfg_path.exists():
|
|
511
|
+
with open(global_cfg_path, "rb") as f:
|
|
512
|
+
global_cfg_dict = tomli.load(f)
|
|
513
|
+
|
|
514
|
+
# Prepare plugin tables
|
|
515
|
+
project_plugins_table = project_cfg_dict.setdefault("plugins", {})
|
|
516
|
+
project_plugin_table = project_plugins_table.setdefault(self.plugin_name, {})
|
|
517
|
+
project_config_table = project_plugin_table.setdefault("config", {})
|
|
518
|
+
|
|
519
|
+
global_plugins_table = global_cfg_dict.setdefault("plugins", {})
|
|
520
|
+
global_plugin_table = global_plugins_table.setdefault(self.plugin_name, {})
|
|
521
|
+
global_config_table = global_plugin_table.setdefault("config", {})
|
|
509
522
|
|
|
510
|
-
#
|
|
523
|
+
# Get field metadata from schema
|
|
524
|
+
field_scopes = {}
|
|
525
|
+
if self.schema and "properties" in self.schema:
|
|
526
|
+
for field_name, field_info in self.schema["properties"].items():
|
|
527
|
+
scope = field_info.get("config_scope", "project") # Default to project
|
|
528
|
+
field_scopes[field_name] = scope
|
|
529
|
+
|
|
530
|
+
# Separate secrets and config by scope
|
|
511
531
|
secrets_to_save = {}
|
|
512
|
-
|
|
532
|
+
global_config_values = {}
|
|
533
|
+
project_config_values = {}
|
|
513
534
|
|
|
514
535
|
for field_name, value in self.config_data.items():
|
|
515
536
|
if isinstance(value, dict) and value.get("_is_secret"):
|
|
@@ -524,15 +545,25 @@ class PluginConfigWizardScreen(BaseScreen):
|
|
|
524
545
|
else:
|
|
525
546
|
secrets_to_save[secret_key] = value["_value"]
|
|
526
547
|
else:
|
|
527
|
-
|
|
548
|
+
# Route to global or project based on field scope
|
|
549
|
+
scope = field_scopes.get(field_name, "project")
|
|
550
|
+
if scope == "global":
|
|
551
|
+
global_config_values[field_name] = value
|
|
552
|
+
else:
|
|
553
|
+
project_config_values[field_name] = value
|
|
528
554
|
|
|
529
|
-
# Update
|
|
530
|
-
|
|
555
|
+
# Update configs
|
|
556
|
+
project_config_table.update(project_config_values)
|
|
557
|
+
global_config_table.update(global_config_values)
|
|
531
558
|
|
|
532
|
-
# Write config
|
|
559
|
+
# Write config files
|
|
533
560
|
with open(project_cfg_path, "wb") as f:
|
|
534
561
|
tomli_w.dump(project_cfg_dict, f)
|
|
535
562
|
|
|
563
|
+
if global_config_values: # Only write global if there are global values
|
|
564
|
+
with open(global_cfg_path, "wb") as f:
|
|
565
|
+
tomli_w.dump(global_cfg_dict, f)
|
|
566
|
+
|
|
536
567
|
# Save secrets
|
|
537
568
|
project_name = self.config.get_project_name()
|
|
538
569
|
for secret_key, secret_value in secrets_to_save.items():
|
|
@@ -504,7 +504,6 @@ class WorkflowExecutionContent(Widget):
|
|
|
504
504
|
def handle_event(self, message) -> None:
|
|
505
505
|
"""Handle workflow events generically."""
|
|
506
506
|
from titan_cli.ui.tui.textual_workflow_executor import TextualWorkflowExecutor
|
|
507
|
-
from titan_cli.ui.tui.widgets import Panel
|
|
508
507
|
|
|
509
508
|
if isinstance(message, TextualWorkflowExecutor.WorkflowStarted):
|
|
510
509
|
# Track nested workflow depth
|
|
@@ -513,31 +512,16 @@ class WorkflowExecutionContent(Widget):
|
|
|
513
512
|
self.append_output(f"\n[bold cyan]🚀 Starting workflow: {message.workflow_name}[/bold cyan]")
|
|
514
513
|
|
|
515
514
|
elif isinstance(message, TextualWorkflowExecutor.StepStarted):
|
|
516
|
-
#
|
|
517
|
-
|
|
518
|
-
# Nested workflow: show with indentation, no step number
|
|
519
|
-
indent = " " * self._workflow_depth
|
|
520
|
-
self.append_output(f"[cyan]{indent}→ Step {message.step_index}: {message.step_name}[/cyan]")
|
|
521
|
-
else:
|
|
522
|
-
# Top-level workflow: show with step number
|
|
523
|
-
self.append_output(f"[cyan]→ Step {message.step_index}: {message.step_name}[/cyan]")
|
|
515
|
+
# StepContainer now handles step titles, so we don't display anything here
|
|
516
|
+
pass
|
|
524
517
|
|
|
525
518
|
elif isinstance(message, TextualWorkflowExecutor.StepCompleted):
|
|
526
|
-
#
|
|
527
|
-
|
|
528
|
-
indent = " " * self._workflow_depth
|
|
529
|
-
self.append_output(f"[green]{indent}{Icons.SUCCESS} Completed: {message.step_name}[/green]\n")
|
|
530
|
-
else:
|
|
531
|
-
self.append_output(f"[green]{Icons.SUCCESS} Completed: {message.step_name}[/green]\n")
|
|
519
|
+
# StepContainer now handles step completion (green border), so we don't display anything here
|
|
520
|
+
pass
|
|
532
521
|
|
|
533
522
|
elif isinstance(message, TextualWorkflowExecutor.StepFailed):
|
|
534
|
-
#
|
|
535
|
-
|
|
536
|
-
self.mount(Panel(f"Failed: {message.step_name} - {message.error_message}", panel_type="error"))
|
|
537
|
-
self._scroll_to_end()
|
|
538
|
-
except Exception:
|
|
539
|
-
pass
|
|
540
|
-
|
|
523
|
+
# StepContainer now handles step failures (red border), so we don't display the panel
|
|
524
|
+
# Only show "continuing despite error" message if on_error is "continue"
|
|
541
525
|
if message.on_error == "continue":
|
|
542
526
|
indent = " " * self._workflow_depth if self._workflow_depth > 0 else ""
|
|
543
527
|
self.append_output(f"[yellow]{indent} {Icons.WARNING} Continuing despite error[/yellow]\n")
|
|
@@ -545,12 +529,8 @@ class WorkflowExecutionContent(Widget):
|
|
|
545
529
|
self.append_output("")
|
|
546
530
|
|
|
547
531
|
elif isinstance(message, TextualWorkflowExecutor.StepSkipped):
|
|
548
|
-
#
|
|
549
|
-
|
|
550
|
-
self.mount(Panel(f"Skipped: {message.step_name}", panel_type="warning"))
|
|
551
|
-
self._scroll_to_end()
|
|
552
|
-
except Exception:
|
|
553
|
-
pass
|
|
532
|
+
# StepContainer now handles step skips (yellow border), so we don't display the panel
|
|
533
|
+
pass
|
|
554
534
|
|
|
555
535
|
elif isinstance(message, TextualWorkflowExecutor.WorkflowCompleted):
|
|
556
536
|
# Track nested workflow depth
|