agentstack-cli 0.6.0rc4__py3-none-any.whl → 0.6.1rc1__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.
@@ -3,9 +3,11 @@
3
3
 
4
4
  import abc
5
5
  import importlib.resources
6
+ import json
6
7
  import pathlib
7
8
  import shlex
8
9
  import typing
10
+ from enum import StrEnum
9
11
  from subprocess import CompletedProcess
10
12
  from textwrap import dedent
11
13
 
@@ -17,6 +19,13 @@ from agentstack_cli.configuration import Configuration
17
19
  from agentstack_cli.utils import merge, run_command
18
20
 
19
21
 
22
+ class ImagePullMode(StrEnum):
23
+ guest = "guest"
24
+ host = "host"
25
+ hybrid = "hybrid"
26
+ skip = "skip"
27
+
28
+
20
29
  class BaseDriver(abc.ABC):
21
30
  vm_name: str
22
31
 
@@ -54,6 +63,34 @@ class BaseDriver(abc.ABC):
54
63
  @abc.abstractmethod
55
64
  async def exec(self, command: list[str]) -> None: ...
56
65
 
66
+ def _canonify(self, tag: str) -> str:
67
+ return tag if "." in tag.split("/")[0] else f"docker.io/{tag}"
68
+
69
+ async def _grab_image_shas(
70
+ self,
71
+ *,
72
+ mode: typing.Literal["guest", "host"],
73
+ ) -> dict[str, str]:
74
+ return {
75
+ tag: sha
76
+ for line in (
77
+ await run_command(
78
+ ["docker", "images", "--digests"],
79
+ "Listing host images",
80
+ )
81
+ if mode == "host"
82
+ else await self.run_in_vm(
83
+ ["k3s", "ctr", "image", "ls"],
84
+ "Listing guest images",
85
+ )
86
+ )
87
+ .stdout.decode()
88
+ .splitlines()[1:]
89
+ if (x := line.split())
90
+ and (sha := x[2])
91
+ and ((tag := self._canonify((x[0] + ":" + x[1]) if mode == "host" else x[0])) in self.loaded_images)
92
+ }
93
+
57
94
  async def install_tools(self) -> None:
58
95
  # Configure k3s registry for local registry access
59
96
  registry_config = dedent(
@@ -103,10 +140,7 @@ class BaseDriver(abc.ABC):
103
140
  self,
104
141
  set_values_list: list[str],
105
142
  values_file: pathlib.Path | None = None,
106
- import_images: list[str] | None = None,
107
- pull_on_host: bool = False,
108
- skip_pull: bool = False,
109
- skip_restart_deployments: bool = False,
143
+ image_pull_mode: ImagePullMode = ImagePullMode.guest,
110
144
  ) -> None:
111
145
  _ = await self.run_in_vm(
112
146
  ["sh", "-c", "mkdir -p /tmp/agentstack && cat >/tmp/agentstack/chart.tgz"],
@@ -138,37 +172,42 @@ class BaseDriver(abc.ABC):
138
172
  input=yaml.dump(values).encode("utf-8"),
139
173
  )
140
174
 
141
- images_str = (
142
- await self.run_in_vm(
143
- [
144
- "/bin/bash",
145
- "-c",
146
- "helm template agentstack /tmp/agentstack/chart.tgz --values=/tmp/agentstack/values.yaml "
147
- + " ".join(shlex.quote(f"--set={value}") for value in set_values_list)
148
- + " | sed -n '/^\\s*image:/{ /{{/!{ s/.*image:\\s*//p } }'",
149
- ],
150
- "Listing necessary images",
175
+ self.loaded_images = {
176
+ self._canonify(typing.cast(str, yaml.safe_load(line)))
177
+ for line in (
178
+ await self.run_in_vm(
179
+ [
180
+ "/bin/bash",
181
+ "-c",
182
+ "helm template agentstack /tmp/agentstack/chart.tgz --values=/tmp/agentstack/values.yaml "
183
+ + " ".join(shlex.quote(f"--set={value}") for value in set_values_list)
184
+ + " | sed -n '/^\\s*image:/{ /{{/!{ s/.*image:\\s*//p } }'",
185
+ ],
186
+ "Listing necessary images",
187
+ )
151
188
  )
152
- ).stdout.decode()
153
-
154
- def canonify(tag: str) -> str:
155
- return tag if "." in tag.split("/")[0] else f"docker.io/{tag}"
189
+ .stdout.decode()
190
+ .splitlines()
191
+ }
156
192
 
157
- required_images = {canonify(typing.cast(str, yaml.safe_load(line))) for line in images_str.splitlines()}
158
- images_to_import = {canonify(tag) for tag in import_images or []}
159
- images_to_pull = required_images - images_to_import
193
+ images_to_import_from_host = set[str]()
194
+ shas_guest_before = dict[str, str]()
160
195
 
161
- if not skip_pull:
162
- if pull_on_host:
196
+ if image_pull_mode in {ImagePullMode.host, ImagePullMode.hybrid}:
197
+ shas_guest_before = await self._grab_image_shas(mode="guest")
198
+ shas_host = await self._grab_image_shas(mode="host")
199
+ if image_pull_mode == ImagePullMode.host and (images_to_pull := self.loaded_images - shas_host.keys()):
163
200
  for image in images_to_pull:
164
- await run_command(["docker", "pull", image], f"Pulling image {image} on host")
165
- images_to_import = required_images
166
- images_to_pull = set[str]()
201
+ await run_command(
202
+ ["docker", "pull", image],
203
+ f"Pulling image {image} on host",
204
+ )
205
+ shas_host = await self._grab_image_shas(mode="host")
206
+ images_to_import_from_host = dict(shas_host.items() - shas_guest_before.items()).keys() & self.loaded_images
207
+ await self.import_images(*images_to_import_from_host)
167
208
 
168
- if images_to_import:
169
- await self.import_images(*images_to_import)
170
-
171
- for image in images_to_pull:
209
+ if image_pull_mode in {ImagePullMode.guest, ImagePullMode.hybrid}:
210
+ for image in self.loaded_images - images_to_import_from_host:
172
211
  async for attempt in AsyncRetrying(stop=stop_after_attempt(5)):
173
212
  with attempt:
174
213
  attempt_num = attempt.retry_state.attempt_number
@@ -176,10 +215,6 @@ class BaseDriver(abc.ABC):
176
215
  ["k3s", "ctr", "image", "pull", image],
177
216
  f"Pulling image {image}" + (f" (attempt {attempt_num})" if attempt_num > 1 else ""),
178
217
  )
179
- elif images_to_import:
180
- await self.import_images(*images_to_import)
181
-
182
- self.loaded_images = required_images
183
218
 
184
219
  kubeconfig_path = anyio.Path(Configuration().lima_home) / self.vm_name / "copied-from-guest" / "kubeconfig.yaml"
185
220
  await kubeconfig_path.parent.mkdir(parents=True, exist_ok=True)
@@ -210,8 +245,35 @@ class BaseDriver(abc.ABC):
210
245
  "Deploying Agent Stack platform with Helm",
211
246
  )
212
247
 
213
- if import_images and not skip_restart_deployments:
214
- await self.run_in_vm(
215
- ["k3s", "kubectl", "rollout", "restart", "deployment"],
216
- "Restarting deployments to load imported images",
217
- )
248
+ if shas_guest_before and (
249
+ replaced_digests := set(shas_guest_before.values())
250
+ - set((await self._grab_image_shas(mode="guest")).values())
251
+ ):
252
+ for pod in dict.get(
253
+ json.loads(
254
+ (
255
+ await self.run_in_vm(
256
+ ["k3s", "kubectl", "get", "pods", "-o", "json", "--all-namespaces"],
257
+ "Getting pods",
258
+ )
259
+ ).stdout
260
+ ),
261
+ "items",
262
+ [],
263
+ ):
264
+ if any(
265
+ container_status.get("imageID", "") in replaced_digests
266
+ for container_status in pod.get("status", {}).get("containerStatuses", [])
267
+ ):
268
+ await self.run_in_vm(
269
+ [
270
+ "k3s",
271
+ "kubectl",
272
+ "delete",
273
+ "pod",
274
+ pod["metadata"]["name"],
275
+ "-n",
276
+ pod["metadata"]["namespace"],
277
+ ],
278
+ f"Removing pod with obsolete image {pod['metadata']['namespace']}/{pod['metadata']['name']}",
279
+ )
@@ -181,6 +181,8 @@ class LimaDriver(BaseDriver):
181
181
 
182
182
  @typing.override
183
183
  async def import_images(self, *tags: str):
184
+ if not tags:
185
+ return
184
186
  image_dir = anyio.Path("/tmp/agentstack")
185
187
  await image_dir.mkdir(exist_ok=True, parents=True)
186
188
  image_file = str(uuid.uuid4())
@@ -13,7 +13,7 @@ import anyio
13
13
  import pydantic
14
14
  import yaml
15
15
 
16
- from agentstack_cli.commands.platform.base_driver import BaseDriver
16
+ from agentstack_cli.commands.platform.base_driver import BaseDriver, ImagePullMode
17
17
  from agentstack_cli.configuration import Configuration
18
18
  from agentstack_cli.console import console
19
19
  from agentstack_cli.utils import run_command
@@ -130,13 +130,10 @@ class WSLDriver(BaseDriver):
130
130
  self,
131
131
  set_values_list: list[str],
132
132
  values_file: pathlib.Path | None = None,
133
- import_images: list[str] | None = None,
134
- pull_on_host: bool = False,
135
- skip_pull: bool = False,
136
- skip_restart_deployments: bool = False,
133
+ image_pull_mode: ImagePullMode = ImagePullMode.guest,
137
134
  ) -> None:
138
- if pull_on_host:
139
- raise NotImplementedError("Pulling on host is not supported on this platform.")
135
+ if image_pull_mode in {ImagePullMode.host, ImagePullMode.hybrid}:
136
+ raise NotImplementedError("Importing host images is not supported on Windows.")
140
137
 
141
138
  host_ip = (
142
139
  (
@@ -162,7 +159,7 @@ class WSLDriver(BaseDriver):
162
159
  }
163
160
  ).encode(),
164
161
  )
165
- await super().deploy(set_values_list=set_values_list, values_file=values_file, import_images=import_images)
162
+ await super().deploy(set_values_list=set_values_list, values_file=values_file, image_pull_mode=image_pull_mode)
166
163
  await self.run_in_vm(
167
164
  ["sh", "-c", "cat >/etc/systemd/system/kubectl-port-forward@.service"],
168
165
  "Installing systemd unit for port-forwarding",
@@ -130,7 +130,7 @@ async def install(
130
130
  ).execute_async()
131
131
  ):
132
132
  try:
133
- await agentstack_cli.commands.platform.start(set_values_list=[], import_images=[], verbose=verbose)
133
+ await agentstack_cli.commands.platform.start(set_values_list=[], verbose=verbose)
134
134
  already_started = True
135
135
  console.print()
136
136
  except Exception:
@@ -190,7 +190,7 @@ async def upgrade(
190
190
  "Upgrading agentstack-cli",
191
191
  env={"PATH": _path()},
192
192
  )
193
- await agentstack_cli.commands.platform.start(set_values_list=[], import_images=[], verbose=verbose)
193
+ await agentstack_cli.commands.platform.start(set_values_list=[], verbose=verbose)
194
194
  await version(verbose=verbose)
195
195
 
196
196
 
@@ -13,6 +13,7 @@ import httpx
13
13
  import typer
14
14
  import uvicorn
15
15
  from authlib.common.security import generate_token
16
+ from authlib.oauth2.rfc6749.errors import InvalidGrantError, OAuth2Error
16
17
  from authlib.oauth2.rfc7636 import create_s256_code_challenge
17
18
  from fastapi import FastAPI, Request
18
19
  from fastapi.responses import HTMLResponse
@@ -104,11 +105,19 @@ async def server_login(server: typing.Annotated[str | None, typer.Argument()] =
104
105
 
105
106
  server = server.rstrip("/")
106
107
 
108
+ log_in_message = "No authentication tokens found for this server. Proceeding to log in."
109
+
107
110
  if server_data := config.auth_manager.get_server(server):
108
- console.info("Switching to an already logged in server.")
111
+ console.info("Logging in to an already logged in server.")
109
112
  auth_server = None
110
113
  auth_servers = list(server_data.authorization_servers.keys())
111
- if len(auth_servers) == 1:
114
+ if not auth_servers:
115
+ # Known server with no auth servers - save and exit
116
+ config.auth_manager.active_server = server
117
+ config.auth_manager.active_auth_server = auth_server
118
+ console.success(f"Logged in to [cyan]{server}[/cyan].")
119
+ return
120
+ elif len(auth_servers) == 1:
112
121
  auth_server = auth_servers[0]
113
122
  elif len(auth_servers) > 1:
114
123
  auth_server = await inquirer.select( # type: ignore
@@ -127,129 +136,106 @@ async def server_login(server: typing.Annotated[str | None, typer.Argument()] =
127
136
  if not auth_server:
128
137
  console.info("Action cancelled.")
129
138
  sys.exit(1)
130
- else:
131
- console.info("No authentication tokens found for this server. Proceeding to log in.")
132
- async with httpx.AsyncClient() as client:
133
- resp = await client.get(f"{server}/.well-known/oauth-protected-resource/", follow_redirects=True)
134
- if resp.is_error:
135
- console.error("This server does not appear to run a compatible version of Agent Stack Platform.")
136
- sys.exit(1)
137
- metadata = resp.json()
138
139
 
139
- auth_servers = metadata.get("authorization_servers", [])
140
- auth_server = None
141
- token = None
140
+ # Validate that the token is still valid by attempting to load it
141
+ # Keep the original active server/auth server in case of failure
142
+ previous_server = config.auth_manager.active_server
143
+ previous_auth_server = config.auth_manager.active_auth_server
142
144
 
143
- client_id = config.client_id
144
- client_secret = config.client_secret
145
- registration_token = None
145
+ config.auth_manager.active_server = server
146
+ config.auth_manager.active_auth_server = auth_server
146
147
 
147
- if auth_servers:
148
- if len(auth_servers) == 1:
149
- auth_server = auth_servers[0]
148
+ try:
149
+ token = await config.auth_manager.load_auth_token()
150
+ if not token:
151
+ # No token available, need to log in
152
+ # Restore previous state until login completes
153
+ config.auth_manager.active_server = previous_server
154
+ config.auth_manager.active_auth_server = previous_auth_server
155
+ # Fall through to login flow below
150
156
  else:
151
- auth_server = await inquirer.select( # type: ignore
152
- message="Select an authorization server:",
153
- choices=auth_servers,
154
- ).execute_async()
155
-
156
- if not auth_server:
157
- raise RuntimeError("No authorization server selected.")
158
-
159
- async with httpx.AsyncClient() as client:
160
- try:
161
- resp = await client.get(f"{auth_server}/.well-known/openid-configuration")
162
- resp.raise_for_status()
163
- oidc = resp.json()
164
- except Exception as e:
165
- raise RuntimeError(f"OIDC discovery failed: {e}") from e
166
-
167
- registration_endpoint = oidc["registration_endpoint"]
168
- if not client_id and registration_endpoint:
169
- async with httpx.AsyncClient() as client:
170
- resp = None
171
- try:
172
- app_name = get_unique_app_name()
173
- resp = await client.post(
174
- registration_endpoint,
175
- json={
176
- "client_name": app_name,
177
- "grant_types": ["authorization_code", "refresh_token"],
178
- "enforce_pkce": True,
179
- "all_users_entitled": True,
180
- "redirect_uris": [REDIRECT_URI],
181
- },
182
- )
183
- resp.raise_for_status()
184
- data = resp.json()
185
- client_id = data["client_id"]
186
- client_secret = data["client_secret"]
187
- registration_token = data["registration_access_token"]
188
- except Exception as e:
189
- if resp:
190
- try:
191
- error_details = resp.json()
192
- console.warning(
193
- f"error: {error_details['error']} error description: {error_details['error_description']}"
194
- )
195
-
196
- except Exception:
197
- console.info("no parsable json response.")
198
- console.warning(f" Dynamic client registration failed. Proceed with manual input. {e!s}")
199
-
200
- if not client_id:
201
- client_id = (
202
- await inquirer.text( # type: ignore
203
- message="Enter Client ID (default agentstack-cli):",
204
- instruction=f"(Redirect URI: {REDIRECT_URI})",
205
- ).execute_async()
206
- or "agentstack-cli"
207
- )
208
- if not client_id:
209
- raise RuntimeError("Client ID is mandatory. Action cancelled.")
210
- client_secret = (
211
- await inquirer.text( # type: ignore
212
- message="Enter Client Secret (optional):"
213
- ).execute_async()
214
- or None
215
- )
216
-
217
- code_verifier = generate_token(64)
218
-
219
- auth_url = f"{oidc['authorization_endpoint']}?{
220
- urlencode(
221
- {
222
- 'client_id': client_id,
223
- 'response_type': 'code',
224
- 'redirect_uri': REDIRECT_URI,
225
- 'scope': ' '.join(metadata.get('scopes_supported', ['openid', 'email', 'profile'])),
226
- 'code_challenge': typing.cast(str, create_s256_code_challenge(code_verifier)),
227
- 'code_challenge_method': 'S256',
228
- }
229
- )
230
- }"
157
+ console.success(f"Logged in to [cyan]{server}[/cyan].")
158
+ return
159
+ except InvalidGrantError:
160
+ # Token refresh failed due to invalid/expired refresh token
161
+ log_in_message = "Your session has expired. Please log in again."
162
+ # Restore previous state until login completes
163
+ config.auth_manager.active_server = previous_server
164
+ config.auth_manager.active_auth_server = previous_auth_server
165
+ # Fall through to login flow below
166
+ except OAuth2Error as e:
167
+ # Other OAuth2 protocol errors - report but don't continue
168
+ console.error(f"OAuth2 error: {e.description}")
169
+ config.auth_manager.active_server = previous_server
170
+ config.auth_manager.active_auth_server = previous_auth_server
171
+ sys.exit(1)
172
+ except RuntimeError as e:
173
+ # Network or OIDC discovery errors - report but don't continue
174
+ console.error(f"Failed to validate authentication: {e}")
175
+ console.hint("Check your network connection and try again.")
176
+ config.auth_manager.active_server = previous_server
177
+ config.auth_manager.active_auth_server = previous_auth_server
178
+ sys.exit(1)
179
+
180
+ # Starting the login flow
181
+ console.info(log_in_message)
182
+
183
+ async with httpx.AsyncClient() as client:
184
+ resp = await client.get(f"{server}/.well-known/oauth-protected-resource/", follow_redirects=True)
185
+ if resp.is_error:
186
+ console.error("This server does not appear to run a compatible version of Agent Stack Platform.")
187
+ sys.exit(1)
188
+ metadata = resp.json()
189
+
190
+ auth_servers = metadata.get("authorization_servers", [])
191
+ auth_server = None
192
+ token = None
193
+
194
+ client_id = config.client_id
195
+ client_secret = config.client_secret
196
+ registration_token = None
197
+
198
+ if auth_servers:
199
+ if len(auth_servers) == 1:
200
+ auth_server = auth_servers[0]
201
+ else:
202
+ auth_server = await inquirer.select( # type: ignore
203
+ message="Select an authorization server:",
204
+ choices=auth_servers,
205
+ ).execute_async()
231
206
 
232
- console.info(f"Opening browser for login: [cyan]{auth_url}[/cyan]")
233
- if not webbrowser.open(auth_url):
234
- console.warning("Could not open browser. Please visit the above URL manually.")
207
+ if not auth_server:
208
+ raise RuntimeError("No authorization server selected.")
235
209
 
236
- code = await _wait_for_auth_code()
210
+ async with httpx.AsyncClient() as client:
211
+ try:
212
+ resp = await client.get(f"{auth_server}/.well-known/openid-configuration")
213
+ resp.raise_for_status()
214
+ oidc = resp.json()
215
+ except Exception as e:
216
+ raise RuntimeError(f"OIDC discovery failed: {e}") from e
217
+
218
+ registration_endpoint = oidc["registration_endpoint"]
219
+ if not client_id and registration_endpoint:
237
220
  async with httpx.AsyncClient() as client:
238
- token_resp = None
221
+ resp = None
239
222
  try:
240
- token_resp = await client.post(
241
- oidc["token_endpoint"],
242
- data={
243
- "grant_type": "authorization_code",
244
- "code": code,
245
- "redirect_uri": REDIRECT_URI,
246
- "client_id": client_id,
247
- "client_secret": client_secret,
248
- "code_verifier": code_verifier,
223
+ app_name = get_unique_app_name()
224
+ resp = await client.post(
225
+ registration_endpoint,
226
+ json={
227
+ "client_name": app_name,
228
+ "grant_types": ["authorization_code", "refresh_token"],
229
+ "enforce_pkce": True,
230
+ "all_users_entitled": True,
231
+ "redirect_uris": [REDIRECT_URI],
249
232
  },
250
233
  )
251
- token_resp.raise_for_status()
252
- token = token_resp.json()
234
+ resp.raise_for_status()
235
+ data = resp.json()
236
+ client_id = data["client_id"]
237
+ client_secret = data["client_secret"]
238
+ registration_token = data["registration_access_token"]
253
239
  except Exception as e:
254
240
  if resp:
255
241
  try:
@@ -257,22 +243,87 @@ async def server_login(server: typing.Annotated[str | None, typer.Argument()] =
257
243
  console.warning(
258
244
  f"error: {error_details['error']} error description: {error_details['error_description']}"
259
245
  )
246
+
260
247
  except Exception:
261
248
  console.info("no parsable json response.")
249
+ console.warning(f" Dynamic client registration failed. Proceed with manual input. {e!s}")
262
250
 
263
- raise RuntimeError(f"Token request failed: {e}") from e
251
+ if not client_id:
252
+ client_id = (
253
+ await inquirer.text( # type: ignore
254
+ message="Enter Client ID:",
255
+ instruction=f"(Redirect URI: {REDIRECT_URI})",
256
+ ).execute_async()
257
+ or "agentstack-cli"
258
+ )
259
+ if not client_id:
260
+ raise RuntimeError("Client ID is mandatory. Action cancelled.")
261
+ client_secret = (
262
+ await inquirer.text( # type: ignore
263
+ message="Enter Client Secret (optional):"
264
+ ).execute_async()
265
+ or None
266
+ )
267
+
268
+ code_verifier = generate_token(64)
269
+
270
+ auth_url = f"{oidc['authorization_endpoint']}?{
271
+ urlencode(
272
+ {
273
+ 'client_id': client_id,
274
+ 'response_type': 'code',
275
+ 'redirect_uri': REDIRECT_URI,
276
+ 'scope': ' '.join(metadata.get('scopes_supported', ['openid', 'email', 'profile'])),
277
+ 'code_challenge': typing.cast(str, create_s256_code_challenge(code_verifier)),
278
+ 'code_challenge_method': 'S256',
279
+ }
280
+ )
281
+ }"
282
+
283
+ console.info(f"Opening browser for login: [cyan]{auth_url}[/cyan]")
284
+ if not webbrowser.open(auth_url):
285
+ console.warning("Could not open browser. Please visit the above URL manually.")
286
+
287
+ code = await _wait_for_auth_code()
288
+ async with httpx.AsyncClient() as client:
289
+ token_resp = None
290
+ try:
291
+ token_resp = await client.post(
292
+ oidc["token_endpoint"],
293
+ data={
294
+ "grant_type": "authorization_code",
295
+ "code": code,
296
+ "redirect_uri": REDIRECT_URI,
297
+ "client_id": client_id,
298
+ "client_secret": client_secret,
299
+ "code_verifier": code_verifier,
300
+ },
301
+ )
302
+ token_resp.raise_for_status()
303
+ token = token_resp.json()
304
+ except Exception as e:
305
+ if token_resp:
306
+ try:
307
+ error_details = token_resp.json()
308
+ console.warning(
309
+ f"error: {error_details['error']} error description: {error_details['error_description']}"
310
+ )
311
+ except Exception:
312
+ console.info("no parsable json response.")
264
313
 
265
- if not token:
266
- raise RuntimeError("Login timed out or not successful.")
267
-
268
- config.auth_manager.save_auth_token(
269
- server=server,
270
- auth_server=auth_server,
271
- client_id=client_id,
272
- client_secret=client_secret,
273
- token=token,
274
- registration_token=registration_token,
275
- )
314
+ raise RuntimeError(f"Token request failed: {e}") from e
315
+
316
+ if not token:
317
+ raise RuntimeError("Login timed out or not successful.")
318
+
319
+ config.auth_manager.save_auth_info(
320
+ server=server,
321
+ auth_server=auth_server,
322
+ client_id=client_id,
323
+ client_secret=client_secret,
324
+ token=token,
325
+ registration_token=registration_token,
326
+ )
276
327
 
277
328
  config.auth_manager.active_server = server
278
329
  config.auth_manager.active_auth_server = auth_server
@@ -11,7 +11,7 @@ from rich.table import Column
11
11
 
12
12
  from agentstack_cli.async_typer import AsyncTyper, console, create_table
13
13
  from agentstack_cli.configuration import Configuration
14
- from agentstack_cli.utils import announce_server_action, confirm_server_action
14
+ from agentstack_cli.server_utils import announce_server_action, confirm_server_action
15
15
 
16
16
  app = AsyncTyper()
17
17
  configuration = Configuration()
@@ -13,6 +13,7 @@ from contextlib import asynccontextmanager
13
13
  import pydantic
14
14
  import pydantic_settings
15
15
  from agentstack_sdk.platform import PlatformClient, use_platform_client
16
+ from authlib.oauth2.rfc6749.errors import InvalidGrantError, OAuth2Error
16
17
  from pydantic import HttpUrl, SecretStr
17
18
 
18
19
  from agentstack_cli.auth_manager import AuthManager
@@ -64,9 +65,28 @@ class Configuration(pydantic_settings.BaseSettings):
64
65
  "Run [green]agentstack platform start[/green] to start a local server, or [green]agentstack server login[/green] to connect to a remote one."
65
66
  )
66
67
  sys.exit(1)
68
+
69
+ re_auth_message = (
70
+ f"Run [green]agentstack server login {self.auth_manager.active_server}[/green] to re-authenticate."
71
+ )
72
+ try:
73
+ auth_token = await self.auth_manager.load_auth_token()
74
+ except InvalidGrantError:
75
+ console.error("Your session has expired.")
76
+ console.hint(re_auth_message)
77
+ sys.exit(1)
78
+ except OAuth2Error as e:
79
+ console.error(f"OAuth2 error: {e.description}")
80
+ console.hint(re_auth_message)
81
+ sys.exit(1)
82
+ except RuntimeError as e:
83
+ console.error(f"Failed to load authentication: {e}")
84
+ console.hint("Check your network connection and try again.")
85
+ sys.exit(1)
86
+
67
87
  async with use_platform_client(
68
88
  auth=(self.username, self.password.get_secret_value()) if self.password else None,
69
- auth_token=await self.auth_manager.load_auth_token(),
89
+ auth_token=auth_token,
70
90
  base_url=self.auth_manager.active_server + "/",
71
91
  ) as client:
72
92
  yield client
Binary file