llamactl 0.3.0a12__py3-none-any.whl → 0.3.0a13__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.
@@ -7,11 +7,11 @@ import hashlib
7
7
  import threading
8
8
  import time
9
9
  from pathlib import Path
10
- from typing import Iterator
11
10
 
12
- from llama_deploy.cli.client import get_project_client as get_client
13
- from llama_deploy.core.client.manage_client import Closer
14
- from llama_deploy.core.schema.base import LogEvent
11
+ from llama_deploy.cli.client import (
12
+ project_client_context,
13
+ )
14
+ from llama_deploy.core.schema import LogEvent
15
15
  from llama_deploy.core.schema.deployments import DeploymentResponse
16
16
  from rich.text import Text
17
17
  from textual import events
@@ -78,7 +78,6 @@ class DeploymentMonitorWidget(Widget):
78
78
  error_message = reactive("", recompose=False)
79
79
  wrap_enabled = reactive(False, recompose=False)
80
80
  autoscroll_enabled = reactive(True, recompose=False)
81
- stream_closer: Closer | None = None
82
81
 
83
82
  def __init__(self, deployment_id: str) -> None:
84
83
  super().__init__()
@@ -87,12 +86,12 @@ class DeploymentMonitorWidget(Widget):
87
86
  # Persist content written to the RichLog across recomposes
88
87
  self._log_buffer: list[Text] = []
89
88
 
90
- def on_mount(self) -> None:
89
+ async def on_mount(self) -> None:
91
90
  # Kick off initial fetch and start logs stream in background
92
- self.run_worker(self._fetch_deployment(), exclusive=True)
93
- self.run_worker(self._stream_logs, exclusive=False, thread=True)
91
+ self.run_worker(self._fetch_deployment())
92
+ self.run_worker(self._stream_logs())
94
93
  # Start periodic polling of deployment status
95
- self.run_worker(self._poll_deployment_status(), exclusive=False)
94
+ self.run_worker(self._poll_deployment_status())
96
95
 
97
96
  def compose(self) -> ComposeResult:
98
97
  yield Static("Deployment Status", classes="primary-message")
@@ -141,81 +140,117 @@ class DeploymentMonitorWidget(Widget):
141
140
 
142
141
  async def _fetch_deployment(self) -> None:
143
142
  try:
144
- client = get_client()
145
- self.deployment = client.get_deployment(
146
- self.deployment_id, include_events=True
147
- )
143
+ async with project_client_context() as client:
144
+ self.deployment = await client.get_deployment(
145
+ self.deployment_id, include_events=True
146
+ )
148
147
  # Clear any previous error on success
149
148
  self.error_message = ""
150
149
  except Exception as e: # pragma: no cover - network errors
151
150
  self.error_message = f"Failed to fetch deployment: {e}"
152
151
 
153
- def _stream_logs(self) -> None:
154
- """Consume the blocking log iterator in a single worker thread.
152
+ async def _stream_logs(self) -> None:
153
+ """Consume the async log iterator, batch updates, and reconnect with backoff."""
155
154
 
156
- Cooperative cancellation uses `self._stop_stream` to exit cleanly.
157
- """
158
- client = get_client()
159
-
160
- def _sleep_with_cancel(total_seconds: float) -> None:
155
+ async def _sleep_with_cancel(total_seconds: float) -> None:
161
156
  step = 0.2
162
157
  remaining = total_seconds
163
158
  while remaining > 0 and not self._stop_stream.is_set():
164
- time.sleep(min(step, remaining))
159
+ await asyncio.sleep(min(step, remaining))
165
160
  remaining -= step
166
161
 
162
+ # Batching configuration: small latency to reduce UI churn while staying responsive
163
+ batch_max_latency_seconds = 0.1
164
+ batch_max_items = 200
165
+
167
166
  base_backoff_seconds = 0.2
168
167
  backoff_seconds = base_backoff_seconds
169
168
  max_backoff_seconds = 30.0
170
169
 
171
170
  while not self._stop_stream.is_set():
172
- try:
173
- connect_started_at = time.monotonic()
174
- closer, stream = client.stream_deployment_logs(
175
- self.deployment_id,
176
- include_init_containers=True,
177
- )
178
- # On any (re)connect, clear existing content
179
- self.app.call_from_thread(self._reset_log_view_for_reconnect)
180
-
181
- buffered_stream = _buffer_log_lines(stream)
182
-
183
- def close_stream():
171
+ connect_started_at = time.monotonic()
172
+ # On any (re)connect, clear existing content
173
+ self._reset_log_view_for_reconnect()
174
+
175
+ queue: asyncio.Queue[LogEvent] = asyncio.Queue(maxsize=10000)
176
+ producer_done = asyncio.Event()
177
+
178
+ async def _producer() -> None:
179
+ try:
180
+ async with project_client_context() as client:
181
+ async for event in client.stream_deployment_logs(
182
+ self.deployment_id,
183
+ include_init_containers=True,
184
+ tail_lines=10000,
185
+ ):
186
+ if self._stop_stream.is_set():
187
+ break
188
+ try:
189
+ await queue.put(event)
190
+ except Exception:
191
+ # If queue put fails due to cancellation/shutdown, stop
192
+ break
193
+ except Exception as e:
194
+ # Surface error via error message and rely on reconnect loop
195
+ if not self._stop_stream.is_set():
196
+ self._set_error_message(
197
+ f"Log stream failed: {e}. Reconnecting..."
198
+ )
199
+ finally:
200
+ producer_done.set()
201
+
202
+ async def _consumer() -> None:
203
+ batch: list[LogEvent] = []
204
+ next_deadline = time.monotonic() + batch_max_latency_seconds
205
+ while not self._stop_stream.is_set():
206
+ # Stop once producer finished and queue drained
207
+ if producer_done.is_set() and queue.empty():
208
+ if batch:
209
+ self._handle_log_events(batch)
210
+ batch = []
211
+ break
212
+ timeout = max(0.0, next_deadline - time.monotonic())
184
213
  try:
185
- closer()
214
+ item = await asyncio.wait_for(queue.get(), timeout=timeout)
215
+ batch.append(item)
216
+ if len(batch) >= batch_max_items:
217
+ self._handle_log_events(batch)
218
+ batch = []
219
+ next_deadline = time.monotonic() + batch_max_latency_seconds
220
+ except asyncio.TimeoutError:
221
+ if batch:
222
+ self._handle_log_events(batch)
223
+ batch = []
224
+ next_deadline = time.monotonic() + batch_max_latency_seconds
186
225
  except Exception:
187
- pass
188
-
189
- self.stream_closer = close_stream
190
- # Stream connected; consume until end
191
- for events in buffered_stream:
192
- if self._stop_stream.is_set():
226
+ # On any unexpected error, flush and exit, reconnect will handle
227
+ if batch:
228
+ self._handle_log_events(batch)
193
229
  break
194
- # Marshal UI updates back to the main thread via the App
195
- self.app.call_from_thread(self._handle_log_events, events)
196
- if self._stop_stream.is_set():
197
- break
198
- # Stream ended without explicit error; attempt reconnect
199
- self.app.call_from_thread(
200
- self._set_error_message, "Log stream disconnected. Reconnecting..."
201
- )
202
- except Exception as e:
203
- if self._stop_stream.is_set():
204
- break
205
- # Surface the error to the UI and attempt reconnect with backoff
206
- self.app.call_from_thread(
207
- self._set_error_message, f"Log stream failed: {e}. Reconnecting..."
208
- )
209
230
 
210
- # Duration-aware backoff: subtract how long the last connection lived
231
+ producer_task = asyncio.create_task(_producer())
232
+ try:
233
+ await _consumer()
234
+ finally:
235
+ # Ensure producer is not left running
236
+ try:
237
+ producer_task.cancel()
238
+ except Exception:
239
+ pass
240
+
241
+ if self._stop_stream.is_set():
242
+ break
243
+
244
+ # If we reached here, the stream ended or failed; attempt reconnect with backoff
245
+ self._set_error_message("Log stream disconnected. Reconnecting...")
246
+
247
+ # Duration-aware backoff (smaller when the previous connection lived longer)
211
248
  connection_lifetime = 0.0
212
249
  try:
213
250
  connection_lifetime = max(0.0, time.monotonic() - connect_started_at)
214
251
  except Exception:
215
252
  connection_lifetime = 0.0
216
253
 
217
- # If the connection lived longer than the current backoff window,
218
- # reset to base so the next reconnect is immediate.
219
254
  if connection_lifetime >= backoff_seconds:
220
255
  backoff_seconds = base_backoff_seconds
221
256
  else:
@@ -223,7 +258,7 @@ class DeploymentMonitorWidget(Widget):
223
258
 
224
259
  delay = max(0.0, backoff_seconds - connection_lifetime)
225
260
  if delay > 0:
226
- _sleep_with_cancel(delay)
261
+ await _sleep_with_cancel(delay)
227
262
 
228
263
  def _reset_log_view_for_reconnect(self) -> None:
229
264
  """Clear UI and buffers so new stream replaces previous content."""
@@ -326,9 +361,6 @@ class DeploymentMonitorWidget(Widget):
326
361
  def on_unmount(self) -> None:
327
362
  # Attempt to stop the streaming loop
328
363
  self._stop_stream.set()
329
- if self.stream_closer is not None:
330
- self.stream_closer()
331
- self.stream_closer = None
332
364
 
333
365
  # Reactive watchers to update widgets in place instead of recomposing
334
366
  def watch_error_message(self, message: str) -> None:
@@ -383,12 +415,12 @@ class DeploymentMonitorWidget(Widget):
383
415
 
384
416
  async def _poll_deployment_status(self) -> None:
385
417
  """Periodically refresh deployment status to reflect updates in the UI."""
386
- client = get_client()
387
418
  while not self._stop_stream.is_set():
388
419
  try:
389
- self.deployment = client.get_deployment(
390
- self.deployment_id, include_events=True
391
- )
420
+ async with project_client_context() as client:
421
+ self.deployment = await client.get_deployment(
422
+ self.deployment_id, include_events=True
423
+ )
392
424
  # Clear any previous error on success
393
425
  if self.error_message:
394
426
  self.error_message = ""
@@ -434,42 +466,3 @@ def monitor_deployment_screen(deployment_id: str) -> None:
434
466
  """Launch the standalone deployment monitor screen."""
435
467
  app = DeploymentMonitorApp(deployment_id)
436
468
  app.run()
437
-
438
-
439
- def _buffer_log_lines(iter: Iterator[LogEvent]) -> Iterator[list[LogEvent]]:
440
- """Batch log events into small lists using a background reader.
441
-
442
- This reduces UI churn while still reacting quickly. On shutdown we
443
- absorb stream read errors that are expected when the connection is
444
- closed from another thread.
445
- """
446
- buffer: list[LogEvent] = []
447
- bg_error: Exception | None = None
448
- done = threading.Event()
449
-
450
- def pump() -> None:
451
- nonlocal bg_error
452
- try:
453
- for event in iter:
454
- buffer.append(event)
455
- except Exception as e:
456
- bg_error = e
457
- finally:
458
- done.set()
459
-
460
- t = threading.Thread(target=pump, daemon=True)
461
- t.start()
462
- try:
463
- while not done.is_set():
464
- if buffer:
465
- # Yield a snapshot and clear in-place to avoid reallocating list
466
- yield list(buffer)
467
- buffer.clear()
468
- time.sleep(0.5)
469
- if bg_error is not None:
470
- raise bg_error
471
- finally:
472
- try:
473
- t.join(timeout=0.1)
474
- except Exception:
475
- pass
@@ -282,14 +282,14 @@ class GitValidationWidget(Widget):
282
282
  self.error_message = ""
283
283
  try:
284
284
  client = get_client()
285
- response = client.validate_repository(
285
+ self.validation_response = await client.validate_repository(
286
286
  repo_url=self.repo_url, deployment_id=self.deployment_id, pat=pat
287
287
  )
288
- self.validation_response = response
289
288
 
290
- if response.accessible:
289
+ resp = self.validation_response
290
+ if resp and resp.accessible:
291
291
  # Success - post result message with appropriate messaging
292
- if response.pat_is_obsolete:
292
+ if resp.pat_is_obsolete:
293
293
  # Show success message about PAT obsolescence before proceeding
294
294
  self.current_state = "success"
295
295
  self.error_message = "Repository accessible via GitHub App. Your Personal Access Token is now obsolete and will be removed."
@@ -308,22 +308,24 @@ class GitValidationWidget(Widget):
308
308
  self.error_message = ""
309
309
  try:
310
310
  client = get_client()
311
- response = client.validate_repository(
311
+ self.validation_response = await client.validate_repository(
312
312
  repo_url=self.repo_url, deployment_id=self.deployment_id
313
313
  )
314
- self.validation_response = response
315
314
 
316
- if response.accessible:
315
+ resp = self.validation_response
316
+ if resp and resp.accessible:
317
317
  # Success - post result message with appropriate messaging
318
318
  self.current_state = "success"
319
319
  self.post_message(
320
320
  ValidationResultMessage(
321
- self.repo_url, "" if response.pat_is_obsolete else None
321
+ self.repo_url, "" if resp.pat_is_obsolete else None
322
322
  )
323
323
  )
324
324
  else:
325
325
  # Failed - stay in github_auth and show error
326
- self.error_message = f"Still not accessible: {response.message}"
326
+ self.error_message = (
327
+ f"Still not accessible: {resp.message if resp else ''}"
328
+ )
327
329
 
328
330
  except Exception as e:
329
331
  # Failed - stay in github_auth and show error
@@ -30,6 +30,10 @@ Container {
30
30
  height: auto;
31
31
  }
32
32
 
33
+ .two-column-form-grid .full-width {
34
+ column-span: 2;
35
+ }
36
+
33
37
  /* =============================================== */
34
38
  /* FORM ELEMENTS */
35
39
  /* =============================================== */
@@ -96,7 +100,7 @@ Input.disabled {
96
100
  background: $error-muted;
97
101
  border-left: heavy $error;
98
102
  margin: 0 0 1 0;
99
- padding: 0 0 0 1
103
+ padding: 0 0 0 1;
100
104
  }
101
105
 
102
106
  .primary-message {
@@ -104,7 +108,7 @@ Input.disabled {
104
108
  background: $primary-muted;
105
109
  border-left: heavy $primary;
106
110
  margin: 0 0 1 0;
107
- padding: 0 0 0 1
111
+ padding: 0 0 0 1;
108
112
  }
109
113
 
110
114
  .secondary-message {
@@ -112,15 +116,23 @@ Input.disabled {
112
116
  background: $secondary-muted;
113
117
  border-left: heavy $secondary;
114
118
  margin: 0 0 1 0;
115
- padding: 0 0 0 1
119
+ padding: 0 0 0 1;
116
120
  }
117
121
 
118
122
  .success-message {
119
123
  color: $text-success;
120
124
  background: $success-muted;
121
125
  border-left: heavy $success;
122
- padding: 1;
123
- margin: 1 0;
126
+ padding: 0 0 0 1;
127
+ margin: 0 0 0 0;
128
+ }
129
+
130
+ .warning-message {
131
+ color: $text-warning;
132
+ background: $warning-muted;
133
+ border-left: heavy $warning;
134
+ padding: 0 0 0 1;
135
+ margin: 0 0 0 0;
124
136
  }
125
137
 
126
138
  .hidden {
@@ -142,6 +154,10 @@ Input.disabled {
142
154
  width: 1fr;
143
155
  }
144
156
 
157
+ .align-right {
158
+ align: right middle;
159
+ }
160
+
145
161
  /* =============================================== */
146
162
  /* BUTTONS & ACTIONS */
147
163
  /* =============================================== */
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: llamactl
3
- Version: 0.3.0a12
3
+ Version: 0.3.0a13
4
4
  Summary: A command-line interface for managing LlamaDeploy projects and deployments
5
5
  Author: Adrian Lyjak
6
6
  Author-email: Adrian Lyjak <adrianlyjak@gmail.com>
7
7
  License: MIT
8
- Requires-Dist: llama-deploy-core[client]>=0.3.0a12,<0.4.0
9
- Requires-Dist: llama-deploy-appserver>=0.3.0a12,<0.4.0
8
+ Requires-Dist: llama-deploy-core[client]>=0.3.0a13,<0.4.0
9
+ Requires-Dist: llama-deploy-appserver>=0.3.0a13,<0.4.0
10
10
  Requires-Dist: httpx>=0.24.0
11
11
  Requires-Dist: rich>=13.0.0
12
12
  Requires-Dist: questionary>=2.0.0
@@ -0,0 +1,29 @@
1
+ llama_deploy/cli/__init__.py,sha256=06afbb37cf01a3880f0487c3ab83a44ce72153b6ef8c2d6c0ed4324f03a5ae8f,411
2
+ llama_deploy/cli/app.py,sha256=a909fcb06f6c2179986fefa5b561e597840aa14a0ee8c038b238d267c3e2a178,2345
3
+ llama_deploy/cli/client.py,sha256=383601a4b9278972b4d8350a277d517d9b22a22776c1c4d6cd3f07db53f8d871,2098
4
+ llama_deploy/cli/commands/aliased_group.py,sha256=bc41007c97b7b93981217dbd4d4591df2b6c9412a2d9ed045b0ec5655ed285f2,1066
5
+ llama_deploy/cli/commands/auth.py,sha256=145dfb175223ee0262677eed69806eb5e1b160574e9b480da6311ae80241141f,12806
6
+ llama_deploy/cli/commands/deployment.py,sha256=7464e09ad53f082667a1e7e3f6c0040d01fcb312cbc92cf182efc66260a55e78,10454
7
+ llama_deploy/cli/commands/init.py,sha256=51b2de1e35ff34bc15c9dfec72fbad08aaf528c334df168896d36458a4e9401c,6307
8
+ llama_deploy/cli/commands/serve.py,sha256=4d47850397ba172944df56a934a51bedb52403cbd3f9b000b1ced90a31c75049,2721
9
+ llama_deploy/cli/config.py,sha256=0864c6e25646c28062b1adc081039bd59cbd9d87ab7e4a6e465660c6231f0489,8951
10
+ llama_deploy/cli/debug.py,sha256=e85a72d473bbe1645eb31772f7349bde703d45704166f767385895c440afc762,496
11
+ llama_deploy/cli/env.py,sha256=6ebc24579815b3787829c81fd5bb9f31698a06e62c0128a788559f962b33a7af,1016
12
+ llama_deploy/cli/interactive_prompts/session_utils.py,sha256=b996f2eddf70d6c49636c4797d246d212fce0950fe7e9a3f59cf6a1bf7ae26f5,1142
13
+ llama_deploy/cli/interactive_prompts/utils.py,sha256=2b29148f2cf2be8d1642fd800f529602cecb03997259790a3b8e1b879604cee2,1736
14
+ llama_deploy/cli/options.py,sha256=9eb6f1c5d89f83cbf5b288d3a85c38acd929168d6afdf4b04d664df419aa1816,1543
15
+ llama_deploy/cli/platform_client.py,sha256=69de23dc79a8f5922afc9e3bac1b633a531340ebbefeb7838e3a88419faa754c,1451
16
+ llama_deploy/cli/py.typed,sha256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855,0
17
+ llama_deploy/cli/textual/api_key_profile_form.py,sha256=cf059426d758136318c3b6f340e2741e79ab1ec613b8741ef3d2a402214ea2c4,22253
18
+ llama_deploy/cli/textual/deployment_form.py,sha256=1cf186b765d10a1bdd7394f22ddd7598d75dba8c9a8a7a8be74e6151da2f31dc,20941
19
+ llama_deploy/cli/textual/deployment_help.py,sha256=d43e9ff29db71a842cf8b491545763d581ede3132b8af518c73af85a40950046,2464
20
+ llama_deploy/cli/textual/deployment_monitor.py,sha256=7bcf3f0213401c2432fdb5a9d9acf468a4afe83b2d86d7f2852319768e6f2534,17231
21
+ llama_deploy/cli/textual/git_validation.py,sha256=94c95b61d0cbc490566a406b4886c9c12e1d1793dc14038a5be37119223c9568,13419
22
+ llama_deploy/cli/textual/github_callback_server.py,sha256=dc74c510f8a98ef6ffaab0f6d11c7ea86ee77ca5adbc7725a2a29112bae24191,7556
23
+ llama_deploy/cli/textual/llama_loader.py,sha256=33cb32a46dd40bcf889c553e44f2672c410e26bd1d4b17aa6cca6d0a5d59c2c4,1468
24
+ llama_deploy/cli/textual/secrets_form.py,sha256=a43fbd81aad034d0d60906bfd917c107f9ace414648b0f63ac0b29eeba4050db,7061
25
+ llama_deploy/cli/textual/styles.tcss,sha256=b1a54dc5fb0e0aa12cbf48807e9e6a94b9926838b8058dae1336a134f02e92b0,3327
26
+ llamactl-0.3.0a13.dist-info/WHEEL,sha256=66530aef82d5020ef5af27ae0123c71abb9261377c5bc519376c671346b12918,79
27
+ llamactl-0.3.0a13.dist-info/entry_points.txt,sha256=b67e1eb64305058751a651a80f2d2268b5f7046732268421e796f64d4697f83c,52
28
+ llamactl-0.3.0a13.dist-info/METADATA,sha256=87e30b0475ce6d5eaf7d400e4c5c00d41d2cabb0ae54abc02ab686659c1b1e15,3177
29
+ llamactl-0.3.0a13.dist-info/RECORD,,
@@ -1,217 +0,0 @@
1
- import click
2
- from llama_deploy.cli.client import get_control_plane_client
3
- from rich import print as rprint
4
- from rich.table import Table
5
-
6
- from ..app import app, console
7
- from ..config import config_manager
8
- from ..interactive_prompts.utils import (
9
- select_profile,
10
- )
11
- from ..options import global_options
12
- from ..textual.profile_form import create_profile_form, edit_profile_form
13
-
14
-
15
- # Create sub-applications for organizing commands
16
- @app.group(
17
- help="Login to manage deployments and switch between projects",
18
- no_args_is_help=True,
19
- )
20
- @global_options
21
- def profiles() -> None:
22
- """Manage profiles"""
23
- pass
24
-
25
-
26
- # Profile commands
27
- @profiles.command("create")
28
- @global_options
29
- @click.option("--name", help="Profile name")
30
- @click.option("--api-url", help="API server URL")
31
- @click.option("--project-id", help="Default project ID")
32
- def create_profile(
33
- name: str | None, api_url: str | None, project_id: str | None
34
- ) -> None:
35
- """Create a new profile"""
36
- try:
37
- # If all required args are provided via CLI, skip interactive mode
38
- if name and api_url:
39
- # Use CLI args directly
40
- profile = config_manager.create_profile(name, api_url, project_id)
41
- rprint(f"[green]Created profile '{profile.name}'[/green]")
42
-
43
- # Automatically switch to the new profile
44
- config_manager.set_current_profile(name)
45
- rprint(f"[green]Switched to profile '{name}'[/green]")
46
- return
47
-
48
- # Use interactive creation
49
- profile = create_profile_form()
50
- if profile is None:
51
- rprint("[yellow]Cancelled[/yellow]")
52
- return
53
-
54
- try:
55
- rprint(f"[green]Created profile '{profile.name}'[/green]")
56
-
57
- # Automatically switch to the new profile
58
- config_manager.set_current_profile(profile.name)
59
- rprint(f"[green]Switched to profile '{profile.name}'[/green]")
60
- except Exception as e:
61
- rprint(f"[red]Error creating profile: {e}[/red]")
62
- raise click.Abort()
63
-
64
- except ValueError as e:
65
- rprint(f"[red]Error: {e}[/red]")
66
- raise click.Abort()
67
- except Exception as e:
68
- rprint(f"[red]Error: {e}[/red]")
69
- raise click.Abort()
70
-
71
-
72
- @profiles.command("list")
73
- @global_options
74
- def list_profiles() -> None:
75
- """List all profiles"""
76
- try:
77
- profiles = config_manager.list_profiles()
78
- current_name = config_manager.get_current_profile_name()
79
-
80
- if not profiles:
81
- rprint("[yellow]No profiles found[/yellow]")
82
- rprint("Create one with: [cyan]llamactl profile create[/cyan]")
83
- return
84
-
85
- table = Table(title="Profiles")
86
- table.add_column("Name", style="cyan")
87
- table.add_column("API URL", style="green")
88
- table.add_column("Active Project", style="yellow")
89
- table.add_column("Current", style="magenta")
90
-
91
- for profile in profiles:
92
- is_current = "✓" if profile.name == current_name else ""
93
- active_project = profile.active_project_id or "-"
94
- table.add_row(profile.name, profile.api_url, active_project, is_current)
95
-
96
- console.print(table)
97
-
98
- except Exception as e:
99
- rprint(f"[red]Error: {e}[/red]")
100
- raise click.Abort()
101
-
102
-
103
- @profiles.command("switch")
104
- @global_options
105
- @click.argument("name", required=False)
106
- def switch_profile(name: str | None) -> None:
107
- """Switch to a different profile"""
108
- try:
109
- name = select_profile(name)
110
- if not name:
111
- rprint("[yellow]No profile selected[/yellow]")
112
- return
113
-
114
- profile = config_manager.get_profile(name)
115
- if not profile:
116
- rprint(f"[red]Profile '{name}' not found[/red]")
117
- raise click.Abort()
118
-
119
- config_manager.set_current_profile(name)
120
- rprint(f"[green]Switched to profile '{name}'[/green]")
121
-
122
- except Exception as e:
123
- rprint(f"[red]Error: {e}[/red]")
124
- raise click.Abort()
125
-
126
-
127
- @profiles.command("delete")
128
- @global_options
129
- @click.argument("name", required=False)
130
- def delete_profile(name: str | None) -> None:
131
- """Delete a profile"""
132
- try:
133
- name = select_profile(name)
134
- if not name:
135
- rprint("[yellow]No profile selected[/yellow]")
136
- return
137
-
138
- profile = config_manager.get_profile(name)
139
- if not profile:
140
- rprint(f"[red]Profile '{name}' not found[/red]")
141
- raise click.Abort()
142
-
143
- if config_manager.delete_profile(name):
144
- rprint(f"[green]Deleted profile '{name}'[/green]")
145
- else:
146
- rprint(f"[red]Profile '{name}' not found[/red]")
147
-
148
- except Exception as e:
149
- rprint(f"[red]Error: {e}[/red]")
150
- raise click.Abort()
151
-
152
-
153
- @profiles.command("edit")
154
- @global_options
155
- @click.argument("name", required=False)
156
- def edit_profile(name: str | None) -> None:
157
- """Edit a profile"""
158
- try:
159
- name = select_profile(name)
160
- if not name:
161
- rprint("[yellow]No profile selected[/yellow]")
162
- return
163
-
164
- # Get current profile
165
- maybe_profile = config_manager.get_profile(name)
166
- if not maybe_profile:
167
- rprint(f"[red]Profile '{name}' not found[/red]")
168
- raise click.Abort()
169
- profile = maybe_profile
170
-
171
- # Use the interactive edit menu
172
- updated = edit_profile_form(profile)
173
- if updated is None:
174
- rprint("[yellow]Cancelled[/yellow]")
175
- return
176
-
177
- try:
178
- current_profile = config_manager.get_current_profile()
179
- if not current_profile or current_profile.name != updated.name:
180
- config_manager.set_current_profile(updated.name)
181
- rprint(f"[green]Updated profile '{profile.name}'[/green]")
182
- except Exception as e:
183
- rprint(f"[red]Error updating profile: {e}[/red]")
184
- raise click.Abort()
185
-
186
- except Exception as e:
187
- rprint(f"[red]Error: {e}[/red]")
188
- raise click.Abort()
189
-
190
-
191
- # Projects commands
192
- @profiles.command("list-projects")
193
- @global_options
194
- def list_projects() -> None:
195
- """List all projects with deployment counts"""
196
- try:
197
- client = get_control_plane_client()
198
- projects = client.list_projects()
199
-
200
- if not projects:
201
- rprint("[yellow]No projects found[/yellow]")
202
- return
203
-
204
- table = Table(title="Projects")
205
- table.add_column("Project ID", style="cyan")
206
- table.add_column("Deployments", style="green")
207
-
208
- for project in projects:
209
- project_id = project.project_id
210
- deployment_count = project.deployment_count
211
- table.add_row(project_id, str(deployment_count))
212
-
213
- console.print(table)
214
-
215
- except Exception as e:
216
- rprint(f"[red]Error: {e}[/red]")
217
- raise click.Abort()