agentstack-cli 0.0.0__tar.gz → 0.4.0__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 (33) hide show
  1. agentstack_cli-0.4.0/PKG-INFO +26 -0
  2. agentstack_cli-0.4.0/README.md +1 -0
  3. agentstack_cli-0.4.0/pyproject.toml +81 -0
  4. agentstack_cli-0.4.0/src/agentstack_cli/__init__.py +77 -0
  5. agentstack_cli-0.4.0/src/agentstack_cli/api.py +125 -0
  6. agentstack_cli-0.4.0/src/agentstack_cli/async_typer.py +104 -0
  7. agentstack_cli-0.4.0/src/agentstack_cli/auth_manager.py +115 -0
  8. agentstack_cli-0.4.0/src/agentstack_cli/commands/__init__.py +3 -0
  9. agentstack_cli-0.4.0/src/agentstack_cli/commands/agent.py +1077 -0
  10. agentstack_cli-0.4.0/src/agentstack_cli/commands/build.py +184 -0
  11. agentstack_cli-0.4.0/src/agentstack_cli/commands/mcp.py +141 -0
  12. agentstack_cli-0.4.0/src/agentstack_cli/commands/model.py +624 -0
  13. agentstack_cli-0.4.0/src/agentstack_cli/commands/platform/__init__.py +181 -0
  14. agentstack_cli-0.4.0/src/agentstack_cli/commands/platform/base_driver.py +222 -0
  15. agentstack_cli-0.4.0/src/agentstack_cli/commands/platform/istio.py +186 -0
  16. agentstack_cli-0.4.0/src/agentstack_cli/commands/platform/lima_driver.py +210 -0
  17. agentstack_cli-0.4.0/src/agentstack_cli/commands/platform/wsl_driver.py +226 -0
  18. agentstack_cli-0.4.0/src/agentstack_cli/commands/self.py +206 -0
  19. agentstack_cli-0.4.0/src/agentstack_cli/commands/server.py +237 -0
  20. agentstack_cli-0.4.0/src/agentstack_cli/configuration.py +76 -0
  21. agentstack_cli-0.4.0/src/agentstack_cli/console.py +25 -0
  22. agentstack_cli-0.4.0/src/agentstack_cli/data/.gitignore +2 -0
  23. agentstack_cli-0.4.0/src/agentstack_cli/data/helm-chart.tgz +0 -0
  24. agentstack_cli-0.4.0/src/agentstack_cli/utils.py +281 -0
  25. agentstack_cli-0.0.0/PKG-INFO +0 -38
  26. agentstack_cli-0.0.0/README.md +0 -4
  27. agentstack_cli-0.0.0/__init__.py +0 -2
  28. agentstack_cli-0.0.0/agentstack_cli.egg-info/PKG-INFO +0 -38
  29. agentstack_cli-0.0.0/agentstack_cli.egg-info/SOURCES.txt +0 -7
  30. agentstack_cli-0.0.0/agentstack_cli.egg-info/dependency_links.txt +0 -1
  31. agentstack_cli-0.0.0/agentstack_cli.egg-info/top_level.txt +0 -1
  32. agentstack_cli-0.0.0/setup.cfg +0 -4
  33. agentstack_cli-0.0.0/setup.py +0 -47
@@ -0,0 +1,26 @@
1
+ Metadata-Version: 2.3
2
+ Name: agentstack-cli
3
+ Version: 0.4.0
4
+ Summary: Agent Stack CLI
5
+ Author: IBM Corp.
6
+ Requires-Dist: anyio~=4.10.0
7
+ Requires-Dist: pydantic~=2.11.7
8
+ Requires-Dist: pydantic-settings~=2.10.1
9
+ Requires-Dist: requests~=2.32.5
10
+ Requires-Dist: jsonschema~=4.25.1
11
+ Requires-Dist: jsf~=0.11.2
12
+ Requires-Dist: gnureadline~=8.2.13 ; sys_platform != 'win32'
13
+ Requires-Dist: prompt-toolkit~=3.0.52
14
+ Requires-Dist: inquirerpy~=0.3.4
15
+ Requires-Dist: psutil~=7.0.0
16
+ Requires-Dist: a2a-sdk
17
+ Requires-Dist: tenacity~=9.1.2
18
+ Requires-Dist: typer~=0.17.4
19
+ Requires-Dist: pyyaml~=6.0.2
20
+ Requires-Dist: agentstack-sdk
21
+ Requires-Dist: authlib~=1.6.3
22
+ Requires-Dist: openai~=1.107.1
23
+ Requires-Python: >=3.13, <3.14
24
+ Description-Content-Type: text/markdown
25
+
26
+ # Agent Stack CLI
@@ -0,0 +1 @@
1
+ # Agent Stack CLI
@@ -0,0 +1,81 @@
1
+ [project]
2
+ name = "agentstack-cli"
3
+ version = "0.4.0"
4
+ description = "Agent Stack CLI"
5
+ readme = "README.md"
6
+ authors = [{ name = "IBM Corp." }]
7
+ requires-python = ">=3.13,<3.14"
8
+ dependencies = [
9
+ "anyio~=4.10.0",
10
+ "pydantic~=2.11.7",
11
+ "pydantic-settings~=2.10.1",
12
+ "requests~=2.32.5",
13
+ "jsonschema~=4.25.1",
14
+ "jsf~=0.11.2",
15
+ 'gnureadline~=8.2.13; sys_platform != "win32"',
16
+ "prompt-toolkit~=3.0.52",
17
+ "inquirerpy~=0.3.4",
18
+ "psutil~=7.0.0",
19
+ "a2a-sdk", # version determined by agentstack-sdk
20
+ "tenacity~=9.1.2",
21
+ "typer~=0.17.4",
22
+ "pyyaml~=6.0.2",
23
+ "agentstack-sdk",
24
+ "authlib~=1.6.3",
25
+ "openai~=1.107.1",
26
+ ]
27
+
28
+ [dependency-groups]
29
+ dev = [
30
+ "pyright>=1.1.399",
31
+ "pytest>=8.3.4",
32
+ "ruff>=0.8.5",
33
+ "wheel>=0.45.1",
34
+ ]
35
+
36
+ [tool.uv.sources]
37
+ agentstack-sdk = { path = "../agentstack-sdk-py", editable = true }
38
+
39
+ [project.scripts]
40
+ agentstack = "agentstack_cli:app"
41
+ agentstack-cli = "agentstack_cli:app"
42
+
43
+ [build-system]
44
+ requires = ["uv_build>=0.9.0,<0.10.0"]
45
+ build-backend = "uv_build"
46
+
47
+ [tool.uv.build-backend]
48
+ source-include = ["src/agentstack_cli/data"]
49
+ source-exclude = ["**/.DS_Store"]
50
+
51
+ [tool.ruff]
52
+ line-length = 120
53
+ target-version = "py313"
54
+ lint.select = [
55
+ "E", # pycodestyle errors
56
+ "W", # pycodestyle warnings
57
+ "F", # pyflakes
58
+ "UP", # pyupgrade
59
+ "I", # isort
60
+ "B", # bugbear
61
+ "N", # pep8-naming
62
+ "C4", # Comprehensions
63
+ "Q", # Quotes
64
+ "SIM", # Simplify
65
+ "RUF", # Ruff
66
+ "TID", # tidy-imports
67
+ "ASYNC", # async
68
+ # TODO: add "DTZ", # DatetimeZ
69
+ # TODO: add "ANN", # annotations
70
+ ]
71
+ lint.ignore = [
72
+ "E501", # line lenght (annyoing)
73
+ "UP013", # update TypedDict to longer syntax -- not necessary
74
+ ]
75
+ force-exclude = true
76
+
77
+ [tool.pyright]
78
+ reportUnusedCallResult = false
79
+ ignore = ["tests/**", "examples/cli.py"]
80
+ venvPath = "."
81
+ venv = ".venv"
@@ -0,0 +1,77 @@
1
+ # Copyright 2025 © BeeAI a Series of LF Projects, LLC
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ import logging
5
+ import typing
6
+ from copy import deepcopy
7
+
8
+ import typer
9
+
10
+ import agentstack_cli.commands.agent
11
+ import agentstack_cli.commands.build
12
+ import agentstack_cli.commands.mcp
13
+ import agentstack_cli.commands.model
14
+ import agentstack_cli.commands.platform
15
+ import agentstack_cli.commands.self
16
+ import agentstack_cli.commands.server
17
+ from agentstack_cli.async_typer import AsyncTyper
18
+ from agentstack_cli.configuration import Configuration
19
+
20
+ logging.basicConfig(level=logging.INFO if Configuration().debug else logging.FATAL)
21
+ logging.getLogger("httpx").setLevel(logging.WARNING) # not sure why this is necessary
22
+
23
+ app = AsyncTyper(no_args_is_help=True)
24
+ app.add_typer(agentstack_cli.commands.model.app, name="model", no_args_is_help=True, help="Manage model providers.")
25
+ app.add_typer(agentstack_cli.commands.agent.app, name="agent", no_args_is_help=True, help="Manage agents.")
26
+ app.add_typer(
27
+ agentstack_cli.commands.platform.app, name="platform", no_args_is_help=True, help="Manage Agent Stack platform."
28
+ )
29
+ app.add_typer(
30
+ agentstack_cli.commands.mcp.app, name="mcp", no_args_is_help=True, help="Manage MCP servers and toolkits."
31
+ )
32
+ app.add_typer(agentstack_cli.commands.build.app, name="", no_args_is_help=True, help="Build agent images.")
33
+ app.add_typer(
34
+ agentstack_cli.commands.server.app,
35
+ name="server",
36
+ no_args_is_help=True,
37
+ help="Manage Agent Stack servers and authentication.",
38
+ )
39
+ app.add_typer(
40
+ agentstack_cli.commands.self.app,
41
+ name="self",
42
+ no_args_is_help=True,
43
+ help="Manage Agent Stack installation.",
44
+ hidden=True,
45
+ )
46
+
47
+
48
+ agent_alias = deepcopy(agentstack_cli.commands.agent.app)
49
+ for cmd in agent_alias.registered_commands:
50
+ cmd.rich_help_panel = "Agent commands"
51
+
52
+ app.add_typer(agent_alias, name="", no_args_is_help=True)
53
+
54
+
55
+ @app.command("version")
56
+ async def version(verbose: typing.Annotated[bool, typer.Option("-v", help="Show verbose output")] = False):
57
+ """Print version of the Agent Stack CLI."""
58
+ import agentstack_cli.commands.self
59
+
60
+ await agentstack_cli.commands.self.version(verbose=verbose)
61
+
62
+
63
+ @app.command("ui")
64
+ async def ui():
65
+ """Launch the graphical interface."""
66
+ import webbrowser
67
+
68
+ import agentstack_cli.commands.model
69
+
70
+ await agentstack_cli.commands.model.ensure_llm_provider()
71
+ webbrowser.open(
72
+ "http://localhost:8334"
73
+ ) # TODO: This always opens the local UI, how to open the UI of a logged in server instead?
74
+
75
+
76
+ if __name__ == "__main__":
77
+ app()
@@ -0,0 +1,125 @@
1
+ # Copyright 2025 © BeeAI a Series of LF Projects, LLC
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ import re
5
+ import urllib
6
+ import urllib.parse
7
+ from collections.abc import AsyncIterator
8
+ from contextlib import asynccontextmanager
9
+ from datetime import timedelta
10
+ from typing import Any
11
+
12
+ import httpx
13
+ import openai
14
+ from a2a.client import Client, ClientConfig, ClientFactory
15
+ from a2a.types import AgentCard
16
+ from httpx import HTTPStatusError
17
+ from httpx._types import RequestFiles
18
+
19
+ from agentstack_cli import configuration
20
+ from agentstack_cli.configuration import Configuration
21
+
22
+ config = Configuration()
23
+
24
+ API_BASE_URL = "api/v1/"
25
+
26
+
27
+ async def api_request(
28
+ method: str,
29
+ path: str,
30
+ json: dict | None = None,
31
+ files: RequestFiles | None = None,
32
+ params: dict[str, Any] | None = None,
33
+ use_auth: bool = True,
34
+ ) -> dict | None:
35
+ """Make an API request to the server."""
36
+ async with configuration.use_platform_client() as client:
37
+ response = await client.request(
38
+ method,
39
+ urllib.parse.urljoin(API_BASE_URL, path),
40
+ json=json,
41
+ files=files,
42
+ params=params,
43
+ timeout=60,
44
+ headers=(
45
+ {"Authorization": f"Bearer {token}"}
46
+ if use_auth and (token := config.auth_manager.load_auth_token())
47
+ else {}
48
+ ),
49
+ )
50
+ if response.is_error:
51
+ error = ""
52
+ try:
53
+ error = response.json()
54
+ error = error.get("detail", str(error))
55
+ except Exception:
56
+ response.raise_for_status()
57
+ if response.status_code == 401:
58
+ message = f'{error}\nexport AGENTSTACK__ADMIN_PASSWORD="<PASSWORD>" to set the admin password.'
59
+ raise HTTPStatusError(message=message, request=response.request, response=response)
60
+ raise HTTPStatusError(message=error, request=response.request, response=response)
61
+ if response.content:
62
+ return response.json()
63
+
64
+
65
+ async def api_stream(
66
+ method: str,
67
+ path: str,
68
+ json: dict | None = None,
69
+ params: dict[str, Any] | None = None,
70
+ use_auth: bool = True,
71
+ ) -> AsyncIterator[dict[str, Any]]:
72
+ """Make a streaming API request to the server."""
73
+ import json as jsonlib
74
+
75
+ async with (
76
+ configuration.use_platform_client() as client,
77
+ client.stream(
78
+ method,
79
+ urllib.parse.urljoin(API_BASE_URL, path),
80
+ json=json,
81
+ params=params,
82
+ timeout=timedelta(hours=1).total_seconds(),
83
+ headers=(
84
+ {"Authorization": f"Bearer {token}"}
85
+ if use_auth and (token := config.auth_manager.load_auth_token())
86
+ else {}
87
+ ),
88
+ ) as response,
89
+ ):
90
+ response: httpx.Response
91
+ if response.is_error:
92
+ error = ""
93
+ try:
94
+ [error] = [jsonlib.loads(message) async for message in response.aiter_text()]
95
+ error = error.get("detail", str(error))
96
+ except Exception:
97
+ response.raise_for_status()
98
+ raise HTTPStatusError(message=error, request=response.request, response=response)
99
+ async for line in response.aiter_lines():
100
+ if line:
101
+ yield jsonlib.loads(re.sub("^data:", "", line).strip())
102
+
103
+
104
+ @asynccontextmanager
105
+ async def a2a_client(agent_card: AgentCard, use_auth: bool = True) -> AsyncIterator[Client]:
106
+ async with httpx.AsyncClient(
107
+ headers=(
108
+ {"Authorization": f"Bearer {token}"}
109
+ if use_auth and (token := config.auth_manager.load_auth_token())
110
+ else {}
111
+ ),
112
+ follow_redirects=True,
113
+ timeout=timedelta(hours=1).total_seconds(),
114
+ ) as httpx_client:
115
+ yield ClientFactory(ClientConfig(httpx_client=httpx_client, use_client_preference=True)).create(card=agent_card)
116
+
117
+
118
+ @asynccontextmanager
119
+ async def openai_client() -> AsyncIterator[openai.AsyncOpenAI]:
120
+ async with Configuration().use_platform_client() as platform_client:
121
+ yield openai.AsyncOpenAI(
122
+ api_key=platform_client.headers.get("Authorization", "").removeprefix("Bearer ") or "dummy",
123
+ base_url=urllib.parse.urljoin(str(platform_client.base_url), urllib.parse.urljoin(API_BASE_URL, "openai")),
124
+ default_headers=platform_client.headers,
125
+ )
@@ -0,0 +1,104 @@
1
+ # Copyright 2025 © BeeAI a Series of LF Projects, LLC
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ import asyncio
5
+ import functools
6
+ import inspect
7
+ import re
8
+ import sys
9
+ from collections.abc import Iterator
10
+ from contextlib import contextmanager
11
+
12
+ import rich.text
13
+ import typer
14
+ from rich.console import RenderResult
15
+ from rich.markdown import Heading, Markdown
16
+ from rich.table import Table
17
+ from typer.core import TyperGroup
18
+
19
+ from agentstack_cli.configuration import Configuration
20
+ from agentstack_cli.console import console, err_console
21
+ from agentstack_cli.utils import extract_messages, format_error
22
+
23
+ DEBUG = Configuration().debug
24
+
25
+
26
+ class _LeftAlignedHeading(Heading):
27
+ def __rich_console__(self, *args, **kwargs) -> RenderResult:
28
+ for elem in super().__rich_console__(*args, **kwargs):
29
+ if isinstance(elem, rich.text.Text):
30
+ elem.justify = "left"
31
+ yield elem
32
+
33
+
34
+ Markdown.elements["heading_open"] = _LeftAlignedHeading
35
+
36
+
37
+ @contextmanager
38
+ def create_table(*args, no_wrap: bool = True, **kwargs) -> Iterator[Table]:
39
+ table = Table(*args, **kwargs, box=None, pad_edge=False, width=console.width, show_header=True)
40
+ yield table
41
+ for column in table.columns:
42
+ column.no_wrap = no_wrap
43
+ column.overflow = "ellipsis"
44
+ assert isinstance(column.header, str)
45
+ column.header = column.header.upper()
46
+
47
+ if not table.rows:
48
+ table._render = lambda *args, **kwargs: [rich.text.Text("<No items found>", style="italic")]
49
+
50
+
51
+ class AliasGroup(TyperGroup):
52
+ """Taken from https://github.com/fastapi/typer/issues/132#issuecomment-2417492805"""
53
+
54
+ _CMD_SPLIT_P = re.compile(r" ?[,|] ?")
55
+
56
+ def get_command(self, ctx, cmd_name):
57
+ cmd_name = self._group_cmd_name(cmd_name)
58
+ return super().get_command(ctx, cmd_name)
59
+
60
+ def _group_cmd_name(self, default_name):
61
+ for cmd in self.commands.values():
62
+ name = cmd.name
63
+ if name and default_name in self._CMD_SPLIT_P.split(name):
64
+ return name
65
+ return default_name
66
+
67
+
68
+ class AsyncTyper(typer.Typer):
69
+ def __init__(self, *args, **kwargs):
70
+ super().__init__(*args, **kwargs, cls=AliasGroup)
71
+
72
+ def command(self, *args, **kwargs):
73
+ parent_decorator = super().command(*args, **kwargs)
74
+
75
+ def decorator(f):
76
+ @functools.wraps(f)
77
+ def wrapped_f(*args, **kwargs):
78
+ try:
79
+ if inspect.iscoroutinefunction(f):
80
+ return asyncio.run(f(*args, **kwargs))
81
+ else:
82
+ return f(*args, **kwargs)
83
+ except* Exception as ex:
84
+ is_connect_error = False
85
+ for exc_type, message in extract_messages(ex):
86
+ err_console.print(format_error(exc_type, message))
87
+ is_connect_error = is_connect_error or exc_type in ["ConnectionError", "ConnectError"]
88
+ err_console.print()
89
+ if is_connect_error:
90
+ err_console.hint(
91
+ "Start the Agent Stack platform using: [green]agentstack platform start[/green]. If that does not help, run [green]agentstack platform delete[/green] to clean up, then [green]agentstack platform start[/green] again."
92
+ )
93
+ else:
94
+ err_console.hint(
95
+ "Are you having consistent problems? If so, try these troubleshooting steps: [green]agentstack platform delete[/green] to remove the platform, and [green]agentstack platform start[/green] to recreate it."
96
+ )
97
+ if DEBUG:
98
+ raise
99
+ sys.exit(1)
100
+
101
+ parent_decorator(wrapped_f)
102
+ return f
103
+
104
+ return decorator
@@ -0,0 +1,115 @@
1
+ # Copyright 2025 © BeeAI a Series of LF Projects, LLC
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ import pathlib
5
+ import typing
6
+ from collections import defaultdict
7
+ from typing import Any
8
+
9
+ from pydantic import BaseModel, Field
10
+
11
+
12
+ class AuthToken(BaseModel):
13
+ access_token: str
14
+ token_type: str = "Bearer"
15
+ expires_in: int | None = None
16
+ refresh_token: str | None = None
17
+ scope: str | None = None
18
+
19
+
20
+ class AuthServer(BaseModel):
21
+ token: AuthToken | None = None
22
+
23
+
24
+ class Server(BaseModel):
25
+ authorization_servers: dict[str, AuthServer] = Field(default_factory=dict)
26
+
27
+
28
+ class Auth(BaseModel):
29
+ version: typing.Literal[1] = 1
30
+ servers: defaultdict[str, typing.Annotated[Server, Field(default_factory=Server)]] = Field(
31
+ default_factory=lambda: defaultdict(Server)
32
+ )
33
+ active_server: str | None = None
34
+ active_auth_server: str | None = None
35
+
36
+
37
+ @typing.final
38
+ class AuthManager:
39
+ def __init__(self, config_path: pathlib.Path):
40
+ self._auth_path = config_path
41
+ self._auth = self._load()
42
+
43
+ def _load(self) -> Auth:
44
+ if not self._auth_path.exists():
45
+ return Auth()
46
+ return Auth.model_validate_json(self._auth_path.read_bytes())
47
+
48
+ def _save(self) -> None:
49
+ self._auth_path.write_text(self._auth.model_dump_json(indent=2))
50
+
51
+ def save_auth_token(self, server: str, auth_server: str | None = None, token: dict[str, Any] | None = None) -> None:
52
+ if auth_server is not None and token is not None:
53
+ self._auth.servers[server].authorization_servers[auth_server] = AuthServer(token=AuthToken(**token))
54
+ else:
55
+ self._auth.servers[server] # touch
56
+ self._save()
57
+
58
+ def load_auth_token(self) -> str | None:
59
+ active_res = self._auth.active_server
60
+ active_auth_server = self._auth.active_auth_server
61
+ if not active_res or not active_auth_server:
62
+ return None
63
+ server = self._auth.servers.get(active_res)
64
+ if not server:
65
+ return None
66
+
67
+ auth_server = server.authorization_servers.get(active_auth_server)
68
+ if not auth_server or not auth_server.token:
69
+ return None
70
+
71
+ return auth_server.token.access_token
72
+
73
+ def clear_auth_token(self, all: bool = False) -> None:
74
+ if all:
75
+ self._auth.servers = defaultdict(Server)
76
+ else:
77
+ if self._auth.active_server and self._auth.active_auth_server:
78
+ del self._auth.servers[self._auth.active_server].authorization_servers[self._auth.active_auth_server]
79
+ if self._auth.active_server and not self._auth.servers[self._auth.active_server].authorization_servers:
80
+ del self._auth.servers[self._auth.active_server]
81
+ self._auth.active_server = None
82
+ self._auth.active_auth_server = None
83
+ self._save()
84
+
85
+ def get_server(self, server: str) -> Server | None:
86
+ return self._auth.servers.get(server)
87
+
88
+ @property
89
+ def servers(self) -> list[str]:
90
+ return list(self._auth.servers.keys())
91
+
92
+ @property
93
+ def active_server(self) -> str | None:
94
+ return self._auth.active_server
95
+
96
+ @active_server.setter
97
+ def active_server(self, server: str | None) -> None:
98
+ if server is not None and server not in self._auth.servers:
99
+ raise ValueError(f"Server {server} not found")
100
+ self._auth.active_server = server
101
+ self._save()
102
+
103
+ @property
104
+ def active_auth_server(self) -> str | None:
105
+ return self._auth.active_auth_server
106
+
107
+ @active_auth_server.setter
108
+ def active_auth_server(self, auth_server: str | None) -> None:
109
+ if auth_server is not None and (
110
+ self._auth.active_server not in self._auth.servers
111
+ or auth_server not in self._auth.servers[self._auth.active_server].authorization_servers
112
+ ):
113
+ raise ValueError(f"Auth server {auth_server} not found in active server")
114
+ self._auth.active_auth_server = auth_server
115
+ self._save()
@@ -0,0 +1,3 @@
1
+ # Copyright 2025 © BeeAI a Series of LF Projects, LLC
2
+ # SPDX-License-Identifier: Apache-2.0
3
+