agentstack-cli 0.5.0rc5__tar.gz → 0.5.1rc3__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.5.0rc5 → agentstack_cli-0.5.1rc3}/PKG-INFO +1 -1
  2. {agentstack_cli-0.5.0rc5 → agentstack_cli-0.5.1rc3}/pyproject.toml +1 -2
  3. {agentstack_cli-0.5.0rc5 → agentstack_cli-0.5.1rc3}/src/agentstack_cli/__init__.py +15 -5
  4. {agentstack_cli-0.5.0rc5 → agentstack_cli-0.5.1rc3}/src/agentstack_cli/api.py +3 -6
  5. {agentstack_cli-0.5.0rc5 → agentstack_cli-0.5.1rc3}/src/agentstack_cli/commands/agent.py +162 -19
  6. {agentstack_cli-0.5.0rc5 → agentstack_cli-0.5.1rc3}/src/agentstack_cli/commands/build.py +13 -2
  7. {agentstack_cli-0.5.0rc5 → agentstack_cli-0.5.1rc3}/src/agentstack_cli/commands/model.py +1 -1
  8. {agentstack_cli-0.5.0rc5 → agentstack_cli-0.5.1rc3}/src/agentstack_cli/commands/platform/base_driver.py +8 -7
  9. {agentstack_cli-0.5.0rc5 → agentstack_cli-0.5.1rc3}/src/agentstack_cli/commands/platform/lima_driver.py +66 -0
  10. {agentstack_cli-0.5.0rc5 → agentstack_cli-0.5.1rc3}/src/agentstack_cli/commands/platform/wsl_driver.py +18 -26
  11. agentstack_cli-0.5.1rc3/src/agentstack_cli/data/helm-chart.tgz +0 -0
  12. {agentstack_cli-0.5.0rc5 → agentstack_cli-0.5.1rc3}/src/agentstack_cli/utils.py +9 -4
  13. agentstack_cli-0.5.0rc5/src/agentstack_cli/data/helm-chart.tgz +0 -0
  14. {agentstack_cli-0.5.0rc5 → agentstack_cli-0.5.1rc3}/README.md +0 -0
  15. {agentstack_cli-0.5.0rc5 → agentstack_cli-0.5.1rc3}/src/agentstack_cli/async_typer.py +0 -0
  16. {agentstack_cli-0.5.0rc5 → agentstack_cli-0.5.1rc3}/src/agentstack_cli/auth_manager.py +0 -0
  17. {agentstack_cli-0.5.0rc5 → agentstack_cli-0.5.1rc3}/src/agentstack_cli/commands/__init__.py +0 -0
  18. {agentstack_cli-0.5.0rc5 → agentstack_cli-0.5.1rc3}/src/agentstack_cli/commands/mcp.py +0 -0
  19. {agentstack_cli-0.5.0rc5 → agentstack_cli-0.5.1rc3}/src/agentstack_cli/commands/platform/__init__.py +0 -0
  20. {agentstack_cli-0.5.0rc5 → agentstack_cli-0.5.1rc3}/src/agentstack_cli/commands/platform/istio.py +0 -0
  21. {agentstack_cli-0.5.0rc5 → agentstack_cli-0.5.1rc3}/src/agentstack_cli/commands/self.py +0 -0
  22. {agentstack_cli-0.5.0rc5 → agentstack_cli-0.5.1rc3}/src/agentstack_cli/commands/server.py +0 -0
  23. {agentstack_cli-0.5.0rc5 → agentstack_cli-0.5.1rc3}/src/agentstack_cli/commands/user.py +0 -0
  24. {agentstack_cli-0.5.0rc5 → agentstack_cli-0.5.1rc3}/src/agentstack_cli/configuration.py +0 -0
  25. {agentstack_cli-0.5.0rc5 → agentstack_cli-0.5.1rc3}/src/agentstack_cli/console.py +0 -0
  26. {agentstack_cli-0.5.0rc5 → agentstack_cli-0.5.1rc3}/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.5.0rc5
3
+ Version: 0.5.1rc3
4
4
  Summary: Agent Stack CLI
5
5
  Author: IBM Corp.
6
6
  Requires-Dist: anyio~=4.10.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentstack-cli"
3
- version = "0.5.0-rc5"
3
+ version = "0.5.1-rc3"
4
4
  description = "Agent Stack CLI"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "IBM Corp." }]
@@ -75,7 +75,6 @@ lint.ignore = [
75
75
  force-exclude = true
76
76
 
77
77
  [tool.pyright]
78
- reportUnusedCallResult = false
79
78
  ignore = ["tests/**", "examples/cli.py"]
80
79
  venvPath = "."
81
80
  venv = ".venv"
@@ -16,16 +16,14 @@ import agentstack_cli.commands.platform
16
16
  import agentstack_cli.commands.self
17
17
  import agentstack_cli.commands.server
18
18
  import agentstack_cli.commands.user
19
- from agentstack_cli.async_typer import AliasGroup, AsyncTyper
19
+ from agentstack_cli.async_typer import AsyncTyper
20
20
  from agentstack_cli.configuration import Configuration
21
21
 
22
22
  logging.basicConfig(level=logging.INFO if Configuration().debug else logging.FATAL)
23
23
  logging.getLogger("httpx").setLevel(logging.WARNING) # not sure why this is necessary
24
24
 
25
25
 
26
- class RootHelpGroup(AliasGroup):
27
- def get_help(self, ctx):
28
- return """\
26
+ HELP_TEXT = """\
29
27
  Usage: agentstack [OPTIONS] COMMAND [ARGS]...
30
28
 
31
29
  ╭─ Getting Started ──────────────────────────────────────────────────────────╮
@@ -63,7 +61,19 @@ Usage: agentstack [OPTIONS] COMMAND [ARGS]...
63
61
  """
64
62
 
65
63
 
66
- app = AsyncTyper(no_args_is_help=True, cls=RootHelpGroup)
64
+ app = AsyncTyper()
65
+
66
+
67
+ @app.callback(invoke_without_command=True)
68
+ def main(
69
+ ctx: typer.Context,
70
+ help: bool = typer.Option(False, "--help", help="Show this message and exit."),
71
+ ):
72
+ if help or ctx.invoked_subcommand is None:
73
+ typer.echo(HELP_TEXT)
74
+ raise typer.Exit()
75
+
76
+
67
77
  app.add_typer(agentstack_cli.commands.model.app, name="model", no_args_is_help=True, help="Manage model providers.")
68
78
  app.add_typer(agentstack_cli.commands.agent.app, name="agent", no_args_is_help=True, help="Manage agents.")
69
79
  app.add_typer(
@@ -16,6 +16,7 @@ import openai
16
16
  import pydantic
17
17
  from a2a.client import A2AClientHTTPError, Client, ClientConfig, ClientFactory
18
18
  from a2a.types import AgentCard
19
+ from agentstack_sdk.platform.context import ContextToken
19
20
  from httpx import HTTPStatusError
20
21
  from httpx._types import RequestFiles
21
22
 
@@ -127,14 +128,10 @@ async def fetch_server_version() -> str | None:
127
128
 
128
129
 
129
130
  @asynccontextmanager
130
- async def a2a_client(agent_card: AgentCard, use_auth: bool = True) -> AsyncIterator[Client]:
131
+ async def a2a_client(agent_card: AgentCard, context_token: ContextToken) -> AsyncIterator[Client]:
131
132
  try:
132
133
  async with httpx.AsyncClient(
133
- headers=(
134
- {"Authorization": f"Bearer {token}"}
135
- if use_auth and (token := await config.auth_manager.load_auth_token())
136
- else {}
137
- ),
134
+ headers={"Authorization": f"Bearer {context_token.token.get_secret_value()}"},
138
135
  follow_redirects=True,
139
136
  timeout=timedelta(hours=1).total_seconds(),
140
137
  ) as httpx_client:
@@ -2,6 +2,7 @@
2
2
  # SPDX-License-Identifier: Apache-2.0
3
3
 
4
4
  import abc
5
+ import asyncio
5
6
  import base64
6
7
  import calendar
7
8
  import inspect
@@ -61,6 +62,21 @@ from agentstack_sdk.a2a.extensions.common.form import (
61
62
  TextField,
62
63
  TextFieldValue,
63
64
  )
65
+ from agentstack_sdk.a2a.extensions.ui.settings import (
66
+ AgentRunSettings,
67
+ CheckboxGroupField,
68
+ CheckboxGroupFieldValue,
69
+ SettingsExtensionSpec,
70
+ SettingsFieldValue,
71
+ SettingsRender,
72
+ )
73
+ from agentstack_sdk.a2a.extensions.ui.settings import (
74
+ CheckboxFieldValue as SettingsCheckboxFieldValue,
75
+ )
76
+ from agentstack_sdk.a2a.extensions.ui.settings import SingleSelectField as SettingsSingleSelectField
77
+ from agentstack_sdk.a2a.extensions.ui.settings import (
78
+ SingleSelectFieldValue as SettingsSingleSelectFieldValue,
79
+ )
64
80
  from agentstack_sdk.platform import BuildState, File, ModelProvider, Provider, UserFeedback
65
81
  from agentstack_sdk.platform.context import Context, ContextPermissions, ContextToken, Permissions
66
82
  from agentstack_sdk.platform.model_provider import ModelCapability
@@ -159,7 +175,22 @@ async def add_agent(
159
175
  verbose: typing.Annotated[bool, typer.Option("-v", "--verbose", help="Show verbose output")] = False,
160
176
  yes: typing.Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompts.")] = False,
161
177
  ) -> None:
162
- """Add a docker image or GitHub repository [aliases: install]"""
178
+ """Add a docker image or GitHub repository.
179
+
180
+ This command supports a variety of GitHub URL formats for deploying agents:
181
+
182
+ - **Basic URL**: `https://github.com/myorg/myrepo`
183
+ - **Git Protocol URL**: `git+https://github.com/myorg/myrepo`
184
+ - **URL with .git suffix**: `https://github.com/myorg/myrepo.git`
185
+ - **URL with Version Tag**: `https://github.com/myorg/myrepo@v1.0.0`
186
+ - **URL with Branch Name**: `https://github.com/myorg/myrepo@my-branch`
187
+ - **URL with Subfolder Path**: `https://github.com/myorg/myrepo#path=/path/to/agent`
188
+ - **Combined Formats**: `https://github.com/myorg/myrepo.git@v1.0.0#path=/path/to/agent`
189
+ - **Enterprise GitHub**: `https://github.mycompany.com/myorg/myrepo`
190
+ - **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"`
191
+
192
+ [aliases: install]
193
+ """
163
194
  if location is None:
164
195
  repo_input = (
165
196
  await inquirer.text( # pyright: ignore[reportPrivateImportUsage]
@@ -308,11 +339,21 @@ async def update_agent(
308
339
  await list_agents()
309
340
 
310
341
 
311
- def select_provider(search_path: str, providers: list[Provider]):
342
+ def search_path_match_providers(search_path: str, providers: list[Provider]) -> dict[str, Provider]:
312
343
  search_path = search_path.lower()
313
- provider_candidates = {p.id: p for p in providers if search_path in p.id.lower()}
314
- provider_candidates.update({p.id: p for p in providers if search_path in p.agent_card.name.lower()})
315
- provider_candidates.update({p.id: p for p in providers if search_path in ProviderUtils.short_location(p)})
344
+ return {
345
+ p.id: p
346
+ for p in providers
347
+ if (
348
+ search_path in p.id.lower()
349
+ or search_path in p.agent_card.name.lower()
350
+ or search_path in ProviderUtils.short_location(p)
351
+ )
352
+ }
353
+
354
+
355
+ def select_provider(search_path: str, providers: list[Provider]):
356
+ provider_candidates = search_path_match_providers(search_path, providers)
316
357
  if len(provider_candidates) != 1:
317
358
  provider_candidates = [f" - {c}" for c in provider_candidates]
318
359
  remove_providers_detail = ":\n" + "\n".join(provider_candidates) if provider_candidates else ""
@@ -321,20 +362,75 @@ def select_provider(search_path: str, providers: list[Provider]):
321
362
  return selected_provider
322
363
 
323
364
 
365
+ async def select_providers_multi(search_path: str, providers: list[Provider]) -> list[Provider]:
366
+ """Select multiple providers matching the search path."""
367
+ provider_candidates = search_path_match_providers(search_path, providers)
368
+ if not provider_candidates:
369
+ raise ValueError(f"No matching agents found for '{search_path}'")
370
+
371
+ if len(provider_candidates) == 1:
372
+ return list(provider_candidates.values())
373
+
374
+ # Multiple matches - show selection menu
375
+ choices = [Choice(value=p.id, name=f"{p.agent_card.name} - {p.id}") for p in provider_candidates.values()]
376
+
377
+ selected_ids = await inquirer.checkbox( # pyright: ignore[reportPrivateImportUsage]
378
+ message="Select agents to remove (use ↑/↓ to navigate, Space to select):", choices=choices
379
+ ).execute_async()
380
+
381
+ return [provider_candidates[pid] for pid in (selected_ids or [])]
382
+
383
+
324
384
  @app.command("remove | uninstall | rm | delete")
325
385
  async def uninstall_agent(
326
386
  search_path: typing.Annotated[
327
- str, typer.Argument(..., help="Short ID, agent name or part of the provider location")
328
- ],
387
+ str, typer.Argument(help="Short ID, agent name or part of the provider location")
388
+ ] = "",
329
389
  yes: typing.Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompts.")] = False,
390
+ all: typing.Annotated[bool, typer.Option("--all", "-a", help="Remove all agents without selection.")] = False,
330
391
  ) -> None:
331
392
  """Remove agent"""
332
- url = announce_server_action(f"Removing agent '{search_path}' from")
333
- await confirm_server_action("Proceed with removing this agent from", url=url, yes=yes)
334
- with console.status("Uninstalling agent (may take a few minutes)...", spinner="dots"):
335
- async with configuration.use_platform_client():
336
- remove_provider = select_provider(search_path, await Provider.list()).id
337
- await Provider.delete(remove_provider)
393
+ if search_path and all:
394
+ console.error(
395
+ "[bold]Cannot specify both --all and a search path."
396
+ " Use --all to remove all agents, or provide a search path for specific agents."
397
+ "[/bold]"
398
+ )
399
+ raise typer.Exit(1)
400
+
401
+ async with configuration.use_platform_client():
402
+ providers = await Provider.list()
403
+ if len(providers) == 0:
404
+ console.info("No agents found to remove.")
405
+ return
406
+
407
+ if all:
408
+ selected_providers = providers
409
+ else:
410
+ selected_providers = await select_providers_multi(search_path, providers)
411
+ if not selected_providers:
412
+ console.info("No agents selected for removal, exiting.")
413
+ return
414
+ elif len(selected_providers) == 1:
415
+ agent_names = f"{selected_providers[0].agent_card.name} - {selected_providers[0].id.split('-', 1)[0]}"
416
+ else:
417
+ agent_names = "\n".join([f" - {p.agent_card.name} - {p.id.split('-', 1)[0]}" for p in selected_providers])
418
+
419
+ message = f"\n[bold]Selected agents to remove:[/bold]\n{agent_names}\n from "
420
+
421
+ url = announce_server_action(message)
422
+ await confirm_server_action("Proceed with removing these agents from", url=url, yes=yes)
423
+
424
+ with console.status("Uninstalling agent(s) (may take a few minutes)...", spinner="dots"):
425
+ delete_tasks = [Provider.delete(provider.id) for provider in selected_providers]
426
+ results = await asyncio.gather(*delete_tasks, return_exceptions=True)
427
+
428
+ # Check results for exceptions
429
+ for provider, result in zip(selected_providers, results, strict=True):
430
+ if isinstance(result, Exception):
431
+ err_console.print(f"Failed to delete {provider.agent_card.name}: {result}")
432
+ # else: deletion succeeded
433
+
338
434
  await list_agents()
339
435
 
340
436
 
@@ -431,11 +527,43 @@ async def _ask_form_questions(form_render: FormRender) -> FormResponse:
431
527
  return FormResponse(values=form_values)
432
528
 
433
529
 
530
+ async def _ask_settings_questions(settings_render: SettingsRender) -> AgentRunSettings:
531
+ """Ask user to configure settings using inquirer."""
532
+ settings_values: dict[str, SettingsFieldValue] = {}
533
+
534
+ console.print("[bold]Agent Settings[/bold]\n")
535
+
536
+ for field in settings_render.fields:
537
+ if isinstance(field, CheckboxGroupField):
538
+ checkbox_values: dict[str, SettingsCheckboxFieldValue] = {}
539
+ for checkbox in field.fields:
540
+ answer = await inquirer.confirm( # pyright: ignore[reportPrivateImportUsage]
541
+ message=checkbox.label + ":",
542
+ default=checkbox.default_value,
543
+ ).execute_async()
544
+ checkbox_values[checkbox.id] = SettingsCheckboxFieldValue(value=answer)
545
+ settings_values[field.id] = CheckboxGroupFieldValue(values=checkbox_values)
546
+ elif isinstance(field, SettingsSingleSelectField):
547
+ choices = [Choice(value=opt.value, name=opt.label) for opt in field.options]
548
+ answer = await inquirer.fuzzy( # pyright: ignore[reportPrivateImportUsage]
549
+ message=field.label + ":",
550
+ choices=choices,
551
+ default=field.default_value,
552
+ ).execute_async()
553
+ settings_values[field.id] = SettingsSingleSelectFieldValue(value=answer)
554
+ else:
555
+ raise ValueError(f"Unsupported settings field type: {type(field).__name__}")
556
+
557
+ console.print()
558
+ return AgentRunSettings(values=settings_values)
559
+
560
+
434
561
  async def _run_agent(
435
562
  client: Client,
436
563
  input: str | DataPart | FormResponse,
437
564
  agent_card: AgentCard,
438
565
  context_token: ContextToken,
566
+ settings: AgentRunSettings | None = None,
439
567
  dump_files_path: Path | None = None,
440
568
  handle_input: Callable[[], str] | None = None,
441
569
  task_id: str | None = None,
@@ -508,6 +636,7 @@ async def _run_agent(
508
636
  if platform_extension_spec
509
637
  else {}
510
638
  )
639
+ | ({SettingsExtensionSpec.URI: settings.model_dump(mode="json")} if settings else {})
511
640
  )
512
641
 
513
642
  msg = Message(
@@ -556,7 +685,7 @@ async def _run_agent(
556
685
  console.print() # Add newline after completion
557
686
  return
558
687
  case Task(id=task_id), TaskStatusUpdateEvent(
559
- status=TaskStatus(state=TaskState.working, message=message)
688
+ status=TaskStatus(state=TaskState.working | TaskState.submitted, message=message)
560
689
  ):
561
690
  # Handle streaming content during working state
562
691
  if message:
@@ -880,8 +1009,6 @@ async def run_agent(
880
1009
  ] = None,
881
1010
  ) -> None:
882
1011
  """Run an agent."""
883
- if search_path is not None and input is None and sys.stdin.isatty():
884
- input = sys.stdin.read()
885
1012
  async with configuration.use_platform_client():
886
1013
  providers = await Provider.list()
887
1014
  await ensure_llm_provider()
@@ -926,6 +1053,15 @@ async def run_agent(
926
1053
  splash_screen = Group(Markdown(f"# {agent.name} \n{agent.description}"), NewLine())
927
1054
  handle_input = _create_input_handler([], splash_screen=splash_screen)
928
1055
 
1056
+ settings_render = next(
1057
+ (
1058
+ SettingsRender.model_validate(ext.params)
1059
+ for ext in agent.capabilities.extensions or ()
1060
+ if ext.uri == SettingsExtensionSpec.URI and ext.params
1061
+ ),
1062
+ None,
1063
+ )
1064
+
929
1065
  if not input:
930
1066
  if interaction_mode not in {InteractionMode.MULTI_TURN, InteractionMode.SINGLE_TURN}:
931
1067
  err_console.error(
@@ -946,8 +1082,9 @@ async def run_agent(
946
1082
 
947
1083
  if interaction_mode == InteractionMode.MULTI_TURN:
948
1084
  console.print(f"{user_greeting}\n")
1085
+ settings_input = await _ask_settings_questions(settings_render) if settings_render else None
949
1086
  turn_input = await _ask_form_questions(initial_form_render) if initial_form_render else handle_input()
950
- async with a2a_client(provider.agent_card) as client:
1087
+ async with a2a_client(provider.agent_card, context_token=context_token) as client:
951
1088
  while True:
952
1089
  console.print()
953
1090
  await _run_agent(
@@ -955,6 +1092,7 @@ async def run_agent(
955
1092
  input=turn_input,
956
1093
  agent_card=agent,
957
1094
  context_token=context_token,
1095
+ settings=settings_input,
958
1096
  dump_files_path=dump_files,
959
1097
  handle_input=handle_input,
960
1098
  )
@@ -963,24 +1101,29 @@ async def run_agent(
963
1101
  elif interaction_mode == InteractionMode.SINGLE_TURN:
964
1102
  user_greeting = ui_annotations.get("user_greeting", None) or "Enter your instructions."
965
1103
  console.print(f"{user_greeting}\n")
1104
+ settings_input = await _ask_settings_questions(settings_render) if settings_render else None
966
1105
  console.print()
967
- async with a2a_client(provider.agent_card) as client:
1106
+ async with a2a_client(provider.agent_card, context_token=context_token) as client:
968
1107
  await _run_agent(
969
1108
  client,
970
1109
  input=await _ask_form_questions(initial_form_render) if initial_form_render else handle_input(),
971
1110
  agent_card=agent,
972
1111
  context_token=context_token,
1112
+ settings=settings_input,
973
1113
  dump_files_path=dump_files,
974
1114
  handle_input=handle_input,
975
1115
  )
976
1116
 
977
1117
  else:
978
- async with a2a_client(provider.agent_card) as client:
1118
+ settings_input = await _ask_settings_questions(settings_render) if settings_render else None
1119
+
1120
+ async with a2a_client(provider.agent_card, context_token=context_token) as client:
979
1121
  await _run_agent(
980
1122
  client,
981
1123
  input,
982
1124
  agent_card=agent,
983
1125
  context_token=context_token,
1126
+ settings=settings_input,
984
1127
  dump_files_path=dump_files,
985
1128
  handle_input=handle_input,
986
1129
  )
@@ -113,7 +113,7 @@ async def client_side_build(
113
113
  context_hash = hashlib.sha256((context + (dockerfile or "")).encode()).hexdigest()[:6]
114
114
  context_shorter = re.sub(r"https?://", "", context).replace(r".git", "")
115
115
  context_shorter = re.sub(r"[^a-zA-Z0-9_-]+", "-", context_shorter)[:32].lstrip("-") or "provider"
116
- tag = (tag or f"agentstack.local/{context_shorter}-{context_hash}:latest").lower()
116
+ tag = (tag or f"agentstack-registry-svc.default:5001/{context_shorter}-{context_hash}:latest").lower()
117
117
  await run_command(
118
118
  command=[
119
119
  *(
@@ -135,11 +135,22 @@ async def client_side_build(
135
135
  if import_image:
136
136
  from agentstack_cli.commands.platform import get_driver
137
137
 
138
+ if "agentstack-registry-svc.default" not in tag:
139
+ source_tag = tag
140
+ tag = re.sub("^[^/]*/", "agentstack-registry-svc.default:5001/", tag)
141
+ await run_command(["docker", "tag", source_tag, tag], "Tagging image")
142
+
138
143
  driver = get_driver(vm_name=vm_name)
144
+
139
145
  if (await driver.status()) != "running":
140
146
  console.error("Agent Stack platform is not running.")
141
147
  sys.exit(1)
142
- await driver.import_image(tag)
148
+
149
+ await driver.import_image_to_internal_registry(tag)
150
+ console.success(
151
+ "Agent was imported to the agent stack internal registry.\n"
152
+ + f"You can add it using [blue]agentstack add {tag}[/blue]"
153
+ )
143
154
 
144
155
  return tag, agent_card
145
156
 
@@ -36,7 +36,7 @@ class ModelProviderError(Exception): ...
36
36
 
37
37
  @functools.cache
38
38
  def _ollama_exe() -> str:
39
- for exe in ("ollama", "ollama.exe"):
39
+ for exe in ("ollama", "ollama.exe", os.environ.get("LOCALAPPDATA", "") + "\\Programs\\Ollama\\ollama.exe"):
40
40
  if shutil.which(exe):
41
41
  return exe
42
42
  raise RuntimeError("Ollama executable not found")
@@ -22,6 +22,7 @@ class BaseDriver(abc.ABC):
22
22
 
23
23
  def __init__(self, vm_name: str = "agentstack"):
24
24
  self.vm_name = vm_name
25
+ self.loaded_images: set[str] = set()
25
26
 
26
27
  @abc.abstractmethod
27
28
  async def run_in_vm(
@@ -47,6 +48,9 @@ class BaseDriver(abc.ABC):
47
48
  @abc.abstractmethod
48
49
  async def import_image(self, tag: str) -> None: ...
49
50
 
51
+ @abc.abstractmethod
52
+ async def import_image_to_internal_registry(self, tag: str) -> None: ...
53
+
50
54
  @abc.abstractmethod
51
55
  async def exec(self, command: list[str]) -> None: ...
52
56
 
@@ -138,20 +142,17 @@ class BaseDriver(abc.ABC):
138
142
  ).stdout.decode()
139
143
  for image in import_images or []:
140
144
  await self.import_image(image)
145
+ self.loaded_images.add(image)
141
146
  for image in {typing.cast(str, yaml.safe_load(line)) for line in images_str.splitlines()} - set(
142
147
  import_images or []
143
148
  ):
144
149
  async for attempt in AsyncRetrying(stop=stop_after_attempt(5)):
145
150
  with attempt:
146
151
  attempt_num = attempt.retry_state.attempt_number
152
+ image_id = image if "." in image.split("/")[0] else f"docker.io/{image}"
153
+ self.loaded_images.add(image_id)
147
154
  await self.run_in_vm(
148
- [
149
- "k3s",
150
- "ctr",
151
- "image",
152
- "pull",
153
- image if "." in image.split("/")[0] else f"docker.io/{image}",
154
- ],
155
+ ["k3s", "ctr", "image", "pull", image_id],
155
156
  f"Pulling image {image}" + (f" (attempt {attempt_num})" if attempt_num > 1 else ""),
156
157
  )
157
158
 
@@ -197,6 +197,72 @@ class LimaDriver(BaseDriver):
197
197
  finally:
198
198
  await image_path.unlink(missing_ok=True)
199
199
 
200
+ @typing.override
201
+ async def import_image_to_internal_registry(self, tag: str) -> None:
202
+ # 1. Check if registry is running
203
+ try:
204
+ await self.run_in_vm(
205
+ ["k3s", "kubectl", "get", "svc", "agentstack-registry-svc"],
206
+ "Checking internal registry availability",
207
+ )
208
+ except Exception as e:
209
+ console.warning(f"Internal registry service not found. Push might fail: {e}")
210
+
211
+ # 2. Export image from Docker to shared temp dir
212
+ image_dir = anyio.Path("/tmp/agentstack")
213
+ await image_dir.mkdir(exist_ok=True, parents=True)
214
+ image_file = f"{uuid.uuid4()}.tar"
215
+ image_path = image_dir / image_file
216
+
217
+ try:
218
+ await run_command(
219
+ ["docker", "image", "save", "-o", str(image_path), tag],
220
+ f"Exporting image {tag} from Docker",
221
+ )
222
+
223
+ # 3 & 4. Run Crane Job
224
+ crane_image = "ghcr.io/i-am-bee/alpine/crane:0.20.6"
225
+ for image in self.loaded_images:
226
+ if "alpine/crane" in image:
227
+ crane_image = image
228
+ break
229
+
230
+ job_name = f"push-{uuid.uuid4().hex[:6]}"
231
+ job_def = {
232
+ "apiVersion": "batch/v1",
233
+ "kind": "Job",
234
+ "metadata": {"name": job_name, "namespace": "default"},
235
+ "spec": {
236
+ "backoffLimit": 0,
237
+ "ttlSecondsAfterFinished": 60,
238
+ "template": {
239
+ "spec": {
240
+ "restartPolicy": "Never",
241
+ "containers": [
242
+ {
243
+ "name": "crane",
244
+ "image": crane_image,
245
+ "command": ["crane", "push", f"/workspace/{image_file}", tag, "--insecure"],
246
+ "volumeMounts": [{"name": "workspace", "mountPath": "/workspace"}],
247
+ }
248
+ ],
249
+ "volumes": [{"name": "workspace", "hostPath": {"path": "/tmp/agentstack"}}],
250
+ }
251
+ },
252
+ },
253
+ }
254
+
255
+ await self.run_in_vm(
256
+ ["k3s", "kubectl", "apply", "-f", "-"], "Starting push job", input=yaml.dump(job_def).encode()
257
+ )
258
+ await self.run_in_vm(
259
+ ["k3s", "kubectl", "wait", "--for=condition=complete", f"job/{job_name}", "--timeout=300s"],
260
+ "Waiting for push to complete",
261
+ )
262
+ await self.run_in_vm(["k3s", "kubectl", "delete", "job", job_name], "Cleaning up push job")
263
+ finally:
264
+ await image_path.unlink(missing_ok=True)
265
+
200
266
  @typing.override
201
267
  async def exec(self, command: list[str]):
202
268
  await anyio.run_process(
@@ -12,8 +12,6 @@ import typing
12
12
  import anyio
13
13
  import pydantic
14
14
  import yaml
15
- from InquirerPy import inquirer
16
- from InquirerPy.base.control import Choice
17
15
 
18
16
  from agentstack_cli.commands.platform.base_driver import BaseDriver
19
17
  from agentstack_cli.configuration import Configuration
@@ -87,29 +85,9 @@ class WSLDriver(BaseDriver):
87
85
  if not config.has_section("wsl2"):
88
86
  config.add_section("wsl2")
89
87
 
90
- if (
91
- config.get("wsl2", "networkingMode", fallback=None) != "mirrored"
92
- and await inquirer.select( # type: ignore
93
- textwrap.dedent("""\
94
- The Agent Stack platform needs to switch WSL to `mirrored` networking mode in order to support connecting to Windows applications -- like Ollama or self-registered agents. If you skip this step, these features won't be available.
95
-
96
- However, the default `nat` mode is required by some software, like Docker Desktop or Rancher Desktop, to function properly. If you use such software, you may want to keep the default `nat` mode.
97
-
98
- (It can be changed anytime later in C:/Users/<your name>/.wslconfig, followed by `wsl --shutdown` and `agentstack platform start` to apply changes.)
99
- """),
100
- choices=[
101
- Choice(
102
- value=True,
103
- name="Change WSL2 networking mode to `mirrored`",
104
- ),
105
- Choice(
106
- value=False,
107
- name="Leave WSL2 networking mode as `nat`",
108
- ),
109
- ],
110
- ).execute_async()
111
- ):
112
- config.set("wsl2", "networkingMode", "mirrored")
88
+ wsl2_networking_mode = config.get("wsl2", "networkingMode", fallback=None)
89
+ if wsl2_networking_mode and wsl2_networking_mode != "nat":
90
+ config.set("wsl2", "networkingMode", "nat")
113
91
  f.seek(0)
114
92
  f.truncate(0)
115
93
  config.write(f)
@@ -154,6 +132,16 @@ class WSLDriver(BaseDriver):
154
132
  values_file: pathlib.Path | None = None,
155
133
  import_images: list[str] | None = None,
156
134
  ) -> None:
135
+ host_ip = (
136
+ (
137
+ await self.run_in_vm(
138
+ ["bash", "-c", "ip route show | grep -i default | cut -d' ' -f3"],
139
+ "Detecting host IP address",
140
+ )
141
+ )
142
+ .stdout.decode()
143
+ .strip()
144
+ )
157
145
  await self.run_in_vm(
158
146
  ["k3s", "kubectl", "apply", "-f", "-"],
159
147
  "Setting up internal networking",
@@ -163,7 +151,7 @@ class WSLDriver(BaseDriver):
163
151
  "kind": "ConfigMap",
164
152
  "metadata": {"name": "coredns-custom", "namespace": "kube-system"},
165
153
  "data": {
166
- "default.server": "host.docker.internal {\n hosts {\n 127.0.0.1 host.docker.internal\n fallthrough\n }\n}"
154
+ "default.server": f"host.docker.internal {{\n hosts {{\n {host_ip} host.docker.internal\n fallthrough\n }}\n}}"
167
155
  },
168
156
  }
169
157
  ).encode(),
@@ -220,6 +208,10 @@ class WSLDriver(BaseDriver):
220
208
  async def import_image(self, tag: str) -> None:
221
209
  raise NotImplementedError("Importing images is not supported on this platform.")
222
210
 
211
+ @typing.override
212
+ async def import_image_to_internal_registry(self, tag: str) -> None:
213
+ raise NotImplementedError("Importing images to internal registry is not supported on this platform.")
214
+
223
215
  @typing.override
224
216
  async def exec(self, command: list[str]):
225
217
  await anyio.run_process(
@@ -309,10 +309,15 @@ def print_log(line, ansi_mode=False, out_console: Console | None = None):
309
309
  def decode(text: str):
310
310
  return Text.from_ansi(text) if ansi_mode else text
311
311
 
312
- if line["stream"] == "stderr":
313
- (out_console or err_console).print(decode(line["message"]))
314
- elif line["stream"] == "stdout":
315
- (out_console or console).print(decode(line["message"]))
312
+ match line:
313
+ case {"stream": "stderr"}:
314
+ (out_console or err_console).print(decode(line["message"]))
315
+ case {"stream": "stdout"}:
316
+ (out_console or console).print(decode(line["message"]))
317
+ case {"event": "[DONE]"}:
318
+ return
319
+ case _:
320
+ (out_console or console).print(line)
316
321
 
317
322
 
318
323
  def is_github_url(url: str) -> bool: