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,163 @@
|
|
|
1
|
+
# core/plugin_registry.py
|
|
2
|
+
from importlib.metadata import entry_points
|
|
3
|
+
from typing import Dict, List, Any, Optional
|
|
4
|
+
from ..errors import PluginLoadError, PluginInitializationError
|
|
5
|
+
from .plugin_base import TitanPlugin
|
|
6
|
+
|
|
7
|
+
class PluginRegistry:
|
|
8
|
+
"""Discovers and manages installed plugins."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, discover_on_init: bool = True):
|
|
11
|
+
self._plugins: Dict[str, TitanPlugin] = {}
|
|
12
|
+
self._failed_plugins: Dict[str, Exception] = {}
|
|
13
|
+
self._discovered_plugin_names: List[str] = []
|
|
14
|
+
if discover_on_init:
|
|
15
|
+
self.discover()
|
|
16
|
+
|
|
17
|
+
def discover(self):
|
|
18
|
+
"""Discover all installed Titan plugins."""
|
|
19
|
+
import logging
|
|
20
|
+
logger = logging.getLogger('titan_cli.ui.tui.screens.project_setup_wizard')
|
|
21
|
+
|
|
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
|
+
|
|
26
|
+
for ep in discovered:
|
|
27
|
+
try:
|
|
28
|
+
logger.debug(f"Loading plugin: {ep.name}")
|
|
29
|
+
plugin_class = ep.load()
|
|
30
|
+
if not issubclass(plugin_class, TitanPlugin):
|
|
31
|
+
raise TypeError("Plugin class must inherit from TitanPlugin")
|
|
32
|
+
self._plugins[ep.name] = plugin_class()
|
|
33
|
+
logger.debug(f"Successfully loaded plugin: {ep.name}")
|
|
34
|
+
except Exception as e:
|
|
35
|
+
logger.error(f"Failed to load plugin {ep.name}: {e}", exc_info=True)
|
|
36
|
+
error = PluginLoadError(plugin_name=ep.name, original_exception=e)
|
|
37
|
+
self._failed_plugins[ep.name] = error
|
|
38
|
+
|
|
39
|
+
logger.debug(f"PluginRegistry.discover() - Loaded {len(self._plugins)} plugins successfully")
|
|
40
|
+
logger.debug(f"PluginRegistry.discover() - Failed {len(self._failed_plugins)} plugins: {list(self._failed_plugins.keys())}")
|
|
41
|
+
|
|
42
|
+
def initialize_plugins(self, config: Any, secrets: Any) -> None:
|
|
43
|
+
"""
|
|
44
|
+
Initializes all discovered plugins in dependency order.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
config: TitanConfig instance
|
|
48
|
+
secrets: SecretManager instance
|
|
49
|
+
"""
|
|
50
|
+
import logging
|
|
51
|
+
logger = logging.getLogger('titan_cli.ui.tui.screens.project_setup_wizard')
|
|
52
|
+
|
|
53
|
+
# Create a copy of plugin names to iterate over, as _plugins might change
|
|
54
|
+
plugins_to_initialize = list(self._plugins.keys())
|
|
55
|
+
initialized = set()
|
|
56
|
+
|
|
57
|
+
# Simple dependency resolution loop
|
|
58
|
+
while plugins_to_initialize:
|
|
59
|
+
remaining_plugins_count = len(plugins_to_initialize)
|
|
60
|
+
next_pass_plugins = []
|
|
61
|
+
|
|
62
|
+
for name in plugins_to_initialize:
|
|
63
|
+
if name in initialized:
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
plugin = self._plugins[name]
|
|
67
|
+
dependencies_met = True
|
|
68
|
+
|
|
69
|
+
# Check if all dependencies are initialized or failed
|
|
70
|
+
for dep_name in plugin.dependencies:
|
|
71
|
+
if dep_name not in initialized:
|
|
72
|
+
# If a dependency failed to load/initialize, this plugin also implicitly fails
|
|
73
|
+
if dep_name in self._failed_plugins:
|
|
74
|
+
# Mark this plugin as failed due to dependency
|
|
75
|
+
error = PluginInitializationError(
|
|
76
|
+
plugin_name=name,
|
|
77
|
+
original_exception=f"Dependency '{dep_name}' failed to load/initialize."
|
|
78
|
+
)
|
|
79
|
+
self._failed_plugins[name] = error
|
|
80
|
+
# Don't delete from _plugins - keep it available for configuration
|
|
81
|
+
dependencies_met = False
|
|
82
|
+
break
|
|
83
|
+
else:
|
|
84
|
+
dependencies_met = False
|
|
85
|
+
break
|
|
86
|
+
|
|
87
|
+
if not dependencies_met:
|
|
88
|
+
if name not in self._failed_plugins: # If not already marked failed by dependency
|
|
89
|
+
next_pass_plugins.append(name)
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
# Initialize the plugin if dependencies are met
|
|
93
|
+
try:
|
|
94
|
+
logger.debug(f"Initializing plugin: {name}")
|
|
95
|
+
plugin.initialize(config, secrets)
|
|
96
|
+
initialized.add(name)
|
|
97
|
+
logger.debug(f"Successfully initialized plugin: {name}")
|
|
98
|
+
except Exception as e:
|
|
99
|
+
logger.error(f"Failed to initialize plugin {name}: {e}", exc_info=True)
|
|
100
|
+
error = PluginInitializationError(plugin_name=name, original_exception=e)
|
|
101
|
+
self._failed_plugins[name] = error
|
|
102
|
+
# Don't delete from _plugins - keep it available for configuration
|
|
103
|
+
|
|
104
|
+
plugins_to_initialize = next_pass_plugins
|
|
105
|
+
if len(plugins_to_initialize) == remaining_plugins_count and remaining_plugins_count > 0:
|
|
106
|
+
# Circular dependency or unresolvable dependency
|
|
107
|
+
for name in plugins_to_initialize:
|
|
108
|
+
if name not in self._failed_plugins: # Only mark if not already failed by dependency
|
|
109
|
+
error = PluginInitializationError(
|
|
110
|
+
plugin_name=name,
|
|
111
|
+
original_exception="Circular or unresolvable dependency detected."
|
|
112
|
+
)
|
|
113
|
+
self._failed_plugins[name] = error
|
|
114
|
+
if name in self._plugins: # Only delete if it wasn't deleted by a dep error
|
|
115
|
+
del self._plugins[name]
|
|
116
|
+
break # Exit the loop if no progress is made
|
|
117
|
+
|
|
118
|
+
def list_installed(self) -> List[str]:
|
|
119
|
+
"""List successfully loaded plugins."""
|
|
120
|
+
return list(self._plugins.keys())
|
|
121
|
+
|
|
122
|
+
def list_discovered(self) -> List[str]:
|
|
123
|
+
"""List all discovered plugins by name, regardless of load status."""
|
|
124
|
+
return self._discovered_plugin_names
|
|
125
|
+
|
|
126
|
+
def list_enabled(self, config: Any) -> List[str]:
|
|
127
|
+
"""
|
|
128
|
+
List plugins that are enabled in the current project configuration.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
config: TitanConfig instance
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
List of enabled plugin names
|
|
135
|
+
"""
|
|
136
|
+
if not config or not config.config or not config.config.plugins:
|
|
137
|
+
return []
|
|
138
|
+
|
|
139
|
+
enabled = []
|
|
140
|
+
for plugin_name, plugin_config in config.config.plugins.items():
|
|
141
|
+
if hasattr(plugin_config, 'enabled') and plugin_config.enabled:
|
|
142
|
+
enabled.append(plugin_name)
|
|
143
|
+
|
|
144
|
+
return enabled
|
|
145
|
+
|
|
146
|
+
def list_failed(self) -> Dict[str, Exception]:
|
|
147
|
+
"""
|
|
148
|
+
List plugins that failed to load or initialize.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
Dict mapping plugin name to error
|
|
152
|
+
"""
|
|
153
|
+
return self._failed_plugins.copy()
|
|
154
|
+
|
|
155
|
+
def get_plugin(self, name: str) -> Optional[TitanPlugin]:
|
|
156
|
+
"""Get plugin instance by name."""
|
|
157
|
+
return self._plugins.get(name)
|
|
158
|
+
|
|
159
|
+
def reset(self):
|
|
160
|
+
"""Resets the registry, clearing all loaded plugins and re-discovering."""
|
|
161
|
+
self._plugins.clear()
|
|
162
|
+
self._failed_plugins.clear()
|
|
163
|
+
self.discover()
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# titan_cli/core/secrets.py
|
|
2
|
+
import os
|
|
3
|
+
import keyring
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional, Literal
|
|
6
|
+
from dotenv import load_dotenv
|
|
7
|
+
|
|
8
|
+
ScopeType = Literal["env", "project", "user"]
|
|
9
|
+
|
|
10
|
+
class SecretManager:
|
|
11
|
+
"""
|
|
12
|
+
Manages secrets with a 3-level cascade:
|
|
13
|
+
|
|
14
|
+
1. Environment variables (HIGHEST - CI/CD)
|
|
15
|
+
2. Project secrets (.titan/secrets.env - team-shared)
|
|
16
|
+
3. System keyring (USER - personal credentials)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, project_path: Optional[Path] = None):
|
|
20
|
+
self.project_path = project_path or Path.cwd()
|
|
21
|
+
self._load_project_secrets()
|
|
22
|
+
|
|
23
|
+
def _load_project_secrets(self):
|
|
24
|
+
"""Load secrets from .titan/secrets.env"""
|
|
25
|
+
secrets_file = self.project_path / ".titan" / "secrets.env"
|
|
26
|
+
if secrets_file.exists():
|
|
27
|
+
load_dotenv(secrets_file)
|
|
28
|
+
|
|
29
|
+
def get(self, key: str, namespace: str = "titan") -> Optional[str]:
|
|
30
|
+
"""
|
|
31
|
+
Get secret with cascading priority
|
|
32
|
+
|
|
33
|
+
Priority:
|
|
34
|
+
1. Environment variable (e.g., GITHUB_TOKEN, includes project secrets loaded at init)
|
|
35
|
+
2. System keyring (user-level)
|
|
36
|
+
3. None
|
|
37
|
+
|
|
38
|
+
Note: Project secrets (.titan/secrets.env) are loaded
|
|
39
|
+
into environment on init, so they are checked in step 1.
|
|
40
|
+
"""
|
|
41
|
+
# 1. Environment variable (includes project secrets)
|
|
42
|
+
env_key = key.upper()
|
|
43
|
+
if env_key in os.environ:
|
|
44
|
+
return os.environ[env_key]
|
|
45
|
+
|
|
46
|
+
# 2. System keyring
|
|
47
|
+
try:
|
|
48
|
+
value = keyring.get_password(namespace, key)
|
|
49
|
+
if value:
|
|
50
|
+
return value
|
|
51
|
+
except Exception:
|
|
52
|
+
pass # Keyring might not be available
|
|
53
|
+
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
def set(
|
|
57
|
+
self,
|
|
58
|
+
key: str,
|
|
59
|
+
value: str,
|
|
60
|
+
namespace: str = "titan",
|
|
61
|
+
scope: ScopeType = "user"
|
|
62
|
+
):
|
|
63
|
+
"""
|
|
64
|
+
Set secret
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
key: Secret key (e.g., "anthropic_api_key")
|
|
68
|
+
value: Secret value
|
|
69
|
+
namespace: Keyring namespace
|
|
70
|
+
scope: Where to store:
|
|
71
|
+
- "env": Current environment only (temporary)
|
|
72
|
+
- "project": .titan/secrets.env (team-shared)
|
|
73
|
+
- "user": System keyring (personal, secure)
|
|
74
|
+
"""
|
|
75
|
+
if scope == "env":
|
|
76
|
+
# Set in current environment only
|
|
77
|
+
os.environ[key.upper()] = value
|
|
78
|
+
|
|
79
|
+
elif scope == "user":
|
|
80
|
+
# Store in system keyring (most secure)
|
|
81
|
+
try:
|
|
82
|
+
keyring.set_password(namespace, key, value)
|
|
83
|
+
except Exception:
|
|
84
|
+
# Fallback to project scope if keyring fails (common on macOS with unsigned apps)
|
|
85
|
+
# Recursively call with project scope
|
|
86
|
+
self.set(key, value, scope="project")
|
|
87
|
+
|
|
88
|
+
elif scope == "project":
|
|
89
|
+
# Store in .titan/secrets.env
|
|
90
|
+
secrets_file = self.project_path / ".titan" / "secrets.env"
|
|
91
|
+
secrets_file.parent.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
|
|
93
|
+
# Read existing content
|
|
94
|
+
existing_lines = []
|
|
95
|
+
if secrets_file.exists():
|
|
96
|
+
with open(secrets_file, "r") as f:
|
|
97
|
+
existing_lines = f.readlines()
|
|
98
|
+
|
|
99
|
+
# Update or append
|
|
100
|
+
key_upper = key.upper()
|
|
101
|
+
updated = False
|
|
102
|
+
for i, line in enumerate(existing_lines):
|
|
103
|
+
if line.startswith(f"{key_upper}="):
|
|
104
|
+
existing_lines[i] = f"{key_upper}='{value}'\n"
|
|
105
|
+
updated = True
|
|
106
|
+
break
|
|
107
|
+
|
|
108
|
+
if not updated:
|
|
109
|
+
existing_lines.append(f"{key_upper}='{value}'\n")
|
|
110
|
+
|
|
111
|
+
# Write back
|
|
112
|
+
with open(secrets_file, "w") as f:
|
|
113
|
+
f.writelines(existing_lines)
|
|
114
|
+
|
|
115
|
+
def delete(self, key: str, namespace: str = "titan", scope: ScopeType = "user"):
|
|
116
|
+
"""Delete secret from specified scope"""
|
|
117
|
+
if scope == "env":
|
|
118
|
+
env_key = key.upper()
|
|
119
|
+
os.environ.pop(env_key, None)
|
|
120
|
+
|
|
121
|
+
elif scope == "user":
|
|
122
|
+
try:
|
|
123
|
+
keyring.delete_password(namespace, key)
|
|
124
|
+
except Exception:
|
|
125
|
+
pass # Keyring might not be available
|
|
126
|
+
|
|
127
|
+
elif scope == "project":
|
|
128
|
+
secrets_file = self.project_path / ".titan" / "secrets.env"
|
|
129
|
+
if not secrets_file.exists():
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
# Read and filter
|
|
133
|
+
with open(secrets_file, "r") as f:
|
|
134
|
+
lines = f.readlines()
|
|
135
|
+
|
|
136
|
+
key_upper = key.upper()
|
|
137
|
+
filtered = [line for line in lines if not line.startswith(f"{key_upper}=")]
|
|
138
|
+
|
|
139
|
+
# Write back
|
|
140
|
+
with open(secrets_file, "w") as f:
|
|
141
|
+
f.writelines(filtered)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# titan_cli/core/workflows/__init__.py
|
|
2
|
+
"""
|
|
3
|
+
Workflow management system.
|
|
4
|
+
|
|
5
|
+
Similar to plugins system, but for workflows:
|
|
6
|
+
- WorkflowRegistry: Discover and manage workflows
|
|
7
|
+
- WorkflowSource: Load from multiple sources (project, user, system, plugins)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .workflow_registry import WorkflowRegistry, ParsedWorkflow
|
|
11
|
+
from .workflow_sources import WorkflowInfo
|
|
12
|
+
from .workflow_exceptions import WorkflowNotFoundError, WorkflowExecutionError
|
|
13
|
+
from .project_step_source import ProjectStepSource
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"WorkflowRegistry",
|
|
17
|
+
"WorkflowInfo",
|
|
18
|
+
"ParsedWorkflow",
|
|
19
|
+
"WorkflowNotFoundError",
|
|
20
|
+
"WorkflowExecutionError",
|
|
21
|
+
"ProjectStepSource",
|
|
22
|
+
]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from typing import Any, Dict, List, Literal, Optional, Union
|
|
2
|
+
from pydantic import BaseModel, Field, model_validator
|
|
3
|
+
|
|
4
|
+
class WorkflowStepModel(BaseModel):
|
|
5
|
+
"""
|
|
6
|
+
Represents a single step in a workflow.
|
|
7
|
+
"""
|
|
8
|
+
id: Optional[str] = Field(None, description="Unique identifier for the step. Auto-generated from name if not provided.")
|
|
9
|
+
name: Optional[str] = Field(None, description="Human-readable name for the step.")
|
|
10
|
+
plugin: Optional[str] = Field(None, description="The plugin providing the step (e.g., 'git', 'github').")
|
|
11
|
+
step: Optional[str] = Field(None, description="The name of the step function within the plugin.")
|
|
12
|
+
command: Optional[str] = Field(None, description="A shell command to execute.")
|
|
13
|
+
workflow: Optional[str] = Field(None, description="The name of another workflow to execute.")
|
|
14
|
+
params: Dict[str, Any] = Field(default_factory=dict, description="Parameters to pass to the step or command.")
|
|
15
|
+
on_error: Literal["fail", "continue"] = Field("fail", description="Action to take if the step fails.")
|
|
16
|
+
use_shell: bool = Field(False, description="If true, execute the command in a shell. WARNING: This can be a security risk if the command uses untrusted input.")
|
|
17
|
+
|
|
18
|
+
# Used only in base workflow definitions to mark injection points for hooks
|
|
19
|
+
hook: Optional[str] = Field(None, description="Marks this step as a hook point for extension.")
|
|
20
|
+
|
|
21
|
+
@model_validator(mode='after')
|
|
22
|
+
def validate_step_type(self):
|
|
23
|
+
"""
|
|
24
|
+
Validate that the step has exactly one of: (plugin + step), command, or workflow.
|
|
25
|
+
Also auto-generates id from name if not provided.
|
|
26
|
+
"""
|
|
27
|
+
# Auto-generate id from name if not provided
|
|
28
|
+
if not self.id:
|
|
29
|
+
if self.name:
|
|
30
|
+
# Convert name to valid id: lowercase, replace non-alphanumeric with underscore
|
|
31
|
+
import re
|
|
32
|
+
self.id = re.sub(r'[^a-z0-9_]', '_', self.name.lower()).strip('_')
|
|
33
|
+
elif self.hook:
|
|
34
|
+
# For hook-only steps, use hook name as id
|
|
35
|
+
self.id = f"hook_{self.hook}"
|
|
36
|
+
elif self.plugin and self.step:
|
|
37
|
+
# For plugin steps, use plugin_step as id
|
|
38
|
+
self.id = f"{self.plugin}_{self.step}"
|
|
39
|
+
elif self.workflow:
|
|
40
|
+
# For workflow steps, use workflow name as id
|
|
41
|
+
self.id = f"workflow_{self.workflow.replace(':', '_').replace('/', '_')}"
|
|
42
|
+
elif self.command:
|
|
43
|
+
# For command steps, generate generic id
|
|
44
|
+
self.id = "command_step"
|
|
45
|
+
else:
|
|
46
|
+
# Fallback
|
|
47
|
+
self.id = "step"
|
|
48
|
+
|
|
49
|
+
# A step can be just a hook, in which case other fields are not needed.
|
|
50
|
+
if self.hook:
|
|
51
|
+
return self
|
|
52
|
+
|
|
53
|
+
has_plugin_step = self.plugin is not None and self.step is not None
|
|
54
|
+
has_command = self.command is not None
|
|
55
|
+
has_workflow = self.workflow is not None
|
|
56
|
+
|
|
57
|
+
provided_actions = sum([has_plugin_step, has_command, has_workflow])
|
|
58
|
+
|
|
59
|
+
if provided_actions > 1:
|
|
60
|
+
raise ValueError(f"Step '{self.id}' can only have one action type, but found multiple: "
|
|
61
|
+
f"{'plugin/step, ' if has_plugin_step else ''}"
|
|
62
|
+
f"{'command, ' if has_command else ''}"
|
|
63
|
+
f"{'workflow' if has_workflow else ''}".strip(', '))
|
|
64
|
+
|
|
65
|
+
if provided_actions == 0 and not self.hook:
|
|
66
|
+
raise ValueError(f"Step '{self.id}' must define an action: either (plugin and step), a command, a workflow, or a hook.")
|
|
67
|
+
|
|
68
|
+
return self
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class WorkflowConfigModel(BaseModel):
|
|
72
|
+
"""
|
|
73
|
+
Represents the overall configuration of a workflow.
|
|
74
|
+
"""
|
|
75
|
+
name: str = Field(..., description="The name of the workflow.")
|
|
76
|
+
description: Optional[str] = Field(None, description="A description of what the workflow does.")
|
|
77
|
+
source: Optional[str] = Field(None, description="Where the workflow is defined (e.g., 'plugin:github').")
|
|
78
|
+
extends: Optional[str] = Field(None, description="The base workflow this workflow extends.")
|
|
79
|
+
|
|
80
|
+
params: Dict[str, Any] = Field(default_factory=dict, description="Workflow-level parameters that can be overridden.")
|
|
81
|
+
|
|
82
|
+
# For base workflows: list of hook names (e.g., ["before_commit"])
|
|
83
|
+
# For extending workflows: dict of hook_name -> list of steps to inject
|
|
84
|
+
# This will be handled during loading/merging by the WorkflowLoader,
|
|
85
|
+
# so we define it broadly here and refine during processing.
|
|
86
|
+
hooks: Optional[Union[List[str], Dict[str, List[WorkflowStepModel]]]] = Field(None, description="Hook definitions or steps to inject into hooks.")
|
|
87
|
+
|
|
88
|
+
steps: List[WorkflowStepModel] = Field(default_factory=list, description="The sequence of steps in the workflow.")
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import importlib.util
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Callable, Dict, Any, Optional, List
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from titan_cli.engine.context import WorkflowContext
|
|
7
|
+
from titan_cli.engine.results import WorkflowResult
|
|
8
|
+
|
|
9
|
+
# Define a type alias for a Step Function
|
|
10
|
+
StepFunction = Callable[[WorkflowContext, Dict[str, Any]], WorkflowResult]
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class StepInfo:
|
|
14
|
+
"""
|
|
15
|
+
Metadata for a discovered project step.
|
|
16
|
+
"""
|
|
17
|
+
name: str
|
|
18
|
+
path: Path
|
|
19
|
+
|
|
20
|
+
class ProjectStepSource:
|
|
21
|
+
"""
|
|
22
|
+
Discovers and loads Python step functions from a project's .titan/steps/ directory.
|
|
23
|
+
"""
|
|
24
|
+
def __init__(self, project_root: Path):
|
|
25
|
+
self._project_root = project_root
|
|
26
|
+
self._steps_dir = self._project_root / ".titan" / "steps"
|
|
27
|
+
self._step_info_cache: Optional[List[StepInfo]] = None
|
|
28
|
+
self._step_function_cache: Dict[str, StepFunction] = {}
|
|
29
|
+
|
|
30
|
+
EXCLUDED_FILES = {"__init__.py", "__pycache__"}
|
|
31
|
+
|
|
32
|
+
def discover(self) -> List[StepInfo]:
|
|
33
|
+
"""
|
|
34
|
+
Discovers all available step files in the project's .titan/steps directory.
|
|
35
|
+
"""
|
|
36
|
+
if self._step_info_cache is not None:
|
|
37
|
+
return self._step_info_cache
|
|
38
|
+
|
|
39
|
+
if not self._steps_dir.is_dir():
|
|
40
|
+
self._step_info_cache = []
|
|
41
|
+
return []
|
|
42
|
+
|
|
43
|
+
discovered = []
|
|
44
|
+
for step_file in self._steps_dir.glob("*.py"):
|
|
45
|
+
if step_file.name not in self.EXCLUDED_FILES:
|
|
46
|
+
step_name = step_file.stem
|
|
47
|
+
discovered.append(StepInfo(name=step_name, path=step_file))
|
|
48
|
+
|
|
49
|
+
self._step_info_cache = discovered
|
|
50
|
+
return discovered
|
|
51
|
+
|
|
52
|
+
def get_step(self, step_name: str) -> Optional[StepFunction]:
|
|
53
|
+
"""
|
|
54
|
+
Retrieves a step function by name, loading it from its file if necessary.
|
|
55
|
+
"""
|
|
56
|
+
if step_name in self._step_function_cache:
|
|
57
|
+
return self._step_function_cache[step_name]
|
|
58
|
+
|
|
59
|
+
# Find the step info from the discovered list
|
|
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:
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
spec = importlib.util.spec_from_file_location(step_name, step_info.path)
|
|
68
|
+
if spec and spec.loader:
|
|
69
|
+
module = importlib.util.module_from_spec(spec)
|
|
70
|
+
spec.loader.exec_module(module)
|
|
71
|
+
|
|
72
|
+
# Convention: the step function has the same name as the file
|
|
73
|
+
step_func = getattr(module, step_name, None)
|
|
74
|
+
if callable(step_func):
|
|
75
|
+
self._step_function_cache[step_name] = step_func
|
|
76
|
+
return step_func
|
|
77
|
+
else:
|
|
78
|
+
# Optional: Log a warning if a file exists but the function doesn't
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
except Exception as e:
|
|
82
|
+
# Optional: Log a more detailed error
|
|
83
|
+
print(f"Error loading project step '{step_name}': {e}")
|
|
84
|
+
|
|
85
|
+
return None
|
|
86
|
+
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from titan_cli.core.errors import TitanError
|
|
2
|
+
|
|
3
|
+
class WorkflowError(TitanError):
|
|
4
|
+
"""Base exception for workflow-related errors."""
|
|
5
|
+
pass
|
|
6
|
+
|
|
7
|
+
class WorkflowNotFoundError(WorkflowError):
|
|
8
|
+
"""Raised when a workflow or its base cannot be found."""
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
class WorkflowValidationError(WorkflowError):
|
|
12
|
+
"""Raised when a workflow configuration fails Pydantic validation."""
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
class WorkflowExecutionError(WorkflowError):
|
|
16
|
+
"""Raised when a workflow execution fails."""
|
|
17
|
+
pass
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Workflow Filter Service
|
|
3
|
+
|
|
4
|
+
Service for filtering and grouping workflows by plugin.
|
|
5
|
+
"""
|
|
6
|
+
from typing import Dict, List, Set
|
|
7
|
+
|
|
8
|
+
from .workflow_sources import WorkflowInfo
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class WorkflowFilterService:
|
|
12
|
+
"""Service for filtering and grouping workflows."""
|
|
13
|
+
|
|
14
|
+
@staticmethod
|
|
15
|
+
def detect_plugin_name(wf_info: WorkflowInfo) -> str:
|
|
16
|
+
"""
|
|
17
|
+
Detect the actual plugin name for a workflow.
|
|
18
|
+
|
|
19
|
+
This method intelligently detects which plugin a workflow belongs to,
|
|
20
|
+
even for project/user workflows that use plugin steps.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
wf_info: WorkflowInfo object to analyze
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Plugin name in capitalized form (e.g., "Github", "Jira", "Custom")
|
|
27
|
+
|
|
28
|
+
Examples:
|
|
29
|
+
Plugin workflow: "plugin:github" -> "Github"
|
|
30
|
+
Project workflow using GitHub steps -> "Github"
|
|
31
|
+
Project workflow using Jira steps -> "Jira"
|
|
32
|
+
Project workflow with no plugins -> "Custom"
|
|
33
|
+
"""
|
|
34
|
+
# If it's already from a plugin, extract the name
|
|
35
|
+
if wf_info.source.startswith("plugin:"):
|
|
36
|
+
plugin_name = wf_info.source.split(":", 1)[1]
|
|
37
|
+
return plugin_name.capitalize()
|
|
38
|
+
|
|
39
|
+
# For project/user workflows, check which plugin they use
|
|
40
|
+
if wf_info.source in ["project", "user"]:
|
|
41
|
+
if wf_info.required_plugins:
|
|
42
|
+
# Use the first required plugin (most workflows use only one)
|
|
43
|
+
primary_plugin = sorted(wf_info.required_plugins)[0]
|
|
44
|
+
return primary_plugin.capitalize()
|
|
45
|
+
else:
|
|
46
|
+
# No plugin dependencies, it's a custom workflow
|
|
47
|
+
return "Custom"
|
|
48
|
+
|
|
49
|
+
# Fallback for other sources
|
|
50
|
+
return wf_info.source.capitalize()
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
def group_by_plugin(workflows: List[WorkflowInfo]) -> Dict[str, List[WorkflowInfo]]:
|
|
54
|
+
"""
|
|
55
|
+
Group workflows by their associated plugin.
|
|
56
|
+
|
|
57
|
+
Analyzes each workflow to determine its plugin and creates a mapping
|
|
58
|
+
from plugin names to lists of workflows.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
workflows: List of WorkflowInfo objects to group
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Dictionary mapping plugin names to lists of workflows
|
|
65
|
+
|
|
66
|
+
Example:
|
|
67
|
+
{
|
|
68
|
+
"Github": [workflow1, workflow2],
|
|
69
|
+
"Jira": [workflow3],
|
|
70
|
+
"Custom": [workflow4]
|
|
71
|
+
}
|
|
72
|
+
"""
|
|
73
|
+
plugin_map: Dict[str, List[WorkflowInfo]] = {}
|
|
74
|
+
|
|
75
|
+
for wf_info in workflows:
|
|
76
|
+
plugin_name = WorkflowFilterService.detect_plugin_name(wf_info)
|
|
77
|
+
|
|
78
|
+
if plugin_name not in plugin_map:
|
|
79
|
+
plugin_map[plugin_name] = []
|
|
80
|
+
plugin_map[plugin_name].append(wf_info)
|
|
81
|
+
|
|
82
|
+
return plugin_map
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def get_unique_plugin_names(workflows: List[WorkflowInfo]) -> Set[str]:
|
|
86
|
+
"""
|
|
87
|
+
Get all unique plugin names from a list of workflows.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
workflows: List of WorkflowInfo objects
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
Set of unique plugin names
|
|
94
|
+
"""
|
|
95
|
+
plugin_names = set()
|
|
96
|
+
for wf_info in workflows:
|
|
97
|
+
plugin_name = WorkflowFilterService.detect_plugin_name(wf_info)
|
|
98
|
+
plugin_names.add(plugin_name)
|
|
99
|
+
return plugin_names
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
def filter_by_plugin(workflows: List[WorkflowInfo], plugin_name: str) -> List[WorkflowInfo]:
|
|
103
|
+
"""
|
|
104
|
+
Filter workflows by plugin name.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
workflows: List of WorkflowInfo objects to filter
|
|
108
|
+
plugin_name: Plugin name to filter by (e.g., "Github")
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
List of workflows that belong to the specified plugin
|
|
112
|
+
"""
|
|
113
|
+
return [
|
|
114
|
+
wf for wf in workflows
|
|
115
|
+
if WorkflowFilterService.detect_plugin_name(wf) == plugin_name
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
@staticmethod
|
|
119
|
+
def remove_duplicates(workflows: List[WorkflowInfo]) -> List[WorkflowInfo]:
|
|
120
|
+
"""
|
|
121
|
+
Remove duplicate workflows by name, keeping first occurrence.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
workflows: List of WorkflowInfo objects that may contain duplicates
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
List of unique workflows (by name)
|
|
128
|
+
"""
|
|
129
|
+
seen = set()
|
|
130
|
+
unique_workflows = []
|
|
131
|
+
|
|
132
|
+
for wf in workflows:
|
|
133
|
+
if wf.name not in seen:
|
|
134
|
+
seen.add(wf.name)
|
|
135
|
+
unique_workflows.append(wf)
|
|
136
|
+
|
|
137
|
+
return unique_workflows
|