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.
Files changed (146) hide show
  1. titan_cli/__init__.py +3 -0
  2. titan_cli/__main__.py +4 -0
  3. titan_cli/ai/__init__.py +0 -0
  4. titan_cli/ai/agents/__init__.py +15 -0
  5. titan_cli/ai/agents/base.py +152 -0
  6. titan_cli/ai/client.py +170 -0
  7. titan_cli/ai/constants.py +56 -0
  8. titan_cli/ai/exceptions.py +48 -0
  9. titan_cli/ai/models.py +34 -0
  10. titan_cli/ai/oauth_helper.py +120 -0
  11. titan_cli/ai/providers/__init__.py +9 -0
  12. titan_cli/ai/providers/anthropic.py +117 -0
  13. titan_cli/ai/providers/base.py +75 -0
  14. titan_cli/ai/providers/gemini.py +278 -0
  15. titan_cli/cli.py +59 -0
  16. titan_cli/clients/__init__.py +1 -0
  17. titan_cli/clients/gcloud_client.py +52 -0
  18. titan_cli/core/__init__.py +3 -0
  19. titan_cli/core/config.py +274 -0
  20. titan_cli/core/discovery.py +51 -0
  21. titan_cli/core/errors.py +81 -0
  22. titan_cli/core/models.py +52 -0
  23. titan_cli/core/plugins/available.py +36 -0
  24. titan_cli/core/plugins/models.py +67 -0
  25. titan_cli/core/plugins/plugin_base.py +108 -0
  26. titan_cli/core/plugins/plugin_registry.py +163 -0
  27. titan_cli/core/secrets.py +141 -0
  28. titan_cli/core/workflows/__init__.py +22 -0
  29. titan_cli/core/workflows/models.py +88 -0
  30. titan_cli/core/workflows/project_step_source.py +86 -0
  31. titan_cli/core/workflows/workflow_exceptions.py +17 -0
  32. titan_cli/core/workflows/workflow_filter_service.py +137 -0
  33. titan_cli/core/workflows/workflow_registry.py +419 -0
  34. titan_cli/core/workflows/workflow_sources.py +307 -0
  35. titan_cli/engine/__init__.py +39 -0
  36. titan_cli/engine/builder.py +159 -0
  37. titan_cli/engine/context.py +82 -0
  38. titan_cli/engine/mock_context.py +176 -0
  39. titan_cli/engine/results.py +91 -0
  40. titan_cli/engine/steps/ai_assistant_step.py +185 -0
  41. titan_cli/engine/steps/command_step.py +93 -0
  42. titan_cli/engine/utils/__init__.py +3 -0
  43. titan_cli/engine/utils/venv.py +31 -0
  44. titan_cli/engine/workflow_executor.py +187 -0
  45. titan_cli/external_cli/__init__.py +0 -0
  46. titan_cli/external_cli/configs.py +17 -0
  47. titan_cli/external_cli/launcher.py +65 -0
  48. titan_cli/messages.py +121 -0
  49. titan_cli/ui/tui/__init__.py +205 -0
  50. titan_cli/ui/tui/__previews__/statusbar_preview.py +88 -0
  51. titan_cli/ui/tui/app.py +113 -0
  52. titan_cli/ui/tui/icons.py +70 -0
  53. titan_cli/ui/tui/screens/__init__.py +24 -0
  54. titan_cli/ui/tui/screens/ai_config.py +498 -0
  55. titan_cli/ui/tui/screens/ai_config_wizard.py +882 -0
  56. titan_cli/ui/tui/screens/base.py +110 -0
  57. titan_cli/ui/tui/screens/cli_launcher.py +151 -0
  58. titan_cli/ui/tui/screens/global_setup_wizard.py +363 -0
  59. titan_cli/ui/tui/screens/main_menu.py +162 -0
  60. titan_cli/ui/tui/screens/plugin_config_wizard.py +550 -0
  61. titan_cli/ui/tui/screens/plugin_management.py +377 -0
  62. titan_cli/ui/tui/screens/project_setup_wizard.py +686 -0
  63. titan_cli/ui/tui/screens/workflow_execution.py +592 -0
  64. titan_cli/ui/tui/screens/workflows.py +249 -0
  65. titan_cli/ui/tui/textual_components.py +537 -0
  66. titan_cli/ui/tui/textual_workflow_executor.py +405 -0
  67. titan_cli/ui/tui/theme.py +102 -0
  68. titan_cli/ui/tui/widgets/__init__.py +40 -0
  69. titan_cli/ui/tui/widgets/button.py +108 -0
  70. titan_cli/ui/tui/widgets/header.py +116 -0
  71. titan_cli/ui/tui/widgets/panel.py +81 -0
  72. titan_cli/ui/tui/widgets/status_bar.py +115 -0
  73. titan_cli/ui/tui/widgets/table.py +77 -0
  74. titan_cli/ui/tui/widgets/text.py +177 -0
  75. titan_cli/utils/__init__.py +0 -0
  76. titan_cli/utils/autoupdate.py +155 -0
  77. titan_cli-0.1.0.dist-info/METADATA +149 -0
  78. titan_cli-0.1.0.dist-info/RECORD +146 -0
  79. titan_cli-0.1.0.dist-info/WHEEL +4 -0
  80. titan_cli-0.1.0.dist-info/entry_points.txt +9 -0
  81. titan_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
  82. titan_plugin_git/__init__.py +1 -0
  83. titan_plugin_git/clients/__init__.py +8 -0
  84. titan_plugin_git/clients/git_client.py +772 -0
  85. titan_plugin_git/exceptions.py +40 -0
  86. titan_plugin_git/messages.py +112 -0
  87. titan_plugin_git/models.py +39 -0
  88. titan_plugin_git/plugin.py +118 -0
  89. titan_plugin_git/steps/__init__.py +1 -0
  90. titan_plugin_git/steps/ai_commit_message_step.py +171 -0
  91. titan_plugin_git/steps/branch_steps.py +104 -0
  92. titan_plugin_git/steps/commit_step.py +80 -0
  93. titan_plugin_git/steps/push_step.py +63 -0
  94. titan_plugin_git/steps/status_step.py +59 -0
  95. titan_plugin_git/workflows/__previews__/__init__.py +1 -0
  96. titan_plugin_git/workflows/__previews__/commit_ai_preview.py +124 -0
  97. titan_plugin_git/workflows/commit-ai.yaml +28 -0
  98. titan_plugin_github/__init__.py +11 -0
  99. titan_plugin_github/agents/__init__.py +6 -0
  100. titan_plugin_github/agents/config_loader.py +130 -0
  101. titan_plugin_github/agents/issue_generator.py +353 -0
  102. titan_plugin_github/agents/pr_agent.py +528 -0
  103. titan_plugin_github/clients/__init__.py +8 -0
  104. titan_plugin_github/clients/github_client.py +1105 -0
  105. titan_plugin_github/config/__init__.py +0 -0
  106. titan_plugin_github/config/pr_agent.toml +85 -0
  107. titan_plugin_github/exceptions.py +28 -0
  108. titan_plugin_github/messages.py +88 -0
  109. titan_plugin_github/models.py +330 -0
  110. titan_plugin_github/plugin.py +131 -0
  111. titan_plugin_github/steps/__init__.py +12 -0
  112. titan_plugin_github/steps/ai_pr_step.py +172 -0
  113. titan_plugin_github/steps/create_pr_step.py +86 -0
  114. titan_plugin_github/steps/github_prompt_steps.py +171 -0
  115. titan_plugin_github/steps/issue_steps.py +143 -0
  116. titan_plugin_github/steps/preview_step.py +40 -0
  117. titan_plugin_github/utils.py +82 -0
  118. titan_plugin_github/workflows/__previews__/__init__.py +1 -0
  119. titan_plugin_github/workflows/__previews__/create_pr_ai_preview.py +140 -0
  120. titan_plugin_github/workflows/create-issue-ai.yaml +32 -0
  121. titan_plugin_github/workflows/create-pr-ai.yaml +49 -0
  122. titan_plugin_jira/__init__.py +8 -0
  123. titan_plugin_jira/agents/__init__.py +6 -0
  124. titan_plugin_jira/agents/config_loader.py +154 -0
  125. titan_plugin_jira/agents/jira_agent.py +553 -0
  126. titan_plugin_jira/agents/prompts.py +364 -0
  127. titan_plugin_jira/agents/response_parser.py +435 -0
  128. titan_plugin_jira/agents/token_tracker.py +223 -0
  129. titan_plugin_jira/agents/validators.py +246 -0
  130. titan_plugin_jira/clients/jira_client.py +745 -0
  131. titan_plugin_jira/config/jira_agent.toml +92 -0
  132. titan_plugin_jira/config/templates/issue_analysis.md.j2 +78 -0
  133. titan_plugin_jira/exceptions.py +37 -0
  134. titan_plugin_jira/formatters/__init__.py +6 -0
  135. titan_plugin_jira/formatters/markdown_formatter.py +245 -0
  136. titan_plugin_jira/messages.py +115 -0
  137. titan_plugin_jira/models.py +89 -0
  138. titan_plugin_jira/plugin.py +264 -0
  139. titan_plugin_jira/steps/ai_analyze_issue_step.py +105 -0
  140. titan_plugin_jira/steps/get_issue_step.py +82 -0
  141. titan_plugin_jira/steps/prompt_select_issue_step.py +80 -0
  142. titan_plugin_jira/steps/search_saved_query_step.py +238 -0
  143. titan_plugin_jira/utils/__init__.py +13 -0
  144. titan_plugin_jira/utils/issue_sorter.py +140 -0
  145. titan_plugin_jira/utils/saved_queries.py +150 -0
  146. titan_plugin_jira/workflows/analyze-jira-issues.yaml +34 -0
@@ -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)
@@ -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)
@@ -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