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
titan_cli/core/config.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
# core/config.py
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Optional, List
|
|
4
|
+
import tomli
|
|
5
|
+
from .models import TitanConfigModel
|
|
6
|
+
from .plugins.plugin_registry import PluginRegistry
|
|
7
|
+
from .workflows import WorkflowRegistry, ProjectStepSource
|
|
8
|
+
from .secrets import SecretManager
|
|
9
|
+
from .errors import ConfigParseError, ConfigWriteError
|
|
10
|
+
|
|
11
|
+
class TitanConfig:
|
|
12
|
+
"""Manages Titan configuration with global + project merge"""
|
|
13
|
+
|
|
14
|
+
GLOBAL_CONFIG = Path.home() / ".titan" / "config.toml"
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
registry: Optional[PluginRegistry] = None,
|
|
19
|
+
global_config_path: Optional[Path] = None
|
|
20
|
+
):
|
|
21
|
+
# Core dependencies
|
|
22
|
+
self.registry = registry or PluginRegistry()
|
|
23
|
+
|
|
24
|
+
# These are initialized in load() after config is read
|
|
25
|
+
self.secrets = None # Set by load()
|
|
26
|
+
self._project_root = None # Set by load()
|
|
27
|
+
self._active_project_path = None # Set by load()
|
|
28
|
+
self._workflow_registry = None # Set by load()
|
|
29
|
+
self._plugin_warnings = []
|
|
30
|
+
|
|
31
|
+
# Use custom global config path if provided (for testing), otherwise use default
|
|
32
|
+
self._global_config_path = global_config_path or self.GLOBAL_CONFIG
|
|
33
|
+
|
|
34
|
+
# Initial load
|
|
35
|
+
self.load()
|
|
36
|
+
|
|
37
|
+
def load(self):
|
|
38
|
+
"""
|
|
39
|
+
Reloads the entire configuration from disk, including global config
|
|
40
|
+
and the project config from the current working directory.
|
|
41
|
+
"""
|
|
42
|
+
# Load global config
|
|
43
|
+
self.global_config = self._load_toml(self._global_config_path)
|
|
44
|
+
|
|
45
|
+
# Set project root to current working directory
|
|
46
|
+
self._project_root = Path.cwd()
|
|
47
|
+
self._active_project_path = Path.cwd()
|
|
48
|
+
|
|
49
|
+
# Look for project config in current directory
|
|
50
|
+
self.project_config_path = self._find_project_config(Path.cwd())
|
|
51
|
+
|
|
52
|
+
# Load project config if it exists
|
|
53
|
+
self.project_config = self._load_toml(self.project_config_path)
|
|
54
|
+
|
|
55
|
+
# Merge and validate final config
|
|
56
|
+
merged = self._merge_configs(self.global_config, self.project_config)
|
|
57
|
+
self.config = TitanConfigModel(**merged)
|
|
58
|
+
|
|
59
|
+
# Re-initialize dependencies that depend on the final config
|
|
60
|
+
# Use current working directory for secrets
|
|
61
|
+
secrets_path = Path.cwd()
|
|
62
|
+
self.secrets = SecretManager(project_path=secrets_path if secrets_path.is_dir() else None)
|
|
63
|
+
|
|
64
|
+
# Reset and re-initialize plugins
|
|
65
|
+
self.registry.reset()
|
|
66
|
+
self.registry.initialize_plugins(config=self, secrets=self.secrets)
|
|
67
|
+
self._plugin_warnings = self.registry.list_failed()
|
|
68
|
+
|
|
69
|
+
# Re-initialize WorkflowRegistry
|
|
70
|
+
# Use current working directory for workflows
|
|
71
|
+
workflow_path = Path.cwd()
|
|
72
|
+
project_step_source = ProjectStepSource(project_root=workflow_path)
|
|
73
|
+
self._workflow_registry = WorkflowRegistry(
|
|
74
|
+
project_root=workflow_path,
|
|
75
|
+
plugin_registry=self.registry,
|
|
76
|
+
project_step_source=project_step_source,
|
|
77
|
+
config=self
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _find_project_config(self, start_path: Optional[Path] = None) -> Optional[Path]:
|
|
82
|
+
"""Search for .titan/config.toml up the directory tree"""
|
|
83
|
+
current = (start_path or Path.cwd()).resolve()
|
|
84
|
+
|
|
85
|
+
# In a test environment, Path.cwd() might not be under /home/
|
|
86
|
+
# and we need a stopping condition.
|
|
87
|
+
sentinel = Path(current.root)
|
|
88
|
+
|
|
89
|
+
while current != current.parent and current != sentinel:
|
|
90
|
+
config_path = current / ".titan" / "config.toml"
|
|
91
|
+
if config_path.exists():
|
|
92
|
+
return config_path
|
|
93
|
+
current = current.parent
|
|
94
|
+
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
def _load_toml(self, path: Optional[Path]) -> dict:
|
|
98
|
+
"""Load TOML file, returning an empty dict on failure."""
|
|
99
|
+
if not path or not path.exists():
|
|
100
|
+
return {}
|
|
101
|
+
|
|
102
|
+
with open(path, "rb") as f:
|
|
103
|
+
try:
|
|
104
|
+
return tomli.load(f)
|
|
105
|
+
except tomli.TOMLDecodeError as e:
|
|
106
|
+
# Wrap the generic exception. Warnings will be handled by CLI commands.
|
|
107
|
+
_ = ConfigParseError(file_path=str(path), original_exception=e)
|
|
108
|
+
return {}
|
|
109
|
+
|
|
110
|
+
def _merge_configs(self, global_cfg: dict, project_cfg: dict) -> dict:
|
|
111
|
+
"""Merge global and project configs (project overrides global)"""
|
|
112
|
+
merged = {**global_cfg}
|
|
113
|
+
|
|
114
|
+
# Project config overrides global
|
|
115
|
+
for key, value in project_cfg.items():
|
|
116
|
+
if key == "plugins" and isinstance(value, dict):
|
|
117
|
+
merged_plugins = merged.setdefault("plugins", {})
|
|
118
|
+
|
|
119
|
+
for plugin_name, plugin_data_project in value.items():
|
|
120
|
+
plugin_data_global = merged_plugins.get(plugin_name, {})
|
|
121
|
+
|
|
122
|
+
# Start with a copy of the global plugin config for this specific plugin
|
|
123
|
+
# This ensures all global settings (like 'enabled') are carried over
|
|
124
|
+
# unless explicitly overridden.
|
|
125
|
+
final_plugin_data = {**plugin_data_global}
|
|
126
|
+
|
|
127
|
+
# Merge top-level keys from project config, excluding 'config'
|
|
128
|
+
for pk, pv in plugin_data_project.items():
|
|
129
|
+
if pk != "config":
|
|
130
|
+
final_plugin_data[pk] = pv
|
|
131
|
+
|
|
132
|
+
# Handle the nested 'config' dictionary separately (deep merge)
|
|
133
|
+
config_section_global = plugin_data_global.get("config", {})
|
|
134
|
+
config_section_project = plugin_data_project.get("config", {})
|
|
135
|
+
|
|
136
|
+
if config_section_global or config_section_project:
|
|
137
|
+
final_plugin_data["config"] = {**config_section_global, **config_section_project}
|
|
138
|
+
elif "config" in final_plugin_data: # If global had a config, and project didn't touch it
|
|
139
|
+
pass # Keep the global config
|
|
140
|
+
|
|
141
|
+
merged_plugins[plugin_name] = final_plugin_data
|
|
142
|
+
elif key == "ai" and isinstance(value, dict):
|
|
143
|
+
# AI config should be merged intelligently (global + project)
|
|
144
|
+
# Global AI config is always available, project can override specific settings
|
|
145
|
+
merged_ai = merged.setdefault("ai", {})
|
|
146
|
+
|
|
147
|
+
# Merge providers (project providers supplement global providers)
|
|
148
|
+
if "providers" in value:
|
|
149
|
+
merged_providers = merged_ai.setdefault("providers", {})
|
|
150
|
+
# Deep merge: preserve global fields, override with project fields
|
|
151
|
+
for provider_id, provider_data in value["providers"].items():
|
|
152
|
+
if provider_id in merged_providers:
|
|
153
|
+
# Provider exists in global: deep merge (extend, not replace)
|
|
154
|
+
merged_providers[provider_id] = {**merged_providers[provider_id], **provider_data}
|
|
155
|
+
else:
|
|
156
|
+
# New provider: just add it
|
|
157
|
+
merged_providers[provider_id] = provider_data
|
|
158
|
+
|
|
159
|
+
# Project can override default provider, otherwise keep global
|
|
160
|
+
if "default" in value:
|
|
161
|
+
merged_ai["default"] = value["default"]
|
|
162
|
+
|
|
163
|
+
# Merge any other AI settings
|
|
164
|
+
for ai_key, ai_value in value.items():
|
|
165
|
+
if ai_key not in ("providers", "default"):
|
|
166
|
+
merged_ai[ai_key] = ai_value
|
|
167
|
+
else:
|
|
168
|
+
merged[key] = value
|
|
169
|
+
|
|
170
|
+
return merged
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def project_root(self) -> Path:
|
|
174
|
+
"""Return the resolved project root path."""
|
|
175
|
+
return self._project_root
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def active_project_path(self) -> Optional[Path]:
|
|
179
|
+
"""Return the path to the currently active project."""
|
|
180
|
+
return self._active_project_path
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def workflows(self) -> WorkflowRegistry:
|
|
184
|
+
"""Access to workflow registry."""
|
|
185
|
+
return self._workflow_registry
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def get_enabled_plugins(self) -> List[str]:
|
|
189
|
+
"""Get list of enabled plugins"""
|
|
190
|
+
if not self.config or not self.config.plugins:
|
|
191
|
+
return []
|
|
192
|
+
return [
|
|
193
|
+
name for name, plugin_cfg in self.config.plugins.items()
|
|
194
|
+
if plugin_cfg.enabled
|
|
195
|
+
]
|
|
196
|
+
|
|
197
|
+
def get_plugin_warnings(self) -> List[str]:
|
|
198
|
+
"""Get list of failed or misconfigured plugins."""
|
|
199
|
+
return self._plugin_warnings
|
|
200
|
+
|
|
201
|
+
def get_project_name(self) -> Optional[str]:
|
|
202
|
+
"""Get the current project name from project config."""
|
|
203
|
+
if self.config and self.config.project:
|
|
204
|
+
return self.config.project.name
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
def _save_global_config(self):
|
|
208
|
+
"""Saves the current state of the global config to disk."""
|
|
209
|
+
if not self._global_config_path.parent.exists():
|
|
210
|
+
try:
|
|
211
|
+
self._global_config_path.parent.mkdir(parents=True)
|
|
212
|
+
except OSError as e:
|
|
213
|
+
raise ConfigWriteError(file_path=str(self._global_config_path), original_exception=e)
|
|
214
|
+
|
|
215
|
+
existing_global_config = {}
|
|
216
|
+
if self._global_config_path.exists():
|
|
217
|
+
try:
|
|
218
|
+
with open(self._global_config_path, "rb") as f:
|
|
219
|
+
import tomllib
|
|
220
|
+
existing_global_config = tomllib.load(f)
|
|
221
|
+
except Exception:
|
|
222
|
+
pass
|
|
223
|
+
|
|
224
|
+
# Save only AI configuration to global config
|
|
225
|
+
# Project-specific settings are stored in project's .titan/config.toml
|
|
226
|
+
config_to_save = self.config.model_dump(exclude_none=True)
|
|
227
|
+
|
|
228
|
+
if 'ai' in config_to_save:
|
|
229
|
+
existing_global_config['ai'] = config_to_save['ai']
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
with open(self._global_config_path, "wb") as f:
|
|
233
|
+
import tomli_w
|
|
234
|
+
tomli_w.dump(existing_global_config, f)
|
|
235
|
+
except ImportError as e:
|
|
236
|
+
raise ConfigWriteError(file_path=str(self._global_config_path), original_exception=e)
|
|
237
|
+
except Exception as e:
|
|
238
|
+
raise ConfigWriteError(file_path=str(self._global_config_path), original_exception=e)
|
|
239
|
+
|
|
240
|
+
def is_plugin_enabled(self, plugin_name: str) -> bool:
|
|
241
|
+
"""Check if plugin is enabled"""
|
|
242
|
+
if not self.config or not self.config.plugins:
|
|
243
|
+
return False
|
|
244
|
+
plugin_cfg = self.config.plugins.get(plugin_name)
|
|
245
|
+
return plugin_cfg.enabled if plugin_cfg else False
|
|
246
|
+
|
|
247
|
+
def get_status_bar_info(self) -> dict:
|
|
248
|
+
"""
|
|
249
|
+
Get information for the status bar display.
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
A dict with keys: 'ai_info', 'project_name'
|
|
253
|
+
Values are strings or None if not available.
|
|
254
|
+
"""
|
|
255
|
+
# Extract AI info
|
|
256
|
+
ai_info = None
|
|
257
|
+
if self.config and self.config.ai:
|
|
258
|
+
ai_config = self.config.ai
|
|
259
|
+
default_provider_id = ai_config.default
|
|
260
|
+
|
|
261
|
+
if default_provider_id and default_provider_id in ai_config.providers:
|
|
262
|
+
provider_config = ai_config.providers[default_provider_id]
|
|
263
|
+
provider_name = provider_config.provider
|
|
264
|
+
model = provider_config.model or "default"
|
|
265
|
+
ai_info = f"{provider_name}/{model}"
|
|
266
|
+
|
|
267
|
+
# Extract project name from project config
|
|
268
|
+
project_name = self.get_project_name()
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
'ai_info': ai_info,
|
|
272
|
+
'project_name': project_name
|
|
273
|
+
}
|
|
274
|
+
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Project Discovery Module
|
|
3
|
+
|
|
4
|
+
Scans a root directory to find and categorize projects based on whether
|
|
5
|
+
they are configured with Titan.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import List, Tuple
|
|
10
|
+
|
|
11
|
+
def discover_projects(root_path_str: str) -> Tuple[List[Path], List[Path]]:
|
|
12
|
+
"""
|
|
13
|
+
Scans a root directory and discovers configured and unconfigured projects.
|
|
14
|
+
|
|
15
|
+
An "unconfigured" project is identified as a directory containing a .git folder.
|
|
16
|
+
A "configured" project is one that also contains a .titan/config.toml file.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
root_path_str: The absolute path to the root directory to scan.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
A tuple containing two lists:
|
|
23
|
+
- A list of paths to configured projects.
|
|
24
|
+
- A list of paths to unconfigured projects.
|
|
25
|
+
"""
|
|
26
|
+
configured_projects: List[Path] = []
|
|
27
|
+
unconfigured_projects: List[Path] = []
|
|
28
|
+
|
|
29
|
+
root_path = Path(root_path_str).expanduser().resolve()
|
|
30
|
+
if not root_path.is_dir():
|
|
31
|
+
return [], []
|
|
32
|
+
|
|
33
|
+
# Iterate through items in the root directory
|
|
34
|
+
for item in root_path.iterdir():
|
|
35
|
+
try:
|
|
36
|
+
# We only care about directories
|
|
37
|
+
if item.is_dir():
|
|
38
|
+
is_git_repo = (item / ".git").is_dir()
|
|
39
|
+
is_titan_project = (item / ".titan" / "config.toml").is_file()
|
|
40
|
+
|
|
41
|
+
if is_titan_project:
|
|
42
|
+
# If it has a titan config, it's definitely a configured project
|
|
43
|
+
configured_projects.append(item)
|
|
44
|
+
elif is_git_repo:
|
|
45
|
+
# If it's a git repo but not a titan project, it's unconfigured
|
|
46
|
+
unconfigured_projects.append(item)
|
|
47
|
+
except PermissionError:
|
|
48
|
+
# Skip directories we don't have permission to access
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
return sorted(configured_projects), sorted(unconfigured_projects)
|
titan_cli/core/errors.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Custom Exception Types for Titan CLI
|
|
3
|
+
"""
|
|
4
|
+
from ..messages import msg
|
|
5
|
+
|
|
6
|
+
class TitanError(Exception):
|
|
7
|
+
"""Base exception for all application-specific errors."""
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
class PluginError(TitanError):
|
|
11
|
+
"""Base exception for plugin-related errors."""
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
class PluginLoadError(PluginError):
|
|
15
|
+
"""Raised when a plugin fails to load from an entry point."""
|
|
16
|
+
def __init__(self, plugin_name: str, original_exception: Exception):
|
|
17
|
+
self.plugin_name = plugin_name
|
|
18
|
+
self.original_exception = original_exception
|
|
19
|
+
# Do not call super().__init__ with message here, as it's formatted by __str__
|
|
20
|
+
|
|
21
|
+
def __str__(self) -> str:
|
|
22
|
+
return msg.Errors.PLUGIN_LOAD_FAILED.format(
|
|
23
|
+
plugin_name=self.plugin_name,
|
|
24
|
+
error=str(self.original_exception)
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
class PluginInitializationError(PluginError):
|
|
28
|
+
"""Raised when a plugin fails to initialize."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, plugin_name: str, original_exception: Exception):
|
|
31
|
+
self.plugin_name = plugin_name
|
|
32
|
+
self.original_exception = original_exception
|
|
33
|
+
# Do not call super().__init__ with message here, as it's formatted by __str__
|
|
34
|
+
|
|
35
|
+
def __str__(self) -> str:
|
|
36
|
+
return msg.Errors.PLUGIN_INIT_FAILED.format(
|
|
37
|
+
plugin_name=self.plugin_name,
|
|
38
|
+
error=str(self.original_exception)
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
class ConfigError(TitanError):
|
|
42
|
+
"""Base exception for configuration-related errors."""
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
class ConfigNotFoundError(ConfigError, FileNotFoundError):
|
|
46
|
+
"""Raised when a config file cannot be found."""
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
class ConfigParseError(ConfigError):
|
|
50
|
+
|
|
51
|
+
"""Raised when a config file cannot be parsed."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, file_path: str, original_exception: Exception):
|
|
54
|
+
|
|
55
|
+
self.file_path = file_path
|
|
56
|
+
self.original_exception = original_exception
|
|
57
|
+
|
|
58
|
+
message = msg.Errors.CONFIG_PARSE_ERROR.format(
|
|
59
|
+
file_path=file_path,
|
|
60
|
+
error=original_exception
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
super().__init__(message)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ConfigWriteError(ConfigError):
|
|
68
|
+
|
|
69
|
+
"""Raised when writing to a configuration file fails."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, file_path: str, original_exception: Exception):
|
|
72
|
+
|
|
73
|
+
self.file_path = file_path
|
|
74
|
+
self.original_exception = original_exception
|
|
75
|
+
|
|
76
|
+
message = msg.Errors.CONFIG_WRITE_FAILED.format(
|
|
77
|
+
path=file_path,
|
|
78
|
+
error=original_exception
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
super().__init__(message)
|
titan_cli/core/models.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# core/models.py
|
|
2
|
+
from pydantic import BaseModel, Field, model_validator
|
|
3
|
+
from typing import Optional, Dict
|
|
4
|
+
from .plugins.models import PluginConfig
|
|
5
|
+
|
|
6
|
+
class ProjectConfig(BaseModel):
|
|
7
|
+
"""
|
|
8
|
+
Represents the configuration for a specific project.
|
|
9
|
+
Defined in .titan/config.toml.
|
|
10
|
+
"""
|
|
11
|
+
name: str = Field(..., description="Name of the project.")
|
|
12
|
+
type: Optional[str] = Field("generic", description="Type of the project (e.g., 'fullstack', 'backend', 'frontend').")
|
|
13
|
+
|
|
14
|
+
class AIProviderConfig(BaseModel):
|
|
15
|
+
"""Configuración de un provider específico"""
|
|
16
|
+
name: str = Field(..., description="Nombre del provider (ej: 'Corporate Gemini')")
|
|
17
|
+
type: str = Field(..., description="'corporate' o 'individual'")
|
|
18
|
+
provider: str = Field(..., description="'anthropic', 'gemini', 'openai'")
|
|
19
|
+
model: Optional[str] = Field(None, description="Modelo a usar")
|
|
20
|
+
base_url: Optional[str] = Field(None, description="URL custom (solo corporate)")
|
|
21
|
+
max_tokens: int = Field(4096)
|
|
22
|
+
temperature: float = Field(0.7)
|
|
23
|
+
|
|
24
|
+
class AIConfig(BaseModel):
|
|
25
|
+
"""
|
|
26
|
+
Represents the configuration for AI provider integration.
|
|
27
|
+
Can be defined globally or per project.
|
|
28
|
+
"""
|
|
29
|
+
default: str = Field("default", description="ID del provider por defecto")
|
|
30
|
+
providers: Dict[str, AIProviderConfig] = Field(default_factory=dict)
|
|
31
|
+
|
|
32
|
+
@model_validator(mode='before')
|
|
33
|
+
def validate_default_provider(cls, values):
|
|
34
|
+
default_provider = values.get('default')
|
|
35
|
+
providers = values.get('providers')
|
|
36
|
+
|
|
37
|
+
if default_provider and providers:
|
|
38
|
+
if default_provider not in providers:
|
|
39
|
+
raise ValueError(f"Default provider '{default_provider}' not found in configured providers.")
|
|
40
|
+
elif default_provider and not providers:
|
|
41
|
+
raise ValueError("Cannot set a default provider when no providers are configured.")
|
|
42
|
+
return values
|
|
43
|
+
|
|
44
|
+
class TitanConfigModel(BaseModel):
|
|
45
|
+
"""
|
|
46
|
+
The main Pydantic model for the entire Titan CLI configuration.
|
|
47
|
+
This model validates the merged configuration from global and project sources.
|
|
48
|
+
"""
|
|
49
|
+
project: Optional[ProjectConfig] = Field(None, description="Project-specific configuration.")
|
|
50
|
+
ai: Optional[AIConfig] = Field(None, description="AI provider configuration.")
|
|
51
|
+
plugins: Dict[str, PluginConfig] = Field(default_factory=dict, description="Dictionary of plugin configurations.")
|
|
52
|
+
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This file contains a list of known, installable plugins for Titan CLI.
|
|
3
|
+
|
|
4
|
+
This acts as a centralized registry for the `install` command, so the CLI
|
|
5
|
+
knows what plugins are available to be installed via `pipx inject`.
|
|
6
|
+
"""
|
|
7
|
+
from typing import TypedDict, List
|
|
8
|
+
|
|
9
|
+
class KnownPlugin(TypedDict):
|
|
10
|
+
"""Represents a known plugin that can be installed."""
|
|
11
|
+
name: str
|
|
12
|
+
description: str
|
|
13
|
+
package_name: str
|
|
14
|
+
dependencies: List[str] # Plugin names that must be installed first
|
|
15
|
+
|
|
16
|
+
# This list should be updated when new official plugins are published.
|
|
17
|
+
KNOWN_PLUGINS: List[KnownPlugin] = [
|
|
18
|
+
{
|
|
19
|
+
"name": "git",
|
|
20
|
+
"description": "Provides core Git functionalities for workflows.",
|
|
21
|
+
"package_name": "titan-plugin-git",
|
|
22
|
+
"dependencies": []
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"name": "github",
|
|
26
|
+
"description": "Adds GitHub integration for pull requests and more.",
|
|
27
|
+
"package_name": "titan-plugin-github",
|
|
28
|
+
"dependencies": ["git"] # Requires git plugin
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"name": "jira",
|
|
32
|
+
"description": "JIRA integration for issue management.",
|
|
33
|
+
"package_name": "titan-plugin-jira",
|
|
34
|
+
"dependencies": []
|
|
35
|
+
},
|
|
36
|
+
]
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# titan_cli/core/plugins/models.py
|
|
2
|
+
from pydantic import BaseModel, Field, field_validator
|
|
3
|
+
from typing import Dict, Any, Optional
|
|
4
|
+
|
|
5
|
+
class PluginConfig(BaseModel):
|
|
6
|
+
"""
|
|
7
|
+
Represents the configuration for an individual plugin.
|
|
8
|
+
"""
|
|
9
|
+
enabled: bool = Field(True, description="Whether the plugin is enabled.")
|
|
10
|
+
config: Dict[str, Any] = Field(default_factory=dict, description="Plugin-specific configuration options.")
|
|
11
|
+
|
|
12
|
+
class GitPluginConfig(BaseModel):
|
|
13
|
+
"""Configuration for Git plugin."""
|
|
14
|
+
main_branch: str = Field("main", description="Main/default branch name")
|
|
15
|
+
default_remote: str = Field("origin", description="Default remote name")
|
|
16
|
+
|
|
17
|
+
class GitHubPluginConfig(BaseModel):
|
|
18
|
+
"""Configuration for GitHub plugin."""
|
|
19
|
+
repo_owner: str = Field(..., description="GitHub repository owner (user or organization).")
|
|
20
|
+
repo_name: str = Field(..., description="GitHub repository name.")
|
|
21
|
+
default_branch: str = Field(None, description="Default branch to use (e.g., 'main', 'develop').")
|
|
22
|
+
pr_template_path: str = Field(None, description="Path to PR template file relative to repository root (e.g., '.github/pull_request_template.md', 'docs/PR_TEMPLATE.md'). Defaults to '.github/pull_request_template.md'.")
|
|
23
|
+
auto_assign_prs: bool = Field(True, description="Automatically assign PRs to the author.")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class JiraPluginConfig(BaseModel):
|
|
27
|
+
"""
|
|
28
|
+
Configuration for JIRA plugin.
|
|
29
|
+
|
|
30
|
+
Credentials (base_url, email, api_token) should be configured at global level (~/.titan/config.toml).
|
|
31
|
+
Project-specific settings (default_project) can override at project level (.titan/config.toml).
|
|
32
|
+
"""
|
|
33
|
+
base_url: Optional[str] = Field(None, description="JIRA instance URL (e.g., 'https://jira.company.com')")
|
|
34
|
+
email: Optional[str] = Field(None, description="User email for authentication")
|
|
35
|
+
# api_token is stored in secrets, not in config.toml
|
|
36
|
+
# It appears in the JSON schema for interactive configuration but is optional in the model
|
|
37
|
+
api_token: Optional[str] = Field(None, description="JIRA API token (Personal Access Token)", json_schema_extra={"format": "password", "required_in_schema": True})
|
|
38
|
+
default_project: Optional[str] = Field(None, description="Default JIRA project key (e.g., 'ECAPP', 'PROJ')")
|
|
39
|
+
timeout: int = Field(30, description="Request timeout in seconds")
|
|
40
|
+
enable_cache: bool = Field(True, description="Enable caching for API responses")
|
|
41
|
+
cache_ttl: int = Field(300, description="Cache time-to-live in seconds")
|
|
42
|
+
|
|
43
|
+
@field_validator('base_url')
|
|
44
|
+
@classmethod
|
|
45
|
+
def validate_base_url(cls, v):
|
|
46
|
+
"""Validate base_url is configured and properly formatted."""
|
|
47
|
+
if not v:
|
|
48
|
+
raise ValueError(
|
|
49
|
+
"JIRA base_url not configured. "
|
|
50
|
+
"Please add [plugins.jira.config] section with base_url in ~/.titan/config.toml"
|
|
51
|
+
)
|
|
52
|
+
if not v.startswith(('http://', 'https://')):
|
|
53
|
+
raise ValueError("base_url must start with http:// or https://")
|
|
54
|
+
return v.rstrip('/') # Normalize trailing slash
|
|
55
|
+
|
|
56
|
+
@field_validator('email')
|
|
57
|
+
@classmethod
|
|
58
|
+
def validate_email(cls, v):
|
|
59
|
+
"""Validate email is configured and has valid format."""
|
|
60
|
+
if not v:
|
|
61
|
+
raise ValueError(
|
|
62
|
+
"JIRA email not configured. "
|
|
63
|
+
"Please add [plugins.jira.config] section with email in ~/.titan/config.toml"
|
|
64
|
+
)
|
|
65
|
+
if '@' not in v:
|
|
66
|
+
raise ValueError("email must be a valid email address")
|
|
67
|
+
return v.lower() # Normalize email to lowercase
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base interface for Titan plugins.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import Optional, Dict, Any, Callable
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TitanPlugin(ABC):
|
|
11
|
+
"""
|
|
12
|
+
Base class for all Titan plugins.
|
|
13
|
+
|
|
14
|
+
Plugins extend Titan CLI with:
|
|
15
|
+
- Service clients (Git, GitHub, Jira, etc.)
|
|
16
|
+
- Workflow steps (atomic operations)
|
|
17
|
+
|
|
18
|
+
Example:
|
|
19
|
+
class GitPlugin(TitanPlugin):
|
|
20
|
+
@property
|
|
21
|
+
def name(self) -> str:
|
|
22
|
+
return "git"
|
|
23
|
+
|
|
24
|
+
def get_client(self):
|
|
25
|
+
return GitClient()
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def name(self) -> str:
|
|
31
|
+
"""
|
|
32
|
+
Plugin unique identifier.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Plugin name (e.g., "git", "github", "jira")
|
|
36
|
+
"""
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def version(self) -> str:
|
|
41
|
+
"""Plugin version (default: "0.0.0")"""
|
|
42
|
+
return "0.0.0"
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def description(self) -> str:
|
|
46
|
+
"""Plugin description (default: empty)"""
|
|
47
|
+
return ""
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def dependencies(self) -> list[str]:
|
|
51
|
+
"""
|
|
52
|
+
Other plugins this plugin depends on.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
List of plugin names (e.g., ["git"] for GitHub plugin)
|
|
56
|
+
"""
|
|
57
|
+
return []
|
|
58
|
+
|
|
59
|
+
def initialize(self, config: Any, secrets: Any) -> None:
|
|
60
|
+
"""
|
|
61
|
+
Initialize plugin with configuration and secrets.
|
|
62
|
+
|
|
63
|
+
Called once when plugin is loaded by PluginRegistry.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
config: TitanConfig instance
|
|
67
|
+
secrets: SecretManager instance
|
|
68
|
+
"""
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
def get_client(self) -> Optional[Any]:
|
|
72
|
+
"""
|
|
73
|
+
Get the main client instance for this plugin.
|
|
74
|
+
|
|
75
|
+
This client will be injected into WorkflowContext.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Client instance or None
|
|
79
|
+
"""
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
def get_steps(self) -> Dict[str, Callable]:
|
|
83
|
+
"""
|
|
84
|
+
Get workflow steps provided by this plugin.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Dict mapping step name to step function
|
|
88
|
+
"""
|
|
89
|
+
return {}
|
|
90
|
+
|
|
91
|
+
def is_available(self) -> bool:
|
|
92
|
+
"""
|
|
93
|
+
Check if plugin is available/configured.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
True if plugin can be used
|
|
97
|
+
"""
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def workflows_path(self) -> Optional[Path]:
|
|
102
|
+
"""
|
|
103
|
+
Optional path to the directory containing workflow definitions for this plugin.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
Path to workflows directory or None if the plugin doesn't provide any.
|
|
107
|
+
"""
|
|
108
|
+
return None
|