agentstack-cli 0.6.1rc1__tar.gz → 0.6.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {agentstack_cli-0.6.1rc1 → agentstack_cli-0.6.2}/PKG-INFO +1 -1
  2. {agentstack_cli-0.6.1rc1 → agentstack_cli-0.6.2}/pyproject.toml +7 -7
  3. {agentstack_cli-0.6.1rc1 → agentstack_cli-0.6.2}/src/agentstack_cli/async_typer.py +2 -0
  4. {agentstack_cli-0.6.1rc1 → agentstack_cli-0.6.2}/src/agentstack_cli/auth_manager.py +4 -1
  5. {agentstack_cli-0.6.1rc1 → agentstack_cli-0.6.2}/src/agentstack_cli/commands/agent.py +66 -59
  6. {agentstack_cli-0.6.1rc1 → agentstack_cli-0.6.2}/src/agentstack_cli/commands/build.py +0 -1
  7. {agentstack_cli-0.6.1rc1 → agentstack_cli-0.6.2}/src/agentstack_cli/commands/connector.py +1 -1
  8. {agentstack_cli-0.6.1rc1 → agentstack_cli-0.6.2}/src/agentstack_cli/commands/model.py +50 -39
  9. {agentstack_cli-0.6.1rc1 → agentstack_cli-0.6.2}/src/agentstack_cli/commands/platform/base_driver.py +81 -7
  10. {agentstack_cli-0.6.1rc1 → agentstack_cli-0.6.2}/src/agentstack_cli/commands/platform/lima_driver.py +17 -92
  11. {agentstack_cli-0.6.1rc1 → agentstack_cli-0.6.2}/src/agentstack_cli/commands/platform/wsl_driver.py +14 -15
  12. {agentstack_cli-0.6.1rc1 → agentstack_cli-0.6.2}/src/agentstack_cli/commands/self.py +6 -6
  13. {agentstack_cli-0.6.1rc1 → agentstack_cli-0.6.2}/src/agentstack_cli/commands/server.py +7 -15
  14. {agentstack_cli-0.6.1rc1 → agentstack_cli-0.6.2}/src/agentstack_cli/configuration.py +1 -1
  15. agentstack_cli-0.6.2/src/agentstack_cli/data/helm-chart.tgz +0 -0
  16. {agentstack_cli-0.6.1rc1 → agentstack_cli-0.6.2}/src/agentstack_cli/server_utils.py +1 -3
  17. {agentstack_cli-0.6.1rc1 → agentstack_cli-0.6.2}/src/agentstack_cli/utils.py +72 -5
  18. agentstack_cli-0.6.1rc1/src/agentstack_cli/data/helm-chart.tgz +0 -0
  19. {agentstack_cli-0.6.1rc1 → agentstack_cli-0.6.2}/README.md +0 -0
  20. {agentstack_cli-0.6.1rc1 → agentstack_cli-0.6.2}/src/agentstack_cli/__init__.py +0 -0
  21. {agentstack_cli-0.6.1rc1 → agentstack_cli-0.6.2}/src/agentstack_cli/api.py +0 -0
  22. {agentstack_cli-0.6.1rc1 → agentstack_cli-0.6.2}/src/agentstack_cli/commands/__init__.py +0 -0
  23. {agentstack_cli-0.6.1rc1 → agentstack_cli-0.6.2}/src/agentstack_cli/commands/platform/__init__.py +0 -0
  24. {agentstack_cli-0.6.1rc1 → agentstack_cli-0.6.2}/src/agentstack_cli/commands/user.py +0 -0
  25. {agentstack_cli-0.6.1rc1 → agentstack_cli-0.6.2}/src/agentstack_cli/console.py +0 -0
  26. {agentstack_cli-0.6.1rc1 → agentstack_cli-0.6.2}/src/agentstack_cli/data/.gitignore +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: agentstack-cli
3
- Version: 0.6.1rc1
3
+ Version: 0.6.2
4
4
  Summary: Agent Stack CLI
5
5
  Author: IBM Corp.
6
6
  Requires-Dist: anyio>=4.12.1
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentstack-cli"
3
- version = "0.6.1-rc1"
3
+ version = "0.6.2"
4
4
  description = "Agent Stack CLI"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "IBM Corp." }]
@@ -27,7 +27,7 @@ dependencies = [
27
27
 
28
28
  [dependency-groups]
29
29
  dev = [
30
- "pyright>=1.1.407", # note: pyright 1.1.408 has a bug with multiple-inheritance
30
+ "pyrefly>=0.52.0",
31
31
  "pytest>=9.0.2",
32
32
  "ruff>=0.14.14",
33
33
  "wheel>=0.46.3",
@@ -74,8 +74,8 @@ lint.ignore = [
74
74
  ]
75
75
  force-exclude = true
76
76
 
77
- [tool.pyright]
78
- ignore = ["tests/**", "examples/cli.py"]
79
- venvPath = "."
80
- venv = ".venv"
81
- reportUnusedCallResult = false
77
+ [tool.pyrefly]
78
+ project-includes = [
79
+ "**/*.py*",
80
+ "**/*.ipynb",
81
+ ]
@@ -22,6 +22,8 @@ from agentstack_cli.utils import extract_messages, format_error
22
22
 
23
23
  DEBUG = Configuration().debug
24
24
 
25
+ sys.unraisablehook = lambda _: None # Suppress benign cleanup errors
26
+
25
27
 
26
28
  class _LeftAlignedHeading(Heading):
27
29
  def __rich_console__(self, *args, **kwargs) -> RenderResult:
@@ -287,7 +287,10 @@ class AuthManager:
287
287
  def active_auth_server(self, auth_server: str | None) -> None:
288
288
  if auth_server is not None and (
289
289
  self._auth.active_server not in self._auth.servers
290
- or auth_server not in self._auth.servers[self._auth.active_server].authorization_servers
290
+ or (
291
+ self._auth.active_server is not None
292
+ and auth_server not in self._auth.servers[self._auth.active_server].authorization_servers
293
+ )
291
294
  ):
292
295
  raise ValueError(f"Auth server {auth_server} not found in active server")
293
296
  self._auth.active_auth_server = auth_server
@@ -93,12 +93,14 @@ from agentstack_cli.commands.build import _server_side_build
93
93
  from agentstack_cli.commands.model import ensure_llm_provider
94
94
  from agentstack_cli.configuration import Configuration
95
95
 
96
+ # This is necessary for proper handling of arrow keys in interactive input
96
97
  if sys.platform != "win32":
98
+ import importlib
99
+
97
100
  try:
98
- # This is necessary for proper handling of arrow keys in interactive input
99
- import gnureadline as readline
101
+ readline = importlib.import_module("gnureadline")
100
102
  except ImportError:
101
- import readline # noqa: F401
103
+ readline = importlib.import_module("readline")
102
104
 
103
105
  from collections.abc import Callable
104
106
  from pathlib import Path
@@ -115,6 +117,8 @@ from agentstack_cli.async_typer import AsyncTyper, console, create_table, err_co
115
117
  from agentstack_cli.server_utils import announce_server_action, confirm_server_action
116
118
  from agentstack_cli.utils import (
117
119
  generate_schema_example,
120
+ get_github_repo_tags,
121
+ github_url_verbose_pattern,
118
122
  is_github_url,
119
123
  parse_env_var,
120
124
  print_log,
@@ -233,44 +237,50 @@ async def add_agent(
233
237
  - **Combined Formats**: `https://github.com/myorg/myrepo.git@v1.0.0#path=/path/to/agent`
234
238
  - **Enterprise GitHub**: `https://github.mycompany.com/myorg/myrepo`
235
239
  - **With a custom Dockerfile location**: `agentstack add --dockerfile /my-agent/path/to/Dockerfile "https://github.com/my-org/my-awesome-agents@main#path=/my-agent"`
236
-
237
- [aliases: install]
238
240
  """
241
+ repo_input = location
239
242
  if location is None:
240
243
  repo_input = (
241
- await inquirer.text( # pyright: ignore[reportPrivateImportUsage]
244
+ await inquirer.text(
242
245
  message="Enter GitHub repository (owner/repo or full URL):",
243
246
  ).execute_async()
244
247
  or ""
245
248
  )
246
249
 
247
- match = re.search(r"^(?:(?:https?://)?(?:www\.)?github\.com/)?([^/]+)/([^/?&]+)", repo_input)
248
- if not match:
249
- raise ValueError(f"Invalid GitHub URL format: {repo_input}. Expected 'owner/repo' or a full GitHub URL.")
250
+ if not repo_input:
251
+ console.error("No location provided. Exiting.")
252
+ sys.exit(1)
250
253
 
251
- owner, repo = match.group(1), match.group(2).removesuffix(".git")
254
+ if match := re.match(github_url_verbose_pattern, repo_input, re.VERBOSE):
255
+ owner, repo, version, path = (
256
+ match.group("org"),
257
+ match.group("repo").removesuffix(".git"),
258
+ match.group("version"),
259
+ match.group("path"),
260
+ )
252
261
 
253
- async with httpx.AsyncClient() as client:
254
- response = await client.get(
255
- f"https://api.github.com/repos/{owner}/{repo}/tags",
256
- headers={"Accept": "application/vnd.github.v3+json"},
257
- )
258
- tags = [tag["name"] for tag in response.json()] if response.status_code == 200 else []
262
+ if version is None and path is None:
263
+ host = match.group("host")
264
+ tags = await get_github_repo_tags(host, owner, repo)
259
265
 
260
- if tags:
261
- selected_tag = await inquirer.fuzzy( # pyright: ignore[reportPrivateImportUsage]
262
- message="Select a tag to use:",
263
- choices=tags,
264
- ).execute_async()
265
- else:
266
- selected_tag = (
267
- await inquirer.text( # pyright: ignore[reportPrivateImportUsage]
268
- message="Enter tag to use:",
266
+ if tags:
267
+ selected_tag = await inquirer.fuzzy(
268
+ message="Select a tag to use:",
269
+ choices=tags,
269
270
  ).execute_async()
270
- or "main"
271
- )
271
+ else:
272
+ selected_tag = (
273
+ await inquirer.text(
274
+ message="Enter tag to use:",
275
+ ).execute_async()
276
+ or "main"
277
+ )
272
278
 
273
- location = f"https://github.com/{owner}/{repo}@{selected_tag}"
279
+ location = f"https://github.com/{owner}/{repo}@{selected_tag}"
280
+ else:
281
+ location = repo_input
282
+ else:
283
+ location = repo_input
274
284
 
275
285
  url = announce_server_action(f"Installing agent '{location}' for")
276
286
  await confirm_server_action("Proceed with installing this agent on", url=url, yes=yes)
@@ -327,7 +337,7 @@ async def update_agent(
327
337
  provider_choices = [
328
338
  Choice(value=p, name=f"{p.agent_card.name} ({ProviderUtils.short_location(p)})") for p in providers
329
339
  ]
330
- provider = await inquirer.fuzzy( # pyright: ignore[reportPrivateImportUsage]
340
+ provider = await inquirer.fuzzy(
331
341
  message="Select an agent to update:",
332
342
  choices=provider_choices,
333
343
  ).execute_async()
@@ -337,20 +347,20 @@ async def update_agent(
337
347
  else:
338
348
  provider = select_provider(search_path, providers=providers)
339
349
 
340
- if location is None and is_github_url(provider.source):
341
- match = re.search(r"^(?:(?:https?://)?(?:www\.)?github\.com/)?([^/]+)/([^/@?&]+)", provider.source)
350
+ if location is None and is_github_url(provider.origin):
351
+ match = re.match(github_url_verbose_pattern, provider.origin, re.VERBOSE)
352
+
342
353
  if match:
343
- owner, repo = match.group(1), match.group(2).removesuffix(".git")
354
+ host, owner, repo = (
355
+ match.group("host"),
356
+ match.group("owner"),
357
+ match.group("repo").removesuffix(".git"),
358
+ )
344
359
 
345
- async with httpx.AsyncClient() as client:
346
- response = await client.get(
347
- f"https://api.github.com/repos/{owner}/{repo}/tags",
348
- headers={"Accept": "application/vnd.github.v3+json"},
349
- )
350
- tags = [tag["name"] for tag in response.json()] if response.status_code == 200 else []
360
+ tags = await get_github_repo_tags(host, owner, repo)
351
361
 
352
362
  if tags:
353
- selected_tag = await inquirer.fuzzy( # pyright: ignore[reportPrivateImportUsage]
363
+ selected_tag = await inquirer.fuzzy(
354
364
  message="Select a new tag to use:",
355
365
  choices=tags,
356
366
  ).execute_async()
@@ -359,9 +369,9 @@ async def update_agent(
359
369
 
360
370
  if location is None:
361
371
  location = (
362
- await inquirer.text( # pyright: ignore[reportPrivateImportUsage]
372
+ await inquirer.text(
363
373
  message="Enter new agent location (public docker image or github url):",
364
- default=provider.source,
374
+ default=provider.origin.lstrip("git+"),
365
375
  ).execute_async()
366
376
  or ""
367
377
  )
@@ -370,7 +380,7 @@ async def update_agent(
370
380
  console.error("No location provided. Exiting.")
371
381
  sys.exit(1)
372
382
 
373
- url = announce_server_action(f"Upgrading agent from '{provider.source}' to {location}")
383
+ url = announce_server_action(f"Upgrading agent from '{provider.origin}' to {location}")
374
384
  await confirm_server_action("Proceed with upgrading agent on", url=url, yes=yes)
375
385
 
376
386
  if is_github_url(location):
@@ -428,7 +438,7 @@ async def select_providers_multi(search_path: str, providers: list[Provider]) ->
428
438
  # Multiple matches - show selection menu
429
439
  choices = [Choice(value=p.id, name=f"{p.agent_card.name} - {p.id}") for p in provider_candidates.values()]
430
440
 
431
- selected_ids = await inquirer.checkbox( # pyright: ignore[reportPrivateImportUsage]
441
+ selected_ids = await inquirer.checkbox(
432
442
  message="Select agents to remove (use ↑/↓ to navigate, Space to select):", choices=choices
433
443
  ).execute_async()
434
444
 
@@ -512,7 +522,7 @@ async def _ask_form_questions(form_render: FormRender) -> FormResponse:
512
522
 
513
523
  for field in form_render.fields:
514
524
  if isinstance(field, TextField):
515
- answer = await inquirer.text( # pyright: ignore[reportPrivateImportUsage]
525
+ answer = await inquirer.text(
516
526
  message=field.label + ":",
517
527
  default=field.default_value or "",
518
528
  validate=EmptyInputValidator() if field.required else None,
@@ -520,7 +530,7 @@ async def _ask_form_questions(form_render: FormRender) -> FormResponse:
520
530
  form_values[field.id] = TextFieldValue(value=answer)
521
531
  elif isinstance(field, SingleSelectField):
522
532
  choices = [Choice(value=opt.id, name=opt.label) for opt in field.options]
523
- answer = await inquirer.fuzzy( # pyright: ignore[reportPrivateImportUsage]
533
+ answer = await inquirer.fuzzy(
524
534
  message=field.label + ":",
525
535
  choices=choices,
526
536
  default=field.default_value,
@@ -529,7 +539,7 @@ async def _ask_form_questions(form_render: FormRender) -> FormResponse:
529
539
  form_values[field.id] = SingleSelectFieldValue(value=answer)
530
540
  elif isinstance(field, MultiSelectField):
531
541
  choices = [Choice(value=opt.id, name=opt.label) for opt in field.options]
532
- answer = await inquirer.checkbox( # pyright: ignore[reportPrivateImportUsage]
542
+ answer = await inquirer.checkbox(
533
543
  message=field.label + ":",
534
544
  choices=choices,
535
545
  default=field.default_value,
@@ -538,14 +548,14 @@ async def _ask_form_questions(form_render: FormRender) -> FormResponse:
538
548
  form_values[field.id] = MultiSelectFieldValue(value=answer)
539
549
 
540
550
  elif isinstance(field, DateField):
541
- year = await inquirer.text( # pyright: ignore[reportPrivateImportUsage]
551
+ year = await inquirer.text(
542
552
  message=f"{field.label} (year):",
543
553
  validate=EmptyInputValidator() if field.required else None,
544
554
  filter=lambda y: y.strip(),
545
555
  ).execute_async()
546
556
  if not year:
547
557
  continue
548
- month = await inquirer.fuzzy( # pyright: ignore[reportPrivateImportUsage]
558
+ month = await inquirer.fuzzy(
549
559
  message=f"{field.label} (month):",
550
560
  validate=EmptyInputValidator() if field.required else None,
551
561
  choices=[
@@ -558,7 +568,7 @@ async def _ask_form_questions(form_render: FormRender) -> FormResponse:
558
568
  ).execute_async()
559
569
  if not month:
560
570
  continue
561
- day = await inquirer.fuzzy( # pyright: ignore[reportPrivateImportUsage]
571
+ day = await inquirer.fuzzy(
562
572
  message=f"{field.label} (day):",
563
573
  validate=EmptyInputValidator() if field.required else None,
564
574
  choices=[
@@ -571,7 +581,7 @@ async def _ask_form_questions(form_render: FormRender) -> FormResponse:
571
581
  full_date = f"{year}-{month}-{day}"
572
582
  form_values[field.id] = DateFieldValue(value=full_date)
573
583
  elif isinstance(field, CheckboxField):
574
- answer = await inquirer.confirm( # pyright: ignore[reportPrivateImportUsage]
584
+ answer = await inquirer.confirm(
575
585
  message=field.label + ":",
576
586
  default=field.default_value,
577
587
  long_instruction=field.content or "",
@@ -591,7 +601,7 @@ async def _ask_settings_questions(settings_render: SettingsRender) -> AgentRunSe
591
601
  if isinstance(field, CheckboxGroupField):
592
602
  checkbox_values: dict[str, SettingsCheckboxFieldValue] = {}
593
603
  for checkbox in field.fields:
594
- answer = await inquirer.confirm( # pyright: ignore[reportPrivateImportUsage]
604
+ answer = await inquirer.confirm(
595
605
  message=checkbox.label + ":",
596
606
  default=checkbox.default_value,
597
607
  ).execute_async()
@@ -599,7 +609,7 @@ async def _ask_settings_questions(settings_render: SettingsRender) -> AgentRunSe
599
609
  settings_values[field.id] = CheckboxGroupFieldValue(values=checkbox_values)
600
610
  elif isinstance(field, SettingsSingleSelectField):
601
611
  choices = [Choice(value=opt.value, name=opt.label) for opt in field.options]
602
- answer = await inquirer.fuzzy( # pyright: ignore[reportPrivateImportUsage]
612
+ answer = await inquirer.fuzzy(
603
613
  message=field.label + ":",
604
614
  choices=choices,
605
615
  default=field.default_value,
@@ -675,11 +685,7 @@ async def _run_agent(
675
685
  else {}
676
686
  )
677
687
  | (
678
- {
679
- FormServiceExtensionSpec.URI: {
680
- "form_fulfillments": {"initial_form": typing.cast(FormResponse, input).model_dump(mode="json")}
681
- }
682
- }
688
+ {FormServiceExtensionSpec.URI: {"form_fulfillments": {"initial_form": input.model_dump(mode="json")}}}
683
689
  if isinstance(input, FormResponse)
684
690
  else {}
685
691
  )
@@ -898,7 +904,8 @@ class ShowConfig(InteractiveCommand):
898
904
  schema_table.add_row(
899
905
  prop,
900
906
  json.dumps(required_schema),
901
- json.dumps(generate_schema_example(required_schema)), # pyright: ignore [reportArgumentType]
907
+ # pyrefly: ignore [bad-argument-type] -- probably a bug in Pyrefly
908
+ json.dumps(generate_schema_example(required_schema)),
902
909
  )
903
910
 
904
911
  renderables = [
@@ -1071,7 +1078,7 @@ async def run_agent(
1071
1078
  if not providers:
1072
1079
  err_console.error("No agents found. Add an agent first using 'agentstack agent add'.")
1073
1080
  sys.exit(1)
1074
- search_path = await inquirer.fuzzy( # pyright: ignore[reportPrivateImportUsage]
1081
+ search_path = await inquirer.fuzzy(
1075
1082
  message="Select an agent to run:",
1076
1083
  choices=[provider.agent_card.name for provider in providers],
1077
1084
  ).execute_async()
@@ -62,7 +62,6 @@ async def client_side_build(
62
62
  ):
63
63
  """Build agent locally using Docker. [Local only]"""
64
64
  with verbosity(verbose):
65
- await run_command(["which", "docker"], "Checking docker")
66
65
  image_id = "agentstack-agent-build-tmp:latest"
67
66
  port = await find_free_port()
68
67
  dockerfile_args = ("-f", dockerfile) if dockerfile else ()
@@ -75,7 +75,7 @@ async def select_connectors_multi(
75
75
  # Multiple matches - show selection menu
76
76
  choices = [Choice(value=c, name=f"{c.url} - {c.id} ({c.state})") for c in connector_candidates]
77
77
 
78
- selected_connectors = await inquirer.checkbox( # pyright: ignore[reportPrivateImportUsage]
78
+ selected_connectors = await inquirer.checkbox(
79
79
  message=f"Select connectors to {operation_name} (use ↑/↓ to navigate, Space to select):", choices=choices
80
80
  ).execute_async()
81
81
 
@@ -19,7 +19,6 @@ from agentstack_sdk.platform import (
19
19
  )
20
20
  from InquirerPy import inquirer
21
21
  from InquirerPy.base.control import Choice
22
- from InquirerPy.validator import EmptyInputValidator
23
22
  from rich.table import Column
24
23
 
25
24
  from agentstack_cli.api import openai_client
@@ -184,26 +183,26 @@ async def _add_provider(capability: ModelCapability, use_true_localhost: bool =
184
183
  base_url: str
185
184
  watsonx_project_id, watsonx_space_id = None, None
186
185
  choices = LLM_PROVIDERS if capability == ModelCapability.LLM else EMBEDDING_PROVIDERS
187
- provider_type, provider_name, base_url = await inquirer.fuzzy( # type: ignore
186
+ provider_type, provider_name, base_url = await inquirer.fuzzy(
188
187
  message=f"Select {capability} provider (type to search):", choices=choices
189
- ).execute_async()
188
+ ).execute_async() or sys.exit(1)
190
189
 
191
190
  watsonx_project_or_space: str = ""
192
191
  watsonx_project_or_space_id: str = ""
193
192
 
194
193
  if provider_type == ModelProviderType.OTHER:
195
- base_url: str = await inquirer.text( # type: ignore
194
+ base_url: str = await inquirer.text(
196
195
  message="Enter the base URL of your API (OpenAI-compatible):",
197
- validate=lambda url: (url.startswith(("http://", "https://")) or "URL must start with http:// or https://"), # type: ignore
196
+ validate=lambda url: url.startswith(("http://", "https://")),
198
197
  transformer=lambda url: url.rstrip("/"),
199
- ).execute_async()
198
+ ).execute_async() or sys.exit(1)
200
199
  if re.match(r"^https://[a-z0-9.-]+\.rits\.fmaas\.res\.ibm\.com/.*$", base_url):
201
200
  provider_type = ModelProviderType.RITS
202
201
  if not base_url.endswith("/v1"):
203
202
  base_url = base_url.removesuffix("/") + "/v1"
204
203
 
205
204
  if provider_type == ModelProviderType.WATSONX:
206
- region: str = await inquirer.select( # type: ignore
205
+ region: str = await inquirer.select(
207
206
  message="Select IBM Cloud region:",
208
207
  choices=[
209
208
  Choice(name="us-south", value="us-south"),
@@ -213,34 +212,36 @@ async def _add_provider(capability: ModelCapability, use_true_localhost: bool =
213
212
  Choice(name="jp-tok", value="jp-tok"),
214
213
  Choice(name="au-syd", value="au-syd"),
215
214
  ],
216
- ).execute_async()
215
+ ).execute_async() or sys.exit(1)
217
216
  base_url: str = f"""https://{region}.ml.cloud.ibm.com"""
218
- watsonx_project_or_space: str = await inquirer.select( # type:ignore
217
+ watsonx_project_or_space: str = await inquirer.select(
219
218
  "Use a Project or a Space?", choices=["project", "space"]
220
- ).execute_async()
219
+ ).execute_async() or sys.exit(1)
221
220
  if (
222
221
  not (watsonx_project_or_space_id := os.environ.get(f"WATSONX_{watsonx_project_or_space.upper()}_ID", ""))
223
- or not await inquirer.confirm( # type:ignore
222
+ or not await inquirer.confirm(
224
223
  message=f"Use the {watsonx_project_or_space} id from environment variable 'WATSONX_{watsonx_project_or_space.upper()}_ID'?",
225
224
  default=True,
226
225
  ).execute_async()
227
226
  ):
228
- watsonx_project_or_space_id = await inquirer.text( # type:ignore
227
+ watsonx_project_or_space_id = await inquirer.text(
229
228
  message=f"Enter the {watsonx_project_or_space} id:"
230
- ).execute_async()
229
+ ).execute_async() or sys.exit(1)
231
230
 
232
231
  watsonx_project_id = watsonx_project_or_space_id if watsonx_project_or_space == "project" else None
233
232
  watsonx_space_id = watsonx_project_or_space_id if watsonx_project_or_space == "space" else None
234
233
 
235
- if (api_key := os.environ.get(f"{provider_type.upper()}_API_KEY")) is None or not await inquirer.confirm( # type: ignore
236
- message=f"Use the API key from environment variable '{provider_type.upper()}_API_KEY'?",
237
- default=True,
238
- ).execute_async():
239
- api_key: str = (
240
- "dummy"
241
- if provider_type in {ModelProviderType.OLLAMA, ModelProviderType.JAN}
242
- else await inquirer.secret(message="Enter API key:", validate=EmptyInputValidator()).execute_async() # type: ignore
243
- )
234
+ api_key: str = (
235
+ "dummy"
236
+ if provider_type in {ModelProviderType.OLLAMA, ModelProviderType.JAN}
237
+ else env_api_key
238
+ if (env_api_key := os.environ.get(f"{provider_type.upper()}_API_KEY"))
239
+ and await inquirer.confirm(
240
+ message=f"Use the API key from environment variable '{provider_type.upper()}_API_KEY'?",
241
+ default=True,
242
+ ).execute_async()
243
+ else await inquirer.secret(message="Enter API key:").execute_async() or ""
244
+ )
244
245
 
245
246
  try:
246
247
  if provider_type == ModelProviderType.OLLAMA:
@@ -264,13 +265,13 @@ async def _add_provider(capability: ModelCapability, use_true_localhost: bool =
264
265
  message = f"Do you want to pull the recommended LLM model '{recommended_llm_model}'?"
265
266
  if not available_models:
266
267
  message = f"There are no locally available models in Ollama. {message}"
267
- if await inquirer.confirm(message, default=True).execute_async(): # type: ignore
268
+ if await inquirer.confirm(message, default=True).execute_async():
268
269
  await run_command(
269
270
  [_ollama_exe(), "pull", recommended_llm_model], "Pulling the selected model", check=True
270
271
  )
271
272
 
272
273
  if recommended_embedding_model not in available_models and (
273
- await inquirer.confirm( # type: ignore
274
+ await inquirer.confirm(
274
275
  message=f"Do you want to pull the recommended embedding model '{recommended_embedding_model}'?",
275
276
  default=True,
276
277
  ).execute_async()
@@ -293,8 +294,8 @@ async def _add_provider(capability: ModelCapability, use_true_localhost: bool =
293
294
  )
294
295
 
295
296
  except httpx.HTTPError as e:
296
- if hasattr(e, "response") and hasattr(e.response, "json"): # pyright: ignore [reportAttributeAccessIssue]
297
- err = str(e.response.json().get("detail", str(e))) # pyright: ignore [reportAttributeAccessIssue]
297
+ if hasattr(e, "response") and hasattr(e.response, "json"):
298
+ err = str(e.response.json().get("detail", str(e)))
298
299
  else:
299
300
  err = str(e)
300
301
  match provider_type:
@@ -336,12 +337,12 @@ async def _select_default_model(capability: ModelCapability) -> str | None:
336
337
  selected_model = (
337
338
  recommended_model
338
339
  if recommended_model
339
- and await inquirer.confirm( # type: ignore
340
+ and await inquirer.confirm(
340
341
  message=f"Do you want to use the recommended model as default: '{recommended_model}'?",
341
342
  default=True,
342
343
  ).execute_async()
343
344
  else (
344
- await inquirer.fuzzy( # type: ignore
345
+ await inquirer.fuzzy(
345
346
  message="Select a model to be used as default (type to search):",
346
347
  choices=sorted(available_models),
347
348
  ).execute_async()
@@ -429,7 +430,7 @@ async def setup(
429
430
  console.warning("The following providers are already configured:\n")
430
431
  _list_providers(existing_providers)
431
432
  console.print()
432
- if await inquirer.confirm( # type: ignore
433
+ if await inquirer.confirm(
433
434
  message="Do you want to reset the configuration?", default=True
434
435
  ).execute_async():
435
436
  with console.status("Resetting configuration...", spinner="dots"):
@@ -450,13 +451,13 @@ async def setup(
450
451
  != ModelProviderType.RITS # RITS does not support embeddings, but we treat it as OTHER
451
452
  and (
452
453
  llm_provider.type != ModelProviderType.OTHER # OTHER may not support embeddings, so we ask
453
- or inquirer.confirm( # type: ignore
454
+ or inquirer.confirm(
454
455
  "Do you want to also set up an embedding model from the same provider?", default=True
455
456
  )
456
457
  )
457
458
  ):
458
459
  default_embedding_model = await _select_default_model(ModelCapability.EMBEDDING)
459
- elif await inquirer.confirm( # type: ignore
460
+ elif await inquirer.confirm(
460
461
  message="Do you want to configure an embedding provider? (recommended)", default=True
461
462
  ).execute_async():
462
463
  console.print("[bold]Setting up embedding provider...[/bold]")
@@ -490,7 +491,7 @@ async def select_default_model(
490
491
  url = announce_server_action("Updating default model for")
491
492
  await confirm_server_action("Proceed with updating default model on", url=url, yes=yes)
492
493
  if not capability:
493
- capability = await inquirer.select( # type: ignore
494
+ capability = await inquirer.select(
494
495
  message="Which default model would you like to change?",
495
496
  choices=[
496
497
  Choice(name="llm", value=ModelCapability.LLM),
@@ -518,9 +519,17 @@ app.add_typer(model_provider_app, name="provider")
518
519
 
519
520
 
520
521
  def _list_providers(providers: list[ModelProvider]):
521
- with create_table(Column("Type"), Column("Name"), Column("Base URL", ratio=1)) as provider_table:
522
+ with create_table(Column("Type"), Column("Name"), Column("State"), Column("Base URL", ratio=1)) as provider_table:
522
523
  for provider in providers:
523
- provider_table.add_row(provider.type, provider.name, str(provider.base_url))
524
+ provider_table.add_row(
525
+ provider.type,
526
+ provider.name,
527
+ {
528
+ "online": "[green]● connected[/green]",
529
+ "offline": "[bright_black]○ disconnected[/bright_black]",
530
+ }.get(provider.state, provider.state or "<unknown>"),
531
+ str(provider.base_url),
532
+ )
524
533
  console.print(provider_table)
525
534
 
526
535
 
@@ -543,7 +552,7 @@ async def add_provider(
543
552
  """Add a new model provider. [Admin only]"""
544
553
  announce_server_action("Adding provider for")
545
554
  if not capability:
546
- capability = await inquirer.select( # type: ignore
555
+ capability = await inquirer.select(
547
556
  message="Which default provider would you like to add?",
548
557
  choices=[
549
558
  Choice(name="llm", value=ModelCapability.LLM),
@@ -598,13 +607,15 @@ async def remove_provider(
598
607
  async with configuration.use_platform_client():
599
608
  providers = await ModelProvider.list()
600
609
 
601
- if not search_path:
602
- provider: ModelProvider = await inquirer.select( # type: ignore
610
+ provider: ModelProvider = (
611
+ _select_provider(providers, search_path)
612
+ if search_path
613
+ else await inquirer.select(
603
614
  message="Choose a provider to remove:",
604
615
  choices=[Choice(name=f"{p.type} ({p.base_url})", value=p) for p in providers],
605
616
  ).execute_async()
606
- else:
607
- provider = _select_provider(providers, search_path)
617
+ or sys.exit(1)
618
+ )
608
619
 
609
620
  await provider.delete()
610
621
 
@@ -7,6 +7,7 @@ import json
7
7
  import pathlib
8
8
  import shlex
9
9
  import typing
10
+ import uuid
10
11
  from enum import StrEnum
11
12
  from subprocess import CompletedProcess
12
13
  from textwrap import dedent
@@ -55,13 +56,87 @@ class BaseDriver(abc.ABC):
55
56
  async def delete(self) -> None: ...
56
57
 
57
58
  @abc.abstractmethod
58
- async def import_images(self, *tags: str) -> None: ...
59
+ async def exec(self, command: list[str]) -> None: ...
59
60
 
60
61
  @abc.abstractmethod
61
- async def import_image_to_internal_registry(self, tag: str) -> None: ...
62
+ def _get_export_import_paths(self) -> tuple[str, str]: ...
62
63
 
63
- @abc.abstractmethod
64
- async def exec(self, command: list[str]) -> None: ...
64
+ async def import_images(self, *tags: str) -> None:
65
+ if not tags:
66
+ return
67
+
68
+ host_path, guest_path = self._get_export_import_paths()
69
+
70
+ try:
71
+ await run_command(
72
+ ["docker", "image", "save", "-o", host_path, *tags],
73
+ f"Exporting image{'' if len(tags) == 1 else 's'} {', '.join(tags)} from Docker",
74
+ )
75
+ await self.run_in_vm(
76
+ ["/bin/sh", "-c", f"k3s ctr images import {guest_path}"],
77
+ f"Importing image{'' if len(tags) == 1 else 's'} {', '.join(tags)} into Agent Stack platform",
78
+ )
79
+ finally:
80
+ await anyio.Path(host_path).unlink(missing_ok=True)
81
+
82
+ async def import_image_to_internal_registry(self, tag: str) -> None:
83
+ host_path, guest_path = self._get_export_import_paths()
84
+
85
+ try:
86
+ await run_command(
87
+ ["docker", "image", "save", "-o", str(host_path), tag],
88
+ f"Exporting image {tag} from Docker",
89
+ )
90
+ job_name = f"push-{uuid.uuid4().hex[:6]}"
91
+ await self.run_in_vm(
92
+ ["k3s", "kubectl", "apply", "-f", "-"],
93
+ "Starting push job",
94
+ input=yaml.dump(
95
+ {
96
+ "apiVersion": "batch/v1",
97
+ "kind": "Job",
98
+ "metadata": {"name": job_name, "namespace": "default"},
99
+ "spec": {
100
+ "backoffLimit": 0,
101
+ "ttlSecondsAfterFinished": 60,
102
+ "template": {
103
+ "spec": {
104
+ "restartPolicy": "Never",
105
+ "containers": [
106
+ {
107
+ "name": "crane",
108
+ "image": next(
109
+ (image for image in self.loaded_images if "alpine/crane" in image),
110
+ "ghcr.io/i-am-bee/alpine/crane:0.20.6",
111
+ ),
112
+ "command": [
113
+ "crane",
114
+ "push",
115
+ f"/workspace/{pathlib.Path(host_path).name}",
116
+ tag,
117
+ "--insecure",
118
+ ],
119
+ "volumeMounts": [{"name": "workspace", "mountPath": "/workspace"}],
120
+ }
121
+ ],
122
+ "volumes": [
123
+ {
124
+ "name": "workspace",
125
+ "hostPath": {"path": str(pathlib.PurePosixPath(guest_path).parent)},
126
+ }
127
+ ],
128
+ }
129
+ },
130
+ },
131
+ }
132
+ ).encode(),
133
+ )
134
+ await self.run_in_vm(
135
+ ["k3s", "kubectl", "wait", "--for=condition=complete", f"job/{job_name}", "--timeout=300s"],
136
+ "Waiting for push to complete",
137
+ )
138
+ finally:
139
+ await anyio.Path(host_path).unlink(missing_ok=True)
65
140
 
66
141
  def _canonify(self, tag: str) -> str:
67
142
  return tag if "." in tag.split("/")[0] else f"docker.io/{tag}"
@@ -92,7 +167,6 @@ class BaseDriver(abc.ABC):
92
167
  }
93
168
 
94
169
  async def install_tools(self) -> None:
95
- # Configure k3s registry for local registry access
96
170
  registry_config = dedent(
97
171
  """\
98
172
  mirrors:
@@ -116,7 +190,7 @@ class BaseDriver(abc.ABC):
116
190
  "sudo tee /etc/rancher/k3s/registries.yaml > /dev/null"
117
191
  ),
118
192
  ],
119
- "Configuring k3s registry",
193
+ "Configuring Kubernetes registry",
120
194
  )
121
195
 
122
196
  await self.run_in_vm(
@@ -125,7 +199,7 @@ class BaseDriver(abc.ABC):
125
199
  "-c",
126
200
  "which k3s || curl -sfL https://get.k3s.io | sh -s - --write-kubeconfig-mode 644 --https-listen-port=16443",
127
201
  ],
128
- "Installing k3s",
202
+ "Installing Kubernetes",
129
203
  )
130
204
  await self.run_in_vm(
131
205
  [
@@ -3,12 +3,14 @@
3
3
 
4
4
  import importlib.resources
5
5
  import os
6
+ import pathlib
6
7
  import shutil
7
8
  import sys
8
9
  import tempfile
9
10
  import typing
10
11
  import uuid
11
12
  from subprocess import CompletedProcess
13
+ from typing import TypedDict
12
14
 
13
15
  import anyio
14
16
  import psutil
@@ -62,9 +64,12 @@ class LimaDriver(BaseDriver):
62
64
  for line in result.stdout.decode().split("\n"):
63
65
  if not line:
64
66
  continue
65
- status = pydantic.TypeAdapter(typing.TypedDict("Status", {"name": str, "status": str})).validate_json(
66
- line
67
- )
67
+
68
+ class Status(TypedDict):
69
+ name: str
70
+ status: str
71
+
72
+ status = pydantic.TypeAdapter(Status).validate_json(line)
68
73
  if status["name"] == self.vm_name:
69
74
  return status["status"].lower()
70
75
  return None
@@ -102,7 +107,7 @@ class LimaDriver(BaseDriver):
102
107
  if total_memory_gib < 8:
103
108
  console.warning("Less than 8 GB of RAM detected. Performance may be degraded.")
104
109
 
105
- vm_memory_gib = round(min(8, max(3, total_memory_gib / 2)))
110
+ vm_memory_gib = round(min(8.0, max(3.0, total_memory_gib / 2)))
106
111
 
107
112
  with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete_on_close=False) as template_file:
108
113
  template_file.write(
@@ -180,100 +185,20 @@ class LimaDriver(BaseDriver):
180
185
  )
181
186
 
182
187
  @typing.override
183
- async def import_images(self, *tags: str):
184
- if not tags:
185
- return
186
- image_dir = anyio.Path("/tmp/agentstack")
187
- await image_dir.mkdir(exist_ok=True, parents=True)
188
- image_file = str(uuid.uuid4())
189
- image_path = image_dir / image_file
190
-
191
- try:
192
- await run_command(
193
- ["docker", "image", "save", "-o", str(image_path), *tags],
194
- f"Exporting image{'' if len(tags) == 1 else 's'} {', '.join(tags)} from Docker",
195
- )
196
- await self.run_in_vm(
197
- ["/bin/sh", "-c", f"k3s ctr images import /tmp/agentstack/{image_file}"],
198
- f"Importing image{'' if len(tags) == 1 else 's'} {', '.join(tags)} into Agent Stack platform",
199
- )
200
- finally:
201
- await image_path.unlink(missing_ok=True)
202
-
203
- @typing.override
204
- async def import_image_to_internal_registry(self, tag: str) -> None:
205
- # 1. Check if registry is running
206
- try:
207
- await self.run_in_vm(
208
- ["k3s", "kubectl", "get", "svc", "agentstack-registry-svc"],
209
- "Checking internal registry availability",
210
- )
211
- except Exception as e:
212
- console.warning(f"Internal registry service not found. Push might fail: {e}")
213
-
214
- # 2. Export image from Docker to shared temp dir
215
- image_dir = anyio.Path("/tmp/agentstack")
216
- await image_dir.mkdir(exist_ok=True, parents=True)
217
- image_file = f"{uuid.uuid4()}.tar"
218
- image_path = image_dir / image_file
219
-
220
- try:
221
- await run_command(
222
- ["docker", "image", "save", "-o", str(image_path), tag],
223
- f"Exporting image {tag} from Docker",
224
- )
225
-
226
- # 3 & 4. Run Crane Job
227
- crane_image = "ghcr.io/i-am-bee/alpine/crane:0.20.6"
228
- for image in self.loaded_images:
229
- if "alpine/crane" in image:
230
- crane_image = image
231
- break
232
-
233
- job_name = f"push-{uuid.uuid4().hex[:6]}"
234
- job_def = {
235
- "apiVersion": "batch/v1",
236
- "kind": "Job",
237
- "metadata": {"name": job_name, "namespace": "default"},
238
- "spec": {
239
- "backoffLimit": 0,
240
- "ttlSecondsAfterFinished": 60,
241
- "template": {
242
- "spec": {
243
- "restartPolicy": "Never",
244
- "containers": [
245
- {
246
- "name": "crane",
247
- "image": crane_image,
248
- "command": ["crane", "push", f"/workspace/{image_file}", tag, "--insecure"],
249
- "volumeMounts": [{"name": "workspace", "mountPath": "/workspace"}],
250
- }
251
- ],
252
- "volumes": [{"name": "workspace", "hostPath": {"path": "/tmp/agentstack"}}],
253
- }
254
- },
255
- },
256
- }
257
-
258
- await self.run_in_vm(
259
- ["k3s", "kubectl", "apply", "-f", "-"], "Starting push job", input=yaml.dump(job_def).encode()
260
- )
261
- await self.run_in_vm(
262
- ["k3s", "kubectl", "wait", "--for=condition=complete", f"job/{job_name}", "--timeout=300s"],
263
- "Waiting for push to complete",
264
- )
265
- await self.run_in_vm(["k3s", "kubectl", "delete", "job", job_name], "Cleaning up push job")
266
- finally:
267
- await image_path.unlink(missing_ok=True)
188
+ def _get_export_import_paths(self) -> tuple[str, str]:
189
+ image_dir = pathlib.Path("/tmp/agentstack")
190
+ image_dir.mkdir(exist_ok=True, parents=True)
191
+ image_path = str(image_dir / f"{uuid.uuid4()}.tar")
192
+ return (image_path, image_path)
268
193
 
269
194
  @typing.override
270
195
  async def exec(self, command: list[str]):
271
196
  await anyio.run_process(
272
197
  [self.limactl_exe, "shell", f"--tty={sys.stdin.isatty()}", self.vm_name, "--", *command],
273
- input=None if sys.stdin.isatty() else sys.stdin.read().encode(),
274
198
  check=False,
275
- stdout=None,
276
- stderr=None,
199
+ stdin=sys.stdin,
200
+ stdout=sys.stdout,
201
+ stderr=sys.stderr,
277
202
  env={**os.environ, "LIMA_HOME": str(Configuration().lima_home)},
278
203
  cwd="/",
279
204
  )
@@ -6,6 +6,7 @@ import os
6
6
  import pathlib
7
7
  import platform
8
8
  import sys
9
+ import tempfile
9
10
  import textwrap
10
11
  import typing
11
12
 
@@ -132,9 +133,6 @@ class WSLDriver(BaseDriver):
132
133
  values_file: pathlib.Path | None = None,
133
134
  image_pull_mode: ImagePullMode = ImagePullMode.guest,
134
135
  ) -> None:
135
- if image_pull_mode in {ImagePullMode.host, ImagePullMode.hybrid}:
136
- raise NotImplementedError("Importing host images is not supported on Windows.")
137
-
138
136
  host_ip = (
139
137
  (
140
138
  await self.run_in_vm(
@@ -170,7 +168,7 @@ class WSLDriver(BaseDriver):
170
168
 
171
169
  [Service]
172
170
  Type=simple
173
- ExecStart=/bin/bash -c 'IFS=":" read svc port <<< "%i"; exec /usr/local/bin/kubectl port-forward --address=127.0.0.1 svc/$svc $port:$port'
171
+ ExecStart=/bin/bash -c 'IFS=":" read svc port <<< "%i"; exec /usr/local/bin/k3s kubectl port-forward --kubeconfig=/etc/rancher/k3s/k3s.yaml --address=127.0.0.1 svc/$svc $port:$port'
174
172
  Restart=on-failure
175
173
  User=root
176
174
 
@@ -207,20 +205,21 @@ class WSLDriver(BaseDriver):
207
205
  async def delete(self):
208
206
  await run_command(["wsl.exe", "--unregister", self.vm_name], "Deleting Agent Stack platform", check=False)
209
207
 
210
- @typing.override
211
- async def import_images(self, *tags: str) -> None:
212
- raise NotImplementedError("Importing images is not supported on this platform.")
213
-
214
- @typing.override
215
- async def import_image_to_internal_registry(self, tag: str) -> None:
216
- raise NotImplementedError("Importing images to internal registry is not supported on this platform.")
217
-
218
208
  @typing.override
219
209
  async def exec(self, command: list[str]):
220
210
  await anyio.run_process(
221
211
  ["wsl.exe", "--user", "root", "--distribution", self.vm_name, "--", *command],
222
- input=None if sys.stdin.isatty() else sys.stdin.read().encode(),
223
212
  check=False,
224
- stdout=None,
225
- stderr=None,
213
+ stdin=sys.stdin,
214
+ stdout=sys.stdout,
215
+ stderr=sys.stderr,
216
+ cwd="/",
226
217
  )
218
+
219
+ @typing.override
220
+ def _get_export_import_paths(self) -> tuple[str, str]:
221
+ fd, tmp_path = tempfile.mkstemp(suffix=".tar")
222
+ os.close(fd)
223
+ windows_path = str(pathlib.Path(tmp_path).resolve().absolute())
224
+ wsl_path = f"/mnt/{windows_path[0].lower()}/{windows_path[2:].replace('\\', '/').removeprefix('/')}"
225
+ return (windows_path, wsl_path)
@@ -31,9 +31,9 @@ def _path() -> str:
31
31
  # These are PATHs where `uv` installs itself when installed through own install script
32
32
  # Package managers may install elsewhere, but that location should already be in PATH
33
33
  return os.pathsep.join(
34
- [
35
- *([xdg_bin_home] if (xdg_bin_home := os.getenv("XDG_BIN_HOME")) else []),
36
- *([os.path.realpath(f"{xdg_data_home}/../bin")] if (xdg_data_home := os.getenv("XDG_DATA_HOME")) else []),
34
+ ([xdg_bin_home] if (xdg_bin_home := os.getenv("XDG_BIN_HOME")) else [])
35
+ + ([os.path.realpath(f"{xdg_data_home}/../bin")] if (xdg_data_home := os.getenv("XDG_DATA_HOME")) else [])
36
+ + [
37
37
  os.path.expanduser("~/.local/bin"),
38
38
  os.getenv("PATH", ""),
39
39
  ]
@@ -124,7 +124,7 @@ async def install(
124
124
  console.print()
125
125
  if (
126
126
  ready_to_start
127
- and await inquirer.confirm( # pyright: ignore[reportPrivateImportUsage]
127
+ and await inquirer.confirm(
128
128
  message="Do you want to start the Agent Stack platform now? Will run: agentstack platform start",
129
129
  default=True,
130
130
  ).execute_async()
@@ -139,7 +139,7 @@ async def install(
139
139
  already_configured = False
140
140
  if (
141
141
  already_started
142
- and await inquirer.confirm( # pyright: ignore[reportPrivateImportUsage]
142
+ and await inquirer.confirm(
143
143
  message="Do you want to configure your LLM provider now? Will run: agentstack model setup", default=True
144
144
  ).execute_async()
145
145
  ):
@@ -151,7 +151,7 @@ async def install(
151
151
 
152
152
  if (
153
153
  already_configured
154
- and await inquirer.confirm( # pyright: ignore[reportPrivateImportUsage]
154
+ and await inquirer.confirm(
155
155
  message="Do you want to open the web UI now? Will run: agentstack ui", default=True
156
156
  ).execute_async()
157
157
  ):
@@ -78,7 +78,7 @@ def get_unique_app_name() -> str:
78
78
  async def server_login(server: typing.Annotated[str | None, typer.Argument()] = None):
79
79
  """Login to a server or switch between logged in servers."""
80
80
  server = server or (
81
- await inquirer.select( # type: ignore
81
+ await inquirer.select(
82
82
  message="Select a server, or log in to a new one:",
83
83
  choices=[
84
84
  *(
@@ -95,7 +95,7 @@ async def server_login(server: typing.Annotated[str | None, typer.Argument()] =
95
95
  if config.auth_manager.servers
96
96
  else None
97
97
  )
98
- server = server or await inquirer.text(message="Enter server URL:").execute_async() # type: ignore
98
+ server = server or await inquirer.text(message="Enter server URL:").execute_async()
99
99
 
100
100
  if not server:
101
101
  raise RuntimeError("No server selected. Action cancelled.")
@@ -120,7 +120,7 @@ async def server_login(server: typing.Annotated[str | None, typer.Argument()] =
120
120
  elif len(auth_servers) == 1:
121
121
  auth_server = auth_servers[0]
122
122
  elif len(auth_servers) > 1:
123
- auth_server = await inquirer.select( # type: ignore
123
+ auth_server = await inquirer.select(
124
124
  message="Select an authorization server:",
125
125
  choices=[
126
126
  Choice(
@@ -199,13 +199,10 @@ async def server_login(server: typing.Annotated[str | None, typer.Argument()] =
199
199
  if len(auth_servers) == 1:
200
200
  auth_server = auth_servers[0]
201
201
  else:
202
- auth_server = await inquirer.select( # type: ignore
202
+ auth_server = await inquirer.select(
203
203
  message="Select an authorization server:",
204
204
  choices=auth_servers,
205
- ).execute_async()
206
-
207
- if not auth_server:
208
- raise RuntimeError("No authorization server selected.")
205
+ ).execute_async() or sys.exit(1)
209
206
 
210
207
  async with httpx.AsyncClient() as client:
211
208
  try:
@@ -250,7 +247,7 @@ async def server_login(server: typing.Annotated[str | None, typer.Argument()] =
250
247
 
251
248
  if not client_id:
252
249
  client_id = (
253
- await inquirer.text( # type: ignore
250
+ await inquirer.text(
254
251
  message="Enter Client ID:",
255
252
  instruction=f"(Redirect URI: {REDIRECT_URI})",
256
253
  ).execute_async()
@@ -258,12 +255,7 @@ async def server_login(server: typing.Annotated[str | None, typer.Argument()] =
258
255
  )
259
256
  if not client_id:
260
257
  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
- )
258
+ client_secret = await inquirer.secret(message="Enter Client Secret (optional):").execute_async() or None
267
259
 
268
260
  code_verifier = generate_token(64)
269
261
 
@@ -87,7 +87,7 @@ class Configuration(pydantic_settings.BaseSettings):
87
87
  async with use_platform_client(
88
88
  auth=(self.username, self.password.get_secret_value()) if self.password else None,
89
89
  auth_token=auth_token,
90
- base_url=self.auth_manager.active_server + "/",
90
+ base_url=(self.auth_manager.active_server or "") + "/",
91
91
  ) as client:
92
92
  yield client
93
93
 
@@ -32,9 +32,7 @@ async def confirm_server_action(message: str, url: str | None = None, *, yes: bo
32
32
  if yes:
33
33
  return
34
34
  url = url or require_active_server()
35
- confirmed = await inquirer.confirm( # type: ignore
36
- message=f"{message} {url}?", default=False
37
- ).execute_async()
35
+ confirmed = await inquirer.confirm(message=f"{message} {url}?", default=False).execute_async()
38
36
  if not confirmed:
39
37
  console.info("Action cancelled.")
40
38
  sys.exit(1)
@@ -288,10 +288,8 @@ def print_log(line, ansi_mode=False, out_console: Console | None = None):
288
288
  (out_console or console).print(line)
289
289
 
290
290
 
291
- def is_github_url(url: str) -> bool:
292
- """This pattern is taken from agentstack_server.utils.github.GithubUrl, make sure to keep it in sync"""
293
-
294
- pattern = r"""
291
+ # ! This pattern is taken from agentstack_server.utils.github.GithubUrl, make sure to keep it in sync
292
+ github_url_verbose_pattern = r"""
295
293
  ^
296
294
  (?:git\+)? # Optional git+ prefix
297
295
  https?://(?P<host>github(?:\.[^/]+)+)/ # GitHub host (github.com or github.enterprise.com)
@@ -307,7 +305,76 @@ def is_github_url(url: str) -> bool:
307
305
  (?:\#path=(?P<path>.+))? # Optional path after #path=
308
306
  $
309
307
  """
310
- return bool(re.match(pattern, url, re.VERBOSE))
308
+
309
+
310
+ def is_github_url(url: str) -> bool:
311
+ return bool(re.match(github_url_verbose_pattern, url, re.VERBOSE))
312
+
313
+
314
+ def get_local_github_token() -> str | None:
315
+ """Get GitHub token from standard local sources for authenticated API requests.
316
+
317
+ Checks multiple sources in order of preference:
318
+ 1. GITHUB_TOKEN environment variable
319
+ 2. GH_TOKEN environment variable (used by GitHub CLI)
320
+ 3. GitHub CLI (gh auth token) if user is logged in
321
+
322
+ Authenticated requests have much higher rate limits (5000/hour vs 60/hour).
323
+ """
324
+ # Check environment variables first
325
+ if token := os.getenv("GITHUB_TOKEN"):
326
+ return token
327
+ if token := os.getenv("GH_TOKEN"):
328
+ return token
329
+
330
+ # Try GitHub CLI if available
331
+ try:
332
+ result = subprocess.run(
333
+ ["gh", "auth", "token"],
334
+ capture_output=True,
335
+ text=True,
336
+ timeout=5,
337
+ )
338
+ if result.returncode == 0 and (token := result.stdout.strip()):
339
+ return token
340
+ except (FileNotFoundError, subprocess.TimeoutExpired):
341
+ pass
342
+
343
+ return None
344
+
345
+
346
+ async def get_github_repo_tags(host: str, owner: str, repo: str) -> list[str]:
347
+ headers = {"Accept": "application/vnd.github.v3+json"}
348
+
349
+ # Add authentication if GITHUB_TOKEN is available (avoids rate limiting)
350
+ if token := get_local_github_token():
351
+ headers["Authorization"] = f"token {token}"
352
+
353
+ # Determine API host
354
+ if host == "github.com":
355
+ api_host = "api.github.com"
356
+ if not get_local_github_token():
357
+ console.info(
358
+ "No GitHub token found. Using unauthenticated API (60 requests/hour limit).\n"
359
+ "For higher limits, run [green]gh auth login[/green] or set GITHUB_TOKEN or GH_TOKEN env variable."
360
+ )
361
+ else:
362
+ # GitHub Enterprise uses host/api/v3
363
+ api_host = f"{host}/api/v3"
364
+
365
+ async with httpx.AsyncClient() as client:
366
+ # Correct format: /repos/{owner}/{repo}/tags
367
+ url = f"https://{api_host}/repos/{owner}/{repo}/tags"
368
+ response = await client.get(url, headers=headers)
369
+
370
+ tags = [tag["name"] for tag in response.json()] if response.status_code == 200 else []
371
+
372
+ if not tags and response.status_code != 200:
373
+ console.warning(f"Failed to fetch tags from '{url}' (status code {response.status_code}). ")
374
+ elif not tags and response.status_code <= 200:
375
+ console.warning(f"No tags fetched from '{url}' (status code {response.status_code}). ")
376
+
377
+ return tags
311
378
 
312
379
 
313
380
  def get_httpx_response_error_details(response: httpx.Response | None) -> tuple[str, str] | None: