llamactl 0.2.7a1__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.
- llama_deploy/cli/__init__.py +32 -0
- llama_deploy/cli/client.py +173 -0
- llama_deploy/cli/commands.py +549 -0
- llama_deploy/cli/config.py +173 -0
- llama_deploy/cli/debug.py +16 -0
- llama_deploy/cli/env.py +30 -0
- llama_deploy/cli/interactive_prompts/utils.py +86 -0
- llama_deploy/cli/options.py +21 -0
- llama_deploy/cli/textual/deployment_form.py +409 -0
- llama_deploy/cli/textual/git_validation.py +357 -0
- llama_deploy/cli/textual/github_callback_server.py +207 -0
- llama_deploy/cli/textual/llama_loader.py +52 -0
- llama_deploy/cli/textual/profile_form.py +171 -0
- llama_deploy/cli/textual/secrets_form.py +186 -0
- llama_deploy/cli/textual/styles.tcss +162 -0
- llamactl-0.2.7a1.dist-info/METADATA +122 -0
- llamactl-0.2.7a1.dist-info/RECORD +19 -0
- llamactl-0.2.7a1.dist-info/WHEEL +4 -0
- llamactl-0.2.7a1.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"""Git repository validation widget for Textual CLI"""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import webbrowser
|
|
5
|
+
from typing import Literal, cast
|
|
6
|
+
|
|
7
|
+
from textual.app import ComposeResult
|
|
8
|
+
from textual.containers import HorizontalGroup, Widget
|
|
9
|
+
from textual.widgets import Button, Input, Label, Static
|
|
10
|
+
from textual.message import Message
|
|
11
|
+
from textual.content import Content
|
|
12
|
+
from textual.reactive import reactive
|
|
13
|
+
|
|
14
|
+
from llama_deploy.cli.client import get_client
|
|
15
|
+
from llama_deploy.core.schema.git_validation import RepositoryValidationResponse
|
|
16
|
+
from llama_deploy.cli.textual.llama_loader import PixelLlamaLoader
|
|
17
|
+
from llama_deploy.cli.textual.github_callback_server import GitHubCallbackServer
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ValidationResultMessage(Message):
|
|
23
|
+
"""Message sent when validation completes successfully"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, repo_url: str, pat: str | None = None):
|
|
26
|
+
super().__init__()
|
|
27
|
+
self.repo_url = repo_url
|
|
28
|
+
self.pat = pat
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ValidationCancelMessage(Message):
|
|
32
|
+
"""Message sent when validation is cancelled"""
|
|
33
|
+
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
States = Literal["validating", "options", "pat_input", "github_auth", "success"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class GitValidationWidget(Widget):
|
|
41
|
+
"""Widget for handling repository validation and GitHub App authentication"""
|
|
42
|
+
|
|
43
|
+
DEFAULT_CSS = """
|
|
44
|
+
GitValidationWidget {
|
|
45
|
+
layout: vertical;
|
|
46
|
+
height: auto;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
.validation-options {
|
|
51
|
+
layout: vertical;
|
|
52
|
+
margin-top: 1;
|
|
53
|
+
align: center middle;
|
|
54
|
+
height: auto;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.validation-options Button {
|
|
58
|
+
max-width: 40;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.option-button {
|
|
62
|
+
margin-bottom: 1;
|
|
63
|
+
width: 100%;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.pat-input-section {
|
|
67
|
+
layout: vertical;
|
|
68
|
+
margin: 2 0;
|
|
69
|
+
border: solid $primary-muted;
|
|
70
|
+
padding: 1;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.url-link {
|
|
74
|
+
text-style: underline;
|
|
75
|
+
color: $accent;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
validation_response: reactive[RepositoryValidationResponse | None] = reactive(
|
|
81
|
+
cast(RepositoryValidationResponse | None, None), recompose=True
|
|
82
|
+
)
|
|
83
|
+
current_state: reactive[States] = reactive(
|
|
84
|
+
cast(States, "validating"), recompose=True
|
|
85
|
+
)
|
|
86
|
+
error_message: reactive[str] = reactive("", recompose=True)
|
|
87
|
+
repo_url: str = ""
|
|
88
|
+
deployment_id: str | None = None
|
|
89
|
+
github_callback_server: GitHubCallbackServer | None = None
|
|
90
|
+
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
repo_url: str,
|
|
94
|
+
deployment_id: str | None = None,
|
|
95
|
+
pat: str | None = None,
|
|
96
|
+
):
|
|
97
|
+
super().__init__()
|
|
98
|
+
self.repo_url = repo_url
|
|
99
|
+
self.deployment_id = deployment_id
|
|
100
|
+
self.initial_pat = pat
|
|
101
|
+
|
|
102
|
+
def on_mount(self) -> None:
|
|
103
|
+
"""Start validation when widget mounts"""
|
|
104
|
+
self.run_worker(self._validate_repository(self.initial_pat))
|
|
105
|
+
|
|
106
|
+
def compose(self) -> ComposeResult:
|
|
107
|
+
yield Static("Repository Validation", classes="primary-message")
|
|
108
|
+
|
|
109
|
+
if self.current_state == "validating":
|
|
110
|
+
yield Static("Validating repository access...")
|
|
111
|
+
yield PixelLlamaLoader(classes="mb-1")
|
|
112
|
+
|
|
113
|
+
elif self.current_state == "options":
|
|
114
|
+
if not self.validation_response:
|
|
115
|
+
yield Static(self.error_message, classes="error-message")
|
|
116
|
+
else:
|
|
117
|
+
yield Static(self.validation_response.message, classes="error-message")
|
|
118
|
+
|
|
119
|
+
with Widget(classes="validation-options"):
|
|
120
|
+
if self.validation_response is not None:
|
|
121
|
+
if self.validation_response.github_app_installation_url:
|
|
122
|
+
# GitHub repository with app available
|
|
123
|
+
yield Button(
|
|
124
|
+
"Install GitHub App (Recommended)",
|
|
125
|
+
id="install_github_app",
|
|
126
|
+
classes="option-button",
|
|
127
|
+
variant="primary",
|
|
128
|
+
compact=True,
|
|
129
|
+
)
|
|
130
|
+
yield Button(
|
|
131
|
+
"Use Personal Access Token (PAT)",
|
|
132
|
+
id="use_pat",
|
|
133
|
+
classes="option-button",
|
|
134
|
+
variant="primary",
|
|
135
|
+
compact=True,
|
|
136
|
+
)
|
|
137
|
+
else:
|
|
138
|
+
# Non-GitHub or GitHub without app
|
|
139
|
+
yield Button(
|
|
140
|
+
"Retry with Personal Access Token (PAT)",
|
|
141
|
+
id="use_pat",
|
|
142
|
+
classes="option-button",
|
|
143
|
+
variant="primary",
|
|
144
|
+
compact=True,
|
|
145
|
+
)
|
|
146
|
+
yield Button(
|
|
147
|
+
"Save Anyway",
|
|
148
|
+
id="save_anyway",
|
|
149
|
+
classes="option-button",
|
|
150
|
+
variant="warning",
|
|
151
|
+
compact=True,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
elif self.current_state == "pat_input":
|
|
155
|
+
if self.error_message:
|
|
156
|
+
yield Static(self.error_message, classes="text-error mb-1")
|
|
157
|
+
|
|
158
|
+
with Widget(classes="two-column-form-grid mb-1"):
|
|
159
|
+
yield Label("Personal Access Token:", classes="form-label")
|
|
160
|
+
yield Input(
|
|
161
|
+
placeholder="Enter your PAT",
|
|
162
|
+
password=True,
|
|
163
|
+
id="pat_input",
|
|
164
|
+
compact=True,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
elif self.current_state == "github_auth":
|
|
168
|
+
yield Static("Waiting for GitHub App installation...")
|
|
169
|
+
if self.error_message:
|
|
170
|
+
yield Static(self.error_message, classes="error-message mt-1")
|
|
171
|
+
yield PixelLlamaLoader(classes="mb-1")
|
|
172
|
+
|
|
173
|
+
if (
|
|
174
|
+
self.validation_response
|
|
175
|
+
and self.validation_response.github_app_installation_url
|
|
176
|
+
):
|
|
177
|
+
yield Static(
|
|
178
|
+
Content.from_markup(
|
|
179
|
+
f'Open this URL to Install: [link="{self.validation_response.github_app_installation_url}"]{self.validation_response.github_app_installation_url}[/link]'
|
|
180
|
+
),
|
|
181
|
+
classes="mb-1",
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
elif self.current_state == "success":
|
|
185
|
+
yield Static("Validation Successful", classes="text-success mb-2")
|
|
186
|
+
if self.error_message:
|
|
187
|
+
yield Static(self.error_message, classes="text-warning mb-2")
|
|
188
|
+
|
|
189
|
+
# Single button row for all states
|
|
190
|
+
with HorizontalGroup(classes="button-row"):
|
|
191
|
+
if self.current_state == "pat_input":
|
|
192
|
+
yield Button(
|
|
193
|
+
"Validate", id="validate_pat", variant="primary", compact=True
|
|
194
|
+
)
|
|
195
|
+
yield Button(
|
|
196
|
+
"Back", id="back_to_options", variant="default", compact=True
|
|
197
|
+
)
|
|
198
|
+
elif self.current_state == "github_auth":
|
|
199
|
+
yield Button(
|
|
200
|
+
"Recheck", id="recheck_github", variant="primary", compact=True
|
|
201
|
+
)
|
|
202
|
+
yield Button(
|
|
203
|
+
"Cancel", id="cancel_github_auth", variant="default", compact=True
|
|
204
|
+
)
|
|
205
|
+
elif self.current_state == "success":
|
|
206
|
+
yield Button(
|
|
207
|
+
"Continue", id="continue_success", variant="primary", compact=True
|
|
208
|
+
)
|
|
209
|
+
print("DEBUG: render cancel button")
|
|
210
|
+
# Always show cancel button
|
|
211
|
+
yield Button("Back to Edit", id="cancel", variant="default", compact=True)
|
|
212
|
+
|
|
213
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
214
|
+
"""Handle button presses"""
|
|
215
|
+
if event.button.id == "install_github_app":
|
|
216
|
+
self._start_github_auth()
|
|
217
|
+
elif event.button.id == "use_pat":
|
|
218
|
+
self.current_state = "pat_input"
|
|
219
|
+
self.error_message = ""
|
|
220
|
+
elif event.button.id == "save_anyway":
|
|
221
|
+
logging.info("saving anyway")
|
|
222
|
+
self.post_message(ValidationResultMessage(self.repo_url))
|
|
223
|
+
elif event.button.id == "validate_pat":
|
|
224
|
+
self._validate_with_pat()
|
|
225
|
+
elif event.button.id == "back_to_options":
|
|
226
|
+
self.current_state = "options"
|
|
227
|
+
self.error_message = ""
|
|
228
|
+
elif event.button.id == "cancel_github_auth":
|
|
229
|
+
self._cancel_github_auth()
|
|
230
|
+
elif event.button.id == "recheck_github":
|
|
231
|
+
self.run_worker(self._recheck_github_auth())
|
|
232
|
+
elif event.button.id == "continue_success":
|
|
233
|
+
# For PAT obsolescence case, send empty PAT to clear it
|
|
234
|
+
pat_to_send = (
|
|
235
|
+
""
|
|
236
|
+
if self.validation_response and self.validation_response.pat_is_obsolete
|
|
237
|
+
else None
|
|
238
|
+
)
|
|
239
|
+
self.post_message(ValidationResultMessage(self.repo_url, pat_to_send))
|
|
240
|
+
elif event.button.id == "cancel":
|
|
241
|
+
print("DEBUG: cancel button pressed")
|
|
242
|
+
self.post_message(ValidationCancelMessage())
|
|
243
|
+
|
|
244
|
+
def _start_github_auth(self) -> None:
|
|
245
|
+
"""Start GitHub App authentication flow"""
|
|
246
|
+
if (
|
|
247
|
+
not self.validation_response
|
|
248
|
+
or not self.validation_response.github_app_installation_url
|
|
249
|
+
):
|
|
250
|
+
return
|
|
251
|
+
|
|
252
|
+
self.current_state = "github_auth"
|
|
253
|
+
|
|
254
|
+
# Open browser to GitHub App installation URL
|
|
255
|
+
try:
|
|
256
|
+
webbrowser.open(self.validation_response.github_app_installation_url)
|
|
257
|
+
except Exception as e:
|
|
258
|
+
logger.warning(f"Failed to open browser: {e}")
|
|
259
|
+
|
|
260
|
+
# # Start callback server
|
|
261
|
+
self.github_callback_server = GitHubCallbackServer()
|
|
262
|
+
self.run_worker(self._wait_for_callback())
|
|
263
|
+
|
|
264
|
+
def _cancel_github_auth(self) -> None:
|
|
265
|
+
"""Cancel GitHub authentication and return to options"""
|
|
266
|
+
if self.github_callback_server:
|
|
267
|
+
self.github_callback_server.stop()
|
|
268
|
+
self.github_callback_server = None
|
|
269
|
+
self.current_state = "options"
|
|
270
|
+
|
|
271
|
+
def _validate_with_pat(self) -> None:
|
|
272
|
+
"""Validate repository with PAT from input"""
|
|
273
|
+
pat_input = self.query_one("#pat_input", Input)
|
|
274
|
+
pat = pat_input.value.strip()
|
|
275
|
+
|
|
276
|
+
if not pat:
|
|
277
|
+
self.error_message = "Please enter a Personal Access Token"
|
|
278
|
+
return
|
|
279
|
+
|
|
280
|
+
self.run_worker(self._validate_repository(pat))
|
|
281
|
+
|
|
282
|
+
async def _validate_repository(self, pat: str | None = None) -> None:
|
|
283
|
+
"""Perform repository validation"""
|
|
284
|
+
self.current_state = "validating"
|
|
285
|
+
self.error_message = ""
|
|
286
|
+
try:
|
|
287
|
+
client = get_client()
|
|
288
|
+
response = client.validate_repository(
|
|
289
|
+
repo_url=self.repo_url, deployment_id=self.deployment_id, pat=pat
|
|
290
|
+
)
|
|
291
|
+
self.validation_response = response
|
|
292
|
+
|
|
293
|
+
if response.accessible:
|
|
294
|
+
# Success - post result message with appropriate messaging
|
|
295
|
+
if response.pat_is_obsolete:
|
|
296
|
+
# Show success message about PAT obsolescence before proceeding
|
|
297
|
+
self.current_state = "success"
|
|
298
|
+
self.error_message = "Repository accessible via GitHub App. Your Personal Access Token is now obsolete and will be removed."
|
|
299
|
+
else:
|
|
300
|
+
self.post_message(ValidationResultMessage(self.repo_url, pat))
|
|
301
|
+
else:
|
|
302
|
+
# Failed - show options
|
|
303
|
+
self.current_state = "options"
|
|
304
|
+
|
|
305
|
+
except Exception as e:
|
|
306
|
+
self.error_message = f"Validation failed: {e}"
|
|
307
|
+
self.current_state = "options"
|
|
308
|
+
|
|
309
|
+
async def _recheck_github_auth(self) -> None:
|
|
310
|
+
"""Re-validate repository while staying in github_auth state"""
|
|
311
|
+
self.error_message = ""
|
|
312
|
+
try:
|
|
313
|
+
client = get_client()
|
|
314
|
+
response = client.validate_repository(
|
|
315
|
+
repo_url=self.repo_url, deployment_id=self.deployment_id
|
|
316
|
+
)
|
|
317
|
+
self.validation_response = response
|
|
318
|
+
|
|
319
|
+
if response.accessible:
|
|
320
|
+
# Success - post result message with appropriate messaging
|
|
321
|
+
self.current_state = "success"
|
|
322
|
+
self.post_message(
|
|
323
|
+
ValidationResultMessage(
|
|
324
|
+
self.repo_url, "" if response.pat_is_obsolete else None
|
|
325
|
+
)
|
|
326
|
+
)
|
|
327
|
+
else:
|
|
328
|
+
# Failed - stay in github_auth and show error
|
|
329
|
+
self.error_message = f"Still not accessible: {response.message}"
|
|
330
|
+
|
|
331
|
+
except Exception as e:
|
|
332
|
+
# Failed - stay in github_auth and show error
|
|
333
|
+
self.error_message = f"Recheck failed: {e}"
|
|
334
|
+
|
|
335
|
+
async def _wait_for_callback(self) -> None:
|
|
336
|
+
"""Wait for GitHub callback and re-validate"""
|
|
337
|
+
if not self.github_callback_server:
|
|
338
|
+
return
|
|
339
|
+
|
|
340
|
+
try:
|
|
341
|
+
# Wait for callback with timeout
|
|
342
|
+
await self.github_callback_server.start_and_wait(timeout=300)
|
|
343
|
+
# Process callback - re-validate without PAT since GitHub App should now be available
|
|
344
|
+
await self._validate_repository()
|
|
345
|
+
|
|
346
|
+
except TimeoutError:
|
|
347
|
+
logger.info("callback timed out")
|
|
348
|
+
self.error_message = "Authentication timed out"
|
|
349
|
+
self.current_state = "options"
|
|
350
|
+
except Exception as e:
|
|
351
|
+
logger.error("callback failed", exc_info=True)
|
|
352
|
+
self.error_message = f"Callback failed: {e}"
|
|
353
|
+
self.current_state = "options"
|
|
354
|
+
finally:
|
|
355
|
+
if self.github_callback_server:
|
|
356
|
+
self.github_callback_server.stop()
|
|
357
|
+
self.github_callback_server = None
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""GitHub App callback server for handling OAuth flows"""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import webbrowser
|
|
6
|
+
from textwrap import dedent
|
|
7
|
+
from typing import Dict, Any, cast
|
|
8
|
+
|
|
9
|
+
from aiohttp import web
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GitHubCallbackServer:
|
|
15
|
+
"""Local HTTP server to handle GitHub App installation callbacks"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, port: int = 41010):
|
|
18
|
+
self.port = port
|
|
19
|
+
self.callback_data: Dict[str, Any] = {}
|
|
20
|
+
self.callback_received = asyncio.Event()
|
|
21
|
+
self.app: web.Application | None = None
|
|
22
|
+
self.runner: web.AppRunner | None = None
|
|
23
|
+
self.site: web.TCPSite | None = None
|
|
24
|
+
|
|
25
|
+
async def start_and_wait(self, timeout: float = 300) -> Dict[str, Any]:
|
|
26
|
+
"""Start the server and wait for a callback with timeout"""
|
|
27
|
+
await self._start_server()
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
# Wait for callback with timeout
|
|
31
|
+
await asyncio.wait_for(self.callback_received.wait(), timeout=timeout)
|
|
32
|
+
logger.debug(f"processing callback: {self.callback_data}")
|
|
33
|
+
return self.callback_data
|
|
34
|
+
except asyncio.TimeoutError:
|
|
35
|
+
raise TimeoutError(f"GitHub callback timed out after {timeout} seconds")
|
|
36
|
+
finally:
|
|
37
|
+
await self.stop()
|
|
38
|
+
|
|
39
|
+
async def _start_server(self) -> None:
|
|
40
|
+
"""Start the aiohttp server"""
|
|
41
|
+
self.app = web.Application()
|
|
42
|
+
self.app.router.add_get("/", self._handle_callback)
|
|
43
|
+
self.app.router.add_get("/{path:.*}", self._handle_callback)
|
|
44
|
+
|
|
45
|
+
self.runner = web.AppRunner(self.app, logger=None) # Suppress server logs
|
|
46
|
+
await self.runner.setup()
|
|
47
|
+
|
|
48
|
+
self.site = web.TCPSite(self.runner, "localhost", self.port)
|
|
49
|
+
await self.site.start()
|
|
50
|
+
|
|
51
|
+
logger.debug(f"GitHub callback server started on port {self.port}")
|
|
52
|
+
|
|
53
|
+
async def _handle_callback(self, request: web.Request) -> web.Response:
|
|
54
|
+
"""Handle the GitHub callback"""
|
|
55
|
+
# Capture query parameters
|
|
56
|
+
query_params: dict[str, str] = dict(cast(Any, request.query))
|
|
57
|
+
self.callback_data.update(query_params)
|
|
58
|
+
|
|
59
|
+
# Signal that callback was received
|
|
60
|
+
logger.debug(f"GitHub callback received: {query_params}")
|
|
61
|
+
self.callback_received.set()
|
|
62
|
+
|
|
63
|
+
# Return success page
|
|
64
|
+
html_response = self._get_success_html()
|
|
65
|
+
return web.Response(text=html_response, content_type="text/html")
|
|
66
|
+
|
|
67
|
+
async def stop(self) -> None:
|
|
68
|
+
"""Stop the server and cleanup"""
|
|
69
|
+
if self.site:
|
|
70
|
+
await self.site.stop()
|
|
71
|
+
self.site = None
|
|
72
|
+
if self.runner:
|
|
73
|
+
await self.runner.cleanup()
|
|
74
|
+
self.runner = None
|
|
75
|
+
self.app = None
|
|
76
|
+
self.callback_received.clear()
|
|
77
|
+
logger.debug("GitHub callback server stopped")
|
|
78
|
+
|
|
79
|
+
def _get_success_html(self) -> str:
|
|
80
|
+
"""Get the HTML for the success page"""
|
|
81
|
+
return dedent("""
|
|
82
|
+
<!DOCTYPE html>
|
|
83
|
+
<html>
|
|
84
|
+
<meta charset="UTF-8">
|
|
85
|
+
<head>
|
|
86
|
+
<title>llamactl - Authentication Complete</title>
|
|
87
|
+
<style>
|
|
88
|
+
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap');
|
|
89
|
+
|
|
90
|
+
* {
|
|
91
|
+
margin: 0;
|
|
92
|
+
padding: 0;
|
|
93
|
+
box-sizing: border-box;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
body {
|
|
97
|
+
font-family: 'JetBrains Mono', 'Courier New', monospace;
|
|
98
|
+
background: #1a0d26;
|
|
99
|
+
color: #e4e4e7;
|
|
100
|
+
min-height: 100vh;
|
|
101
|
+
display: flex;
|
|
102
|
+
align-items: center;
|
|
103
|
+
justify-content: center;
|
|
104
|
+
line-height: 1.6;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.terminal {
|
|
108
|
+
background: #0f0a17;
|
|
109
|
+
border: 2px solid #7c3aed;
|
|
110
|
+
border-radius: 0;
|
|
111
|
+
max-width: 600px;
|
|
112
|
+
width: 90vw;
|
|
113
|
+
padding: 0;
|
|
114
|
+
box-shadow: 0 0 20px rgba(124, 58, 237, 0.3);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.terminal-header {
|
|
118
|
+
background: #7c3aed;
|
|
119
|
+
color: #ffffff;
|
|
120
|
+
padding: 12px 20px;
|
|
121
|
+
font-weight: bold;
|
|
122
|
+
font-size: 14px;
|
|
123
|
+
border-bottom: 2px solid #6d28d9;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.terminal-body {
|
|
127
|
+
padding: 30px;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.prompt {
|
|
131
|
+
color: #10b981;
|
|
132
|
+
font-weight: bold;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.success-icon {
|
|
136
|
+
color: #10b981;
|
|
137
|
+
font-size: 24px;
|
|
138
|
+
margin-right: 8px;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.highlight {
|
|
142
|
+
color: #a78bfa;
|
|
143
|
+
font-weight: bold;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.instruction {
|
|
147
|
+
background: #2d1b69;
|
|
148
|
+
border: 1px solid #7c3aed;
|
|
149
|
+
padding: 16px;
|
|
150
|
+
margin: 20px 0;
|
|
151
|
+
border-radius: 4px;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.blink {
|
|
155
|
+
animation: blink 1s infinite;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
@keyframes blink {
|
|
159
|
+
0%, 50% { opacity: 1; }
|
|
160
|
+
51%, 100% { opacity: 0; }
|
|
161
|
+
}
|
|
162
|
+
</style>
|
|
163
|
+
</head>
|
|
164
|
+
<body>
|
|
165
|
+
<div class="terminal">
|
|
166
|
+
<div class="terminal-header">
|
|
167
|
+
llamactl@github-auth-server
|
|
168
|
+
</div>
|
|
169
|
+
<div class="terminal-body">
|
|
170
|
+
<div><span class="prompt">$</span> GitHub App installation successful</div>
|
|
171
|
+
<div class="instruction">
|
|
172
|
+
<div><strong>Next Steps:</strong></div>
|
|
173
|
+
<div>1. Close this browser</div>
|
|
174
|
+
<div>2. Return to your terminal</div>
|
|
175
|
+
<div>3. Continue with llamactl<span class="blink">_</span></div>
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
</body>
|
|
180
|
+
</html>
|
|
181
|
+
""").strip()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
async def main():
|
|
185
|
+
"""Main function to demo the callback server"""
|
|
186
|
+
logging.basicConfig(level=logging.INFO)
|
|
187
|
+
|
|
188
|
+
server = GitHubCallbackServer(port=41010)
|
|
189
|
+
|
|
190
|
+
# Start server and open browser
|
|
191
|
+
print(f"Starting GitHub callback server on http://localhost:{server.port}")
|
|
192
|
+
print("Opening browser to show success page...")
|
|
193
|
+
|
|
194
|
+
# Open browser to success page to see the styling
|
|
195
|
+
webbrowser.open(f"http://localhost:{server.port}")
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
# Wait for callback (or just keep server running)
|
|
199
|
+
print("Server running... Press Ctrl+C to stop")
|
|
200
|
+
callback_data = await server.start_and_wait(timeout=3600) # 1 hour timeout
|
|
201
|
+
print(f"Received callback data: {callback_data}")
|
|
202
|
+
finally:
|
|
203
|
+
await server.stop()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
if __name__ == "__main__":
|
|
207
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from textual.widgets import Static
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class PixelLlamaLoader(Static):
|
|
6
|
+
"""Pixelated llama loading animation using block characters"""
|
|
7
|
+
|
|
8
|
+
def __init__(self, **kwargs):
|
|
9
|
+
self.frame = 0
|
|
10
|
+
# Pixelated llama frames using Unicode block characters
|
|
11
|
+
self.frames = [
|
|
12
|
+
# ── Frame 1 – all legs down (starting position) ─
|
|
13
|
+
"""
|
|
14
|
+
,
|
|
15
|
+
~)
|
|
16
|
+
(_---;
|
|
17
|
+
|~|
|
|
18
|
+
| |""",
|
|
19
|
+
# ── Frame 2 – lift right front leg ─
|
|
20
|
+
"""
|
|
21
|
+
,
|
|
22
|
+
~)
|
|
23
|
+
(_---;
|
|
24
|
+
/|~|
|
|
25
|
+
/ | |""",
|
|
26
|
+
"""
|
|
27
|
+
,
|
|
28
|
+
~)
|
|
29
|
+
(_---;
|
|
30
|
+
/|~|
|
|
31
|
+
|| |\\""",
|
|
32
|
+
# ── Frame 3 – right front forward, lift left back ─
|
|
33
|
+
"""
|
|
34
|
+
,
|
|
35
|
+
~)
|
|
36
|
+
(_---;
|
|
37
|
+
|~|\\
|
|
38
|
+
|\\| \\""",
|
|
39
|
+
]
|
|
40
|
+
self.frames = [re.sub(r"^\n", "", x) for x in self.frames]
|
|
41
|
+
|
|
42
|
+
super().__init__(self._get_display_text(), **kwargs)
|
|
43
|
+
|
|
44
|
+
def _get_display_text(self) -> str:
|
|
45
|
+
return f"{self.frames[self.frame]}"
|
|
46
|
+
|
|
47
|
+
def on_mount(self) -> None:
|
|
48
|
+
self.timer = self.set_interval(0.6, self.animate)
|
|
49
|
+
|
|
50
|
+
def animate(self) -> None:
|
|
51
|
+
self.frame = (self.frame + 1) % len(self.frames)
|
|
52
|
+
self.update(self._get_display_text())
|