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,162 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Main Menu Screen
|
|
3
|
+
|
|
4
|
+
The primary navigation screen for Titan TUI.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from textual.app import ComposeResult
|
|
8
|
+
from textual.widgets import OptionList
|
|
9
|
+
from textual.widgets.option_list import Option
|
|
10
|
+
from textual.containers import Container
|
|
11
|
+
|
|
12
|
+
from titan_cli.ui.tui.icons import Icons
|
|
13
|
+
from .base import BaseScreen
|
|
14
|
+
|
|
15
|
+
from .cli_launcher import CLILauncherScreen
|
|
16
|
+
from .ai_config import AIConfigScreen
|
|
17
|
+
from .plugin_management import PluginManagementScreen
|
|
18
|
+
|
|
19
|
+
class MainMenuScreen(BaseScreen):
|
|
20
|
+
"""
|
|
21
|
+
Main menu screen with navigation options.
|
|
22
|
+
|
|
23
|
+
Displays the primary actions available in Titan:
|
|
24
|
+
- Launch External CLI
|
|
25
|
+
- Project Management
|
|
26
|
+
- Workflows
|
|
27
|
+
- Plugin Management
|
|
28
|
+
- AI Configuration
|
|
29
|
+
- Switch Project
|
|
30
|
+
- Exit
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
BINDINGS = [
|
|
34
|
+
("q", "quit", "Quit"),
|
|
35
|
+
("escape", "quit", "Quit"),
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
CSS = """
|
|
39
|
+
MainMenuScreen {
|
|
40
|
+
align: center middle;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
#menu-container {
|
|
44
|
+
width: 70%;
|
|
45
|
+
height: 1fr;
|
|
46
|
+
background: $surface-lighten-1;
|
|
47
|
+
border: solid $primary;
|
|
48
|
+
margin: 1;
|
|
49
|
+
padding: 1 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
#menu-title {
|
|
53
|
+
text-align: center;
|
|
54
|
+
color: $primary;
|
|
55
|
+
text-style: bold;
|
|
56
|
+
margin-bottom: 1;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
OptionList {
|
|
60
|
+
height: auto;
|
|
61
|
+
border: none;
|
|
62
|
+
background: $surface-lighten-1;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
OptionList:focus {
|
|
66
|
+
border: none;
|
|
67
|
+
background: $surface-lighten-1;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
OptionList > .option-list--option {
|
|
71
|
+
padding: 1 2;
|
|
72
|
+
background: $surface-lighten-1;
|
|
73
|
+
border-left: none;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
OptionList > .option-list--option-highlighted {
|
|
77
|
+
background: $primary;
|
|
78
|
+
border-left: none;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
OptionList:focus > .option-list--option {
|
|
82
|
+
border-left: none;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
OptionList:focus > .option-list--option-highlighted {
|
|
86
|
+
border-left: none;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def compose_content(self) -> ComposeResult:
|
|
92
|
+
"""Compose the main menu content."""
|
|
93
|
+
with Container(id="menu-container"):
|
|
94
|
+
|
|
95
|
+
# Build menu options
|
|
96
|
+
options = [
|
|
97
|
+
Option("🚀 Launch External CLI", id="cli"),
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
# Only show Workflows if there are enabled plugins
|
|
101
|
+
installed_plugins = self.config.registry.list_installed()
|
|
102
|
+
enabled_plugins = [
|
|
103
|
+
p for p in installed_plugins if self.config.is_plugin_enabled(p)
|
|
104
|
+
]
|
|
105
|
+
if enabled_plugins:
|
|
106
|
+
options.append(Option(f"{Icons.WORKFLOW} Workflows", id="run_workflow"))
|
|
107
|
+
|
|
108
|
+
options.extend(
|
|
109
|
+
[
|
|
110
|
+
Option(f"{Icons.PLUGIN} Plugin Management", id="plugin_management"),
|
|
111
|
+
Option(f"{Icons.SETTINGS} AI Configuration", id="ai_config"),
|
|
112
|
+
]
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
yield OptionList(*options)
|
|
116
|
+
|
|
117
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
118
|
+
"""Handle menu option selection."""
|
|
119
|
+
action = event.option.id
|
|
120
|
+
|
|
121
|
+
if action == "exit":
|
|
122
|
+
self.app.exit()
|
|
123
|
+
elif action == "cli":
|
|
124
|
+
self.handle_cli_action()
|
|
125
|
+
elif action == "projects":
|
|
126
|
+
self.handle_projects_action()
|
|
127
|
+
elif action == "run_workflow":
|
|
128
|
+
self.handle_workflow_action()
|
|
129
|
+
elif action == "plugin_management":
|
|
130
|
+
self.handle_plugin_management_action()
|
|
131
|
+
elif action == "ai_config":
|
|
132
|
+
self.handle_ai_config_action()
|
|
133
|
+
|
|
134
|
+
def handle_cli_action(self) -> None:
|
|
135
|
+
"""Handle Launch External CLI action."""
|
|
136
|
+
self.app.push_screen(CLILauncherScreen(self.config))
|
|
137
|
+
|
|
138
|
+
def handle_projects_action(self) -> None:
|
|
139
|
+
"""Handle Project Management action."""
|
|
140
|
+
self.app.notify("Project management - Coming soon!")
|
|
141
|
+
|
|
142
|
+
def handle_workflow_action(self) -> None:
|
|
143
|
+
"""Handle Workflows action."""
|
|
144
|
+
from .workflows import WorkflowsScreen
|
|
145
|
+
|
|
146
|
+
self.app.push_screen(WorkflowsScreen(self.config))
|
|
147
|
+
|
|
148
|
+
def handle_plugin_management_action(self) -> None:
|
|
149
|
+
"""Handle Plugin Management action."""
|
|
150
|
+
self.app.push_screen(PluginManagementScreen(self.config))
|
|
151
|
+
|
|
152
|
+
def handle_ai_config_action(self) -> None:
|
|
153
|
+
"""Handle AI Configuration action."""
|
|
154
|
+
self.app.push_screen(AIConfigScreen(self.config))
|
|
155
|
+
|
|
156
|
+
def handle_switch_project_action(self) -> None:
|
|
157
|
+
"""Handle Switch Project action."""
|
|
158
|
+
self.app.notify("Switch project - Coming soon!")
|
|
159
|
+
|
|
160
|
+
def action_quit(self) -> None:
|
|
161
|
+
"""Quit the application."""
|
|
162
|
+
self.app.exit()
|
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Plugin Configuration Wizard Screen
|
|
3
|
+
|
|
4
|
+
Generic wizard that adapts to any plugin's configuration schema.
|
|
5
|
+
Each plugin can have different configuration steps based on its schema.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from textual.app import ComposeResult
|
|
10
|
+
from textual.widgets import Static, Input
|
|
11
|
+
from textual.containers import Container, Horizontal, VerticalScroll
|
|
12
|
+
from textual.binding import Binding
|
|
13
|
+
|
|
14
|
+
from titan_cli.ui.tui.icons import Icons
|
|
15
|
+
from titan_cli.ui.tui.widgets import Text, DimText, Button, BoldText
|
|
16
|
+
from .base import BaseScreen
|
|
17
|
+
|
|
18
|
+
# Use the same logger as project_setup_wizard
|
|
19
|
+
logger = logging.getLogger('titan_cli.ui.tui.screens.project_setup_wizard')
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class StepIndicator(Static):
|
|
23
|
+
"""Widget showing a single step with status indicator."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, step_number: int, title: str, status: str = "pending"):
|
|
26
|
+
self.step_number = step_number
|
|
27
|
+
self.title = title
|
|
28
|
+
self.status = status
|
|
29
|
+
super().__init__()
|
|
30
|
+
|
|
31
|
+
def render(self) -> str:
|
|
32
|
+
"""Render the step with appropriate icon."""
|
|
33
|
+
if self.status == "completed":
|
|
34
|
+
icon = Icons.SUCCESS
|
|
35
|
+
style = "dim"
|
|
36
|
+
elif self.status == "in_progress":
|
|
37
|
+
icon = Icons.RUNNING
|
|
38
|
+
style = "bold cyan"
|
|
39
|
+
else: # pending
|
|
40
|
+
icon = Icons.PENDING
|
|
41
|
+
style = "dim"
|
|
42
|
+
|
|
43
|
+
return f"[{style}]{icon} {self.step_number}. {self.title}[/{style}]"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class PluginConfigWizardScreen(BaseScreen):
|
|
47
|
+
"""
|
|
48
|
+
Generic wizard for configuring any plugin.
|
|
49
|
+
|
|
50
|
+
Adapts its steps based on the plugin's configuration schema.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
BINDINGS = [
|
|
54
|
+
Binding("escape", "cancel", "Cancel"),
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
CSS = """
|
|
58
|
+
PluginConfigWizardScreen {
|
|
59
|
+
align: center middle;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
#wizard-container {
|
|
63
|
+
width: 100%;
|
|
64
|
+
height: 1fr;
|
|
65
|
+
background: $surface-lighten-1;
|
|
66
|
+
padding: 0 2 1 2;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
#steps-panel {
|
|
70
|
+
width: 20%;
|
|
71
|
+
height: 100%;
|
|
72
|
+
border: round $primary;
|
|
73
|
+
border-title-align: center;
|
|
74
|
+
background: $surface-lighten-1;
|
|
75
|
+
padding: 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
#steps-content {
|
|
79
|
+
padding: 1;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
StepIndicator {
|
|
83
|
+
height: auto;
|
|
84
|
+
margin-bottom: 1;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
#content-panel {
|
|
88
|
+
width: 80%;
|
|
89
|
+
height: 100%;
|
|
90
|
+
border: round $primary;
|
|
91
|
+
border-title-align: center;
|
|
92
|
+
background: $surface-lighten-1;
|
|
93
|
+
padding: 0;
|
|
94
|
+
layout: vertical;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
#content-scroll {
|
|
98
|
+
height: 1fr;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
#content-area {
|
|
102
|
+
padding: 1;
|
|
103
|
+
height: auto;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
#content-title {
|
|
107
|
+
color: $accent;
|
|
108
|
+
text-style: bold;
|
|
109
|
+
margin-bottom: 2;
|
|
110
|
+
height: auto;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
#content-body {
|
|
114
|
+
height: auto;
|
|
115
|
+
margin-bottom: 2;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
#button-container {
|
|
119
|
+
height: auto;
|
|
120
|
+
padding: 1 2;
|
|
121
|
+
background: $surface-lighten-1;
|
|
122
|
+
border-top: solid $primary;
|
|
123
|
+
align: right middle;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.field-label {
|
|
127
|
+
margin-top: 1;
|
|
128
|
+
margin-bottom: 0;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
Input {
|
|
132
|
+
width: 100%;
|
|
133
|
+
margin-bottom: 2;
|
|
134
|
+
border: solid $accent;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
Input:focus {
|
|
138
|
+
border: solid $primary;
|
|
139
|
+
}
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
def __init__(self, config, plugin_name: str):
|
|
143
|
+
super().__init__(
|
|
144
|
+
config,
|
|
145
|
+
title=f"{Icons.SETTINGS} Configure {plugin_name}",
|
|
146
|
+
show_back=False,
|
|
147
|
+
show_status_bar=False
|
|
148
|
+
)
|
|
149
|
+
self.plugin_name = plugin_name
|
|
150
|
+
self.current_step = 0
|
|
151
|
+
self.config_data = {}
|
|
152
|
+
self.steps = []
|
|
153
|
+
self.schema = None
|
|
154
|
+
self.properties = {}
|
|
155
|
+
self.required_fields = []
|
|
156
|
+
|
|
157
|
+
def compose_content(self) -> ComposeResult:
|
|
158
|
+
"""Compose the wizard screen with two panels."""
|
|
159
|
+
with Container(id="wizard-container"):
|
|
160
|
+
with Horizontal():
|
|
161
|
+
# Left panel: Steps
|
|
162
|
+
left_panel = VerticalScroll(id="steps-panel")
|
|
163
|
+
left_panel.border_title = "Configuration Steps"
|
|
164
|
+
with left_panel:
|
|
165
|
+
with Container(id="steps-content"):
|
|
166
|
+
# Steps will be added dynamically
|
|
167
|
+
pass
|
|
168
|
+
|
|
169
|
+
# Right panel: Content
|
|
170
|
+
right_panel = Container(id="content-panel")
|
|
171
|
+
right_panel.border_title = "Configuration"
|
|
172
|
+
with right_panel:
|
|
173
|
+
with VerticalScroll(id="content-scroll"):
|
|
174
|
+
with Container(id="content-area"):
|
|
175
|
+
yield Static("", id="content-title")
|
|
176
|
+
yield Container(id="content-body")
|
|
177
|
+
|
|
178
|
+
# Bottom buttons
|
|
179
|
+
with Horizontal(id="button-container"):
|
|
180
|
+
yield Button("Back", variant="default", id="back-button", disabled=True)
|
|
181
|
+
yield Button("Next", variant="primary", id="next-button")
|
|
182
|
+
yield Button("Cancel", variant="default", id="cancel-button")
|
|
183
|
+
|
|
184
|
+
def on_mount(self) -> None:
|
|
185
|
+
"""Load plugin schema and build steps."""
|
|
186
|
+
# Get plugin instance directly from registry's internal dict
|
|
187
|
+
logger.debug(f"PluginConfigWizard mounted for: '{self.plugin_name}'")
|
|
188
|
+
logger.debug(f"Registry plugins: {list(self.config.registry._plugins.keys())}")
|
|
189
|
+
|
|
190
|
+
plugin = self.config.registry._plugins.get(self.plugin_name)
|
|
191
|
+
|
|
192
|
+
if not plugin:
|
|
193
|
+
available = list(self.config.registry._plugins.keys())
|
|
194
|
+
logger.error(f"Plugin '{self.plugin_name}' not found. Available: {available}")
|
|
195
|
+
self.app.notify(f"Plugin '{self.plugin_name}' not found", severity="error")
|
|
196
|
+
self.dismiss(result=False)
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
# Check if plugin has configuration schema
|
|
200
|
+
if not hasattr(plugin, "get_config_schema"):
|
|
201
|
+
logger.debug(f"Plugin '{self.plugin_name}' has no config schema")
|
|
202
|
+
self.dismiss(result=True)
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
self.schema = plugin.get_config_schema()
|
|
207
|
+
logger.debug(f"Got schema for '{self.plugin_name}': {self.schema}")
|
|
208
|
+
except Exception as e:
|
|
209
|
+
logger.error(f"Failed to get config schema: {e}")
|
|
210
|
+
self.app.notify(f"Failed to get config schema: {e}", severity="error")
|
|
211
|
+
self.dismiss(result=False)
|
|
212
|
+
return
|
|
213
|
+
|
|
214
|
+
self.properties = self.schema.get("properties", {})
|
|
215
|
+
self.required_fields = self.schema.get("required", [])
|
|
216
|
+
|
|
217
|
+
logger.debug(f"Properties: {list(self.properties.keys())}")
|
|
218
|
+
logger.debug(f"Required fields: {self.required_fields}")
|
|
219
|
+
|
|
220
|
+
if not self.properties:
|
|
221
|
+
logger.debug(f"Plugin '{self.plugin_name}' has no config fields")
|
|
222
|
+
self.dismiss(result=True)
|
|
223
|
+
return
|
|
224
|
+
|
|
225
|
+
# Build steps based on field types
|
|
226
|
+
self._build_steps()
|
|
227
|
+
self._render_step_indicators()
|
|
228
|
+
self.load_step(0)
|
|
229
|
+
|
|
230
|
+
def _build_steps(self):
|
|
231
|
+
"""Build wizard steps based on plugin configuration schema."""
|
|
232
|
+
# Group fields by type/category
|
|
233
|
+
# For now, we'll create one step per field for simplicity
|
|
234
|
+
# In the future, plugins could define custom step grouping
|
|
235
|
+
|
|
236
|
+
for field_name in self.properties.keys():
|
|
237
|
+
self.steps.append({
|
|
238
|
+
"id": field_name,
|
|
239
|
+
"title": field_name.replace("_", " ").title(),
|
|
240
|
+
"field": field_name
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
# Add review step
|
|
244
|
+
self.steps.append({
|
|
245
|
+
"id": "review",
|
|
246
|
+
"title": "Review",
|
|
247
|
+
"field": None
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
def _render_step_indicators(self):
|
|
251
|
+
"""Render step indicators in the left panel."""
|
|
252
|
+
steps_content = self.query_one("#steps-content", Container)
|
|
253
|
+
|
|
254
|
+
for i, step in enumerate(self.steps, 1):
|
|
255
|
+
status = "in_progress" if i == 1 else "pending"
|
|
256
|
+
steps_content.mount(StepIndicator(i, step["title"], status=status))
|
|
257
|
+
|
|
258
|
+
def load_step(self, step_index: int):
|
|
259
|
+
"""Load content for the given step."""
|
|
260
|
+
self.current_step = step_index
|
|
261
|
+
step = self.steps[step_index]
|
|
262
|
+
|
|
263
|
+
# Update step indicators
|
|
264
|
+
for i, indicator in enumerate(self.query(StepIndicator)):
|
|
265
|
+
if i < step_index:
|
|
266
|
+
indicator.status = "completed"
|
|
267
|
+
elif i == step_index:
|
|
268
|
+
indicator.status = "in_progress"
|
|
269
|
+
else:
|
|
270
|
+
indicator.status = "pending"
|
|
271
|
+
indicator.refresh()
|
|
272
|
+
|
|
273
|
+
# Update buttons
|
|
274
|
+
back_button = self.query_one("#back-button", Button)
|
|
275
|
+
back_button.disabled = (step_index == 0)
|
|
276
|
+
|
|
277
|
+
next_button = self.query_one("#next-button", Button)
|
|
278
|
+
if step_index == len(self.steps) - 1:
|
|
279
|
+
next_button.label = "Save"
|
|
280
|
+
else:
|
|
281
|
+
next_button.label = "Next"
|
|
282
|
+
|
|
283
|
+
# Load step content
|
|
284
|
+
if step["id"] == "review":
|
|
285
|
+
self.load_review_step()
|
|
286
|
+
else:
|
|
287
|
+
self.load_field_step(step["field"])
|
|
288
|
+
|
|
289
|
+
def load_field_step(self, field_name: str):
|
|
290
|
+
"""Load a configuration field step."""
|
|
291
|
+
content_title = self.query_one("#content-title", Static)
|
|
292
|
+
content_body = self.query_one("#content-body", Container)
|
|
293
|
+
|
|
294
|
+
content_title.update(field_name.replace("_", " ").title())
|
|
295
|
+
content_body.remove_children()
|
|
296
|
+
|
|
297
|
+
field_schema = self.properties[field_name]
|
|
298
|
+
description = field_schema.get("description", "")
|
|
299
|
+
field_format = field_schema.get("format", "")
|
|
300
|
+
default_value = field_schema.get("default")
|
|
301
|
+
is_required = field_name in self.required_fields
|
|
302
|
+
|
|
303
|
+
# Detect if secret
|
|
304
|
+
is_secret = (
|
|
305
|
+
"token" in field_name.lower() or
|
|
306
|
+
"password" in field_name.lower() or
|
|
307
|
+
"secret" in field_name.lower() or
|
|
308
|
+
"api_key" in field_name.lower() or
|
|
309
|
+
field_format == "password"
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
# Show description
|
|
313
|
+
if description:
|
|
314
|
+
desc_text = Text(f"{description}\n")
|
|
315
|
+
content_body.mount(desc_text)
|
|
316
|
+
|
|
317
|
+
# Show if required
|
|
318
|
+
if is_required:
|
|
319
|
+
req_text = BoldText("This field is required.")
|
|
320
|
+
content_body.mount(req_text)
|
|
321
|
+
else:
|
|
322
|
+
opt_text = DimText("This field is optional.")
|
|
323
|
+
content_body.mount(opt_text)
|
|
324
|
+
|
|
325
|
+
# Check for existing value
|
|
326
|
+
current_value = self.config_data.get(field_name)
|
|
327
|
+
if current_value is None:
|
|
328
|
+
current_value = default_value
|
|
329
|
+
|
|
330
|
+
# For secrets, check keychain
|
|
331
|
+
if is_secret:
|
|
332
|
+
project_name = self.config.get_project_name()
|
|
333
|
+
secret_key = f"{self.plugin_name}_{field_name}"
|
|
334
|
+
keychain_key = f"{project_name}_{secret_key}" if project_name else secret_key
|
|
335
|
+
existing_secret = self.config.secrets.get(keychain_key) or self.config.secrets.get(secret_key)
|
|
336
|
+
|
|
337
|
+
if existing_secret:
|
|
338
|
+
info = DimText("\n\nAlready configured. Leave blank to keep existing value.")
|
|
339
|
+
content_body.mount(info)
|
|
340
|
+
|
|
341
|
+
# Create input
|
|
342
|
+
input_value = ""
|
|
343
|
+
if not is_secret and current_value is not None:
|
|
344
|
+
input_value = str(current_value)
|
|
345
|
+
|
|
346
|
+
input_widget = Input(
|
|
347
|
+
value=input_value,
|
|
348
|
+
placeholder=f"Enter {field_name}...",
|
|
349
|
+
password=is_secret,
|
|
350
|
+
id=f"input-{field_name}"
|
|
351
|
+
)
|
|
352
|
+
input_widget.styles.margin = (2, 0, 0, 0)
|
|
353
|
+
content_body.mount(input_widget)
|
|
354
|
+
|
|
355
|
+
# Focus input
|
|
356
|
+
self.call_after_refresh(lambda: input_widget.focus())
|
|
357
|
+
|
|
358
|
+
def load_review_step(self):
|
|
359
|
+
"""Load review step showing all configuration."""
|
|
360
|
+
content_title = self.query_one("#content-title", Static)
|
|
361
|
+
content_body = self.query_one("#content-body", Container)
|
|
362
|
+
|
|
363
|
+
content_title.update("Review Configuration")
|
|
364
|
+
content_body.remove_children()
|
|
365
|
+
|
|
366
|
+
desc = Text("Please review your configuration before saving.\n")
|
|
367
|
+
content_body.mount(desc)
|
|
368
|
+
|
|
369
|
+
# Show all configured values
|
|
370
|
+
for field_name, value in self.config_data.items():
|
|
371
|
+
is_secret = (
|
|
372
|
+
"token" in field_name.lower() or
|
|
373
|
+
"password" in field_name.lower() or
|
|
374
|
+
"secret" in field_name.lower() or
|
|
375
|
+
"api_key" in field_name.lower()
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
label = BoldText(f"\n{field_name.replace('_', ' ').title()}:")
|
|
379
|
+
content_body.mount(label)
|
|
380
|
+
|
|
381
|
+
if is_secret:
|
|
382
|
+
value_text = DimText(" ••••••••••••")
|
|
383
|
+
else:
|
|
384
|
+
value_text = DimText(f" {value}")
|
|
385
|
+
content_body.mount(value_text)
|
|
386
|
+
|
|
387
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
388
|
+
"""Handle Enter key in input fields."""
|
|
389
|
+
self.handle_next()
|
|
390
|
+
|
|
391
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
392
|
+
"""Handle button presses."""
|
|
393
|
+
if event.button.id == "next-button":
|
|
394
|
+
self.handle_next()
|
|
395
|
+
elif event.button.id == "back-button":
|
|
396
|
+
self.handle_back()
|
|
397
|
+
elif event.button.id == "cancel-button":
|
|
398
|
+
self.action_cancel()
|
|
399
|
+
|
|
400
|
+
def handle_next(self) -> None:
|
|
401
|
+
"""Move to next step or save."""
|
|
402
|
+
# Validate and save current step
|
|
403
|
+
if not self.validate_and_save_step():
|
|
404
|
+
return
|
|
405
|
+
|
|
406
|
+
# If on last step, save configuration
|
|
407
|
+
if self.current_step == len(self.steps) - 1:
|
|
408
|
+
self.save_configuration()
|
|
409
|
+
return
|
|
410
|
+
|
|
411
|
+
# Move to next step
|
|
412
|
+
if self.current_step < len(self.steps) - 1:
|
|
413
|
+
self.load_step(self.current_step + 1)
|
|
414
|
+
|
|
415
|
+
def validate_and_save_step(self) -> bool:
|
|
416
|
+
"""Validate and save current step data."""
|
|
417
|
+
step = self.steps[self.current_step]
|
|
418
|
+
|
|
419
|
+
if step["id"] == "review":
|
|
420
|
+
# No validation needed
|
|
421
|
+
return True
|
|
422
|
+
|
|
423
|
+
# Get field input
|
|
424
|
+
field_name = step["field"]
|
|
425
|
+
field_schema = self.properties[field_name]
|
|
426
|
+
is_required = field_name in self.required_fields
|
|
427
|
+
|
|
428
|
+
try:
|
|
429
|
+
input_widget = self.query_one(f"#input-{field_name}", Input)
|
|
430
|
+
value = input_widget.value.strip()
|
|
431
|
+
|
|
432
|
+
# Check for existing secret
|
|
433
|
+
is_secret = (
|
|
434
|
+
"token" in field_name.lower() or
|
|
435
|
+
"password" in field_name.lower() or
|
|
436
|
+
"secret" in field_name.lower() or
|
|
437
|
+
"api_key" in field_name.lower()
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
if is_secret:
|
|
441
|
+
project_name = self.config.get_project_name()
|
|
442
|
+
secret_key = f"{self.plugin_name}_{field_name}"
|
|
443
|
+
keychain_key = f"{project_name}_{secret_key}" if project_name else secret_key
|
|
444
|
+
existing_secret = self.config.secrets.get(keychain_key) or self.config.secrets.get(secret_key)
|
|
445
|
+
|
|
446
|
+
if not value and existing_secret:
|
|
447
|
+
# Keep existing
|
|
448
|
+
self.config_data[field_name] = {"_is_secret": True, "_existing": True}
|
|
449
|
+
return True
|
|
450
|
+
elif not value and is_required:
|
|
451
|
+
self.app.notify(f"{field_name} is required", severity="warning")
|
|
452
|
+
return False
|
|
453
|
+
elif value:
|
|
454
|
+
self.config_data[field_name] = {"_is_secret": True, "_value": value}
|
|
455
|
+
return True
|
|
456
|
+
return True
|
|
457
|
+
|
|
458
|
+
# Validate required
|
|
459
|
+
if is_required and not value:
|
|
460
|
+
self.app.notify(f"{field_name} is required", severity="warning")
|
|
461
|
+
return False
|
|
462
|
+
|
|
463
|
+
# Skip empty optional fields
|
|
464
|
+
if not value:
|
|
465
|
+
return True
|
|
466
|
+
|
|
467
|
+
# Type conversion
|
|
468
|
+
field_type = field_schema.get("type")
|
|
469
|
+
if field_type == "integer":
|
|
470
|
+
try:
|
|
471
|
+
value = int(value)
|
|
472
|
+
except ValueError:
|
|
473
|
+
self.app.notify(f"{field_name} must be a number", severity="warning")
|
|
474
|
+
return False
|
|
475
|
+
elif field_type == "boolean":
|
|
476
|
+
value = value.lower() in ("true", "yes", "1")
|
|
477
|
+
|
|
478
|
+
self.config_data[field_name] = value
|
|
479
|
+
return True
|
|
480
|
+
|
|
481
|
+
except Exception:
|
|
482
|
+
self.app.notify("Please enter a value", severity="error")
|
|
483
|
+
return False
|
|
484
|
+
|
|
485
|
+
def handle_back(self) -> None:
|
|
486
|
+
"""Move to previous step."""
|
|
487
|
+
if self.current_step > 0:
|
|
488
|
+
self.load_step(self.current_step - 1)
|
|
489
|
+
|
|
490
|
+
def save_configuration(self) -> None:
|
|
491
|
+
"""Save plugin configuration."""
|
|
492
|
+
import tomli
|
|
493
|
+
import tomli_w
|
|
494
|
+
|
|
495
|
+
try:
|
|
496
|
+
project_cfg_path = self.config.project_config_path
|
|
497
|
+
if not project_cfg_path:
|
|
498
|
+
self.app.notify("No project config found", severity="error")
|
|
499
|
+
return
|
|
500
|
+
|
|
501
|
+
project_cfg_dict = {}
|
|
502
|
+
if project_cfg_path.exists():
|
|
503
|
+
with open(project_cfg_path, "rb") as f:
|
|
504
|
+
project_cfg_dict = tomli.load(f)
|
|
505
|
+
|
|
506
|
+
plugins_table = project_cfg_dict.setdefault("plugins", {})
|
|
507
|
+
plugin_specific_table = plugins_table.setdefault(self.plugin_name, {})
|
|
508
|
+
plugin_config_table = plugin_specific_table.setdefault("config", {})
|
|
509
|
+
|
|
510
|
+
# Separate secrets from regular config
|
|
511
|
+
secrets_to_save = {}
|
|
512
|
+
config_values = {}
|
|
513
|
+
|
|
514
|
+
for field_name, value in self.config_data.items():
|
|
515
|
+
if isinstance(value, dict) and value.get("_is_secret"):
|
|
516
|
+
secret_key = f"{self.plugin_name}_{field_name}"
|
|
517
|
+
if value.get("_existing"):
|
|
518
|
+
# Keep existing secret
|
|
519
|
+
project_name = self.config.get_project_name()
|
|
520
|
+
keychain_key = f"{project_name}_{secret_key}" if project_name else secret_key
|
|
521
|
+
existing = self.config.secrets.get(keychain_key) or self.config.secrets.get(secret_key)
|
|
522
|
+
if existing:
|
|
523
|
+
secrets_to_save[secret_key] = existing
|
|
524
|
+
else:
|
|
525
|
+
secrets_to_save[secret_key] = value["_value"]
|
|
526
|
+
else:
|
|
527
|
+
config_values[field_name] = value
|
|
528
|
+
|
|
529
|
+
# Update config
|
|
530
|
+
plugin_config_table.update(config_values)
|
|
531
|
+
|
|
532
|
+
# Write config file
|
|
533
|
+
with open(project_cfg_path, "wb") as f:
|
|
534
|
+
tomli_w.dump(project_cfg_dict, f)
|
|
535
|
+
|
|
536
|
+
# Save secrets
|
|
537
|
+
project_name = self.config.get_project_name()
|
|
538
|
+
for secret_key, secret_value in secrets_to_save.items():
|
|
539
|
+
keychain_key = f"{project_name}_{secret_key}" if project_name else secret_key
|
|
540
|
+
self.config.secrets.set(keychain_key, secret_value, scope="user")
|
|
541
|
+
|
|
542
|
+
self.app.notify(f"Plugin '{self.plugin_name}' configured successfully!", severity="information")
|
|
543
|
+
self.dismiss(result=True)
|
|
544
|
+
|
|
545
|
+
except Exception as e:
|
|
546
|
+
self.app.notify(f"Failed to save configuration: {e}", severity="error")
|
|
547
|
+
|
|
548
|
+
def action_cancel(self) -> None:
|
|
549
|
+
"""Cancel configuration."""
|
|
550
|
+
self.dismiss(result=False)
|