gumloop 0.3.3__tar.gz → 0.3.5__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 (83) hide show
  1. {gumloop-0.3.3 → gumloop-0.3.5}/PKG-INFO +2 -1
  2. {gumloop-0.3.3 → gumloop-0.3.5}/pyproject.toml +2 -1
  3. {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/__init__.py +1 -1
  4. gumloop-0.3.5/src/gumloop/_auth.py +35 -0
  5. {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/_client.py +3 -2
  6. {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/_http.py +15 -18
  7. {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/cli/commands/agents.py +147 -2
  8. {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/resources/agents.py +82 -0
  9. {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/types.py +29 -0
  10. {gumloop-0.3.3 → gumloop-0.3.5}/tests/cli/test_agents.py +67 -0
  11. {gumloop-0.3.3 → gumloop-0.3.5}/tests/integration/conftest.py +35 -0
  12. gumloop-0.3.5/tests/integration/test_live.py +170 -0
  13. {gumloop-0.3.3 → gumloop-0.3.5}/tests/sdk/test_agents.py +126 -0
  14. {gumloop-0.3.3 → gumloop-0.3.5}/tests/sdk/test_client.py +69 -0
  15. {gumloop-0.3.3 → gumloop-0.3.5}/uv.lock +3 -1
  16. gumloop-0.3.3/tests/integration/test_live.py +0 -64
  17. {gumloop-0.3.3 → gumloop-0.3.5}/.env.example +0 -0
  18. {gumloop-0.3.3 → gumloop-0.3.5}/.gitignore +0 -0
  19. {gumloop-0.3.3 → gumloop-0.3.5}/.pre-commit-config.yaml +0 -0
  20. {gumloop-0.3.3 → gumloop-0.3.5}/.python-version +0 -0
  21. {gumloop-0.3.3 → gumloop-0.3.5}/LICENSE +0 -0
  22. {gumloop-0.3.3 → gumloop-0.3.5}/README.md +0 -0
  23. {gumloop-0.3.3 → gumloop-0.3.5}/scripts/check +0 -0
  24. {gumloop-0.3.3 → gumloop-0.3.5}/scripts/fmt +0 -0
  25. {gumloop-0.3.3 → gumloop-0.3.5}/scripts/sdk_examples.py +0 -0
  26. {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/cli/__init__.py +0 -0
  27. {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/cli/commands/__init__.py +0 -0
  28. {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/cli/commands/_args_input.py +0 -0
  29. {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/cli/commands/_downloads.py +0 -0
  30. {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/cli/commands/artifacts.py +0 -0
  31. {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/cli/commands/auth.py +0 -0
  32. {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/cli/commands/brain.py +0 -0
  33. {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/cli/commands/chat.py +0 -0
  34. {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/cli/commands/mcp.py +0 -0
  35. {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/cli/commands/sessions.py +0 -0
  36. {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/cli/commands/skills.py +0 -0
  37. {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/cli/console.py +0 -0
  38. {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/cli/context.py +0 -0
  39. {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/cli/credentials.py +0 -0
  40. {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/cli/errors.py +0 -0
  41. {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/cli/main.py +0 -0
  42. {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/cli/oauth.py +0 -0
  43. {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/errors.py +0 -0
  44. {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/oauth.py +0 -0
  45. {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/py.typed +0 -0
  46. {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/resources/__init__.py +0 -0
  47. {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/resources/artifacts.py +0 -0
  48. {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/resources/brain.py +0 -0
  49. {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/resources/chat.py +0 -0
  50. {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/resources/mcp.py +0 -0
  51. {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/resources/sessions.py +0 -0
  52. {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/resources/skills.py +0 -0
  53. {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/resources/teams.py +0 -0
  54. {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/spec/__init__.py +0 -0
  55. {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/spec/_compat.py +0 -0
  56. {gumloop-0.3.3 → gumloop-0.3.5}/src/gumloop/spec/_extensions.py +0 -0
  57. {gumloop-0.3.3 → gumloop-0.3.5}/tests/__init__.py +0 -0
  58. {gumloop-0.3.3 → gumloop-0.3.5}/tests/cli/__init__.py +0 -0
  59. {gumloop-0.3.3 → gumloop-0.3.5}/tests/cli/conftest.py +0 -0
  60. {gumloop-0.3.3 → gumloop-0.3.5}/tests/cli/test_args_input.py +0 -0
  61. {gumloop-0.3.3 → gumloop-0.3.5}/tests/cli/test_artifacts.py +0 -0
  62. {gumloop-0.3.3 → gumloop-0.3.5}/tests/cli/test_brain.py +0 -0
  63. {gumloop-0.3.3 → gumloop-0.3.5}/tests/cli/test_chat.py +0 -0
  64. {gumloop-0.3.3 → gumloop-0.3.5}/tests/cli/test_context.py +0 -0
  65. {gumloop-0.3.3 → gumloop-0.3.5}/tests/cli/test_credentials.py +0 -0
  66. {gumloop-0.3.3 → gumloop-0.3.5}/tests/cli/test_login.py +0 -0
  67. {gumloop-0.3.3 → gumloop-0.3.5}/tests/cli/test_main.py +0 -0
  68. {gumloop-0.3.3 → gumloop-0.3.5}/tests/cli/test_mcp.py +0 -0
  69. {gumloop-0.3.3 → gumloop-0.3.5}/tests/cli/test_sessions.py +0 -0
  70. {gumloop-0.3.3 → gumloop-0.3.5}/tests/cli/test_skills.py +0 -0
  71. {gumloop-0.3.3 → gumloop-0.3.5}/tests/conftest.py +0 -0
  72. {gumloop-0.3.3 → gumloop-0.3.5}/tests/sdk/__init__.py +0 -0
  73. {gumloop-0.3.3 → gumloop-0.3.5}/tests/sdk/conftest.py +0 -0
  74. {gumloop-0.3.3 → gumloop-0.3.5}/tests/sdk/helpers.py +0 -0
  75. {gumloop-0.3.3 → gumloop-0.3.5}/tests/sdk/test_artifacts.py +0 -0
  76. {gumloop-0.3.3 → gumloop-0.3.5}/tests/sdk/test_brain.py +0 -0
  77. {gumloop-0.3.3 → gumloop-0.3.5}/tests/sdk/test_chat.py +0 -0
  78. {gumloop-0.3.3 → gumloop-0.3.5}/tests/sdk/test_mcp.py +0 -0
  79. {gumloop-0.3.3 → gumloop-0.3.5}/tests/sdk/test_oauth.py +0 -0
  80. {gumloop-0.3.3 → gumloop-0.3.5}/tests/sdk/test_sessions.py +0 -0
  81. {gumloop-0.3.3 → gumloop-0.3.5}/tests/sdk/test_skills.py +0 -0
  82. {gumloop-0.3.3 → gumloop-0.3.5}/tests/sdk/test_teams.py +0 -0
  83. {gumloop-0.3.3 → gumloop-0.3.5}/tests/test_client.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gumloop
3
- Version: 0.3.3
3
+ Version: 0.3.5
4
4
  Summary: Python client for the Gumloop API
5
5
  Project-URL: Homepage, https://github.com/gumloop/gumloop-py
6
6
  Project-URL: Bug Tracker, https://github.com/gumloop/gumloop-py/issues
@@ -12,6 +12,7 @@ Classifier: Programming Language :: Python :: 3.11
12
12
  Classifier: Programming Language :: Python :: 3.12
13
13
  Classifier: Programming Language :: Python :: 3.13
14
14
  Requires-Python: >=3.10
15
+ Requires-Dist: click>=8.0
15
16
  Requires-Dist: httpx-sse>=0.4.0
16
17
  Requires-Dist: httpx>=0.24.1
17
18
  Requires-Dist: keyring>=24
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "gumloop"
7
- version = "0.3.3"
7
+ version = "0.3.5"
8
8
  description = "Python client for the Gumloop API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -28,6 +28,7 @@ dependencies = [
28
28
  "pydantic>=2.0.0",
29
29
  "questionary>=2.0",
30
30
  "rich",
31
+ "click>=8.0",
31
32
  "typer>=0.25.1",
32
33
  ]
33
34
 
@@ -6,7 +6,7 @@ from gumloop.errors import AuthenticationError
6
6
  from gumloop.errors import GumloopError
7
7
  from gumloop.oauth import OAuth
8
8
 
9
- __version__ = "0.3.3"
9
+ __version__ = "0.3.5"
10
10
  __all__ = [
11
11
  "APIStatusError",
12
12
  "AsyncGumloop",
@@ -0,0 +1,35 @@
1
+ """Credential resolution shared by the sync and async transports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ from gumloop.errors import AuthenticationError
8
+
9
+
10
+ class EnvToken(str):
11
+ """Access token sourced from ``GUMLOOP_ACCESS_TOKEN``.
12
+
13
+ Stays live: ``current()`` returns the env var's present value, so
14
+ platform-rotated tokens (e.g. per-call sandbox credentials) are picked up
15
+ on every request. Explicit tokens are plain strings and never change.
16
+ """
17
+
18
+ @classmethod
19
+ def from_env(cls) -> EnvToken | None:
20
+ token = os.environ.get("GUMLOOP_ACCESS_TOKEN")
21
+ return cls(token) if token else None
22
+
23
+ def current(self) -> str:
24
+ return os.environ.get("GUMLOOP_ACCESS_TOKEN") or str(self)
25
+
26
+
27
+ def auth_headers(access_token: str | None, user_id: str | None) -> dict[str, str]:
28
+ if isinstance(access_token, EnvToken):
29
+ access_token = access_token.current()
30
+ if not access_token:
31
+ raise AuthenticationError("access_token, api_key, or GUMLOOP_ACCESS_TOKEN is required")
32
+ headers = {"Authorization": f"Bearer {access_token}"}
33
+ if user_id:
34
+ headers["x-auth-key"] = user_id
35
+ return headers
@@ -7,6 +7,7 @@ from typing import Any
7
7
 
8
8
  import httpx
9
9
 
10
+ from gumloop._auth import EnvToken
10
11
  from gumloop._http import AsyncHttpClient
11
12
  from gumloop._http import HttpClient
12
13
  from gumloop.oauth import OAuth
@@ -59,7 +60,7 @@ class Gumloop:
59
60
  stream_timeout: float | None = DEFAULT_STREAM_TIMEOUT,
60
61
  ) -> None:
61
62
  self.api_key = api_key
62
- self.access_token = access_token or api_key or os.environ.get("GUMLOOP_ACCESS_TOKEN")
63
+ self.access_token = access_token or api_key or EnvToken.from_env()
63
64
  # Personal Gumloop API keys are user-scoped; the backend uses the
64
65
  # x-auth-key header to look up the owner's stored secret. OAuth
65
66
  # callers omit it because the bearer token already identifies them.
@@ -115,7 +116,7 @@ class AsyncGumloop:
115
116
  stream_timeout: float | None = DEFAULT_STREAM_TIMEOUT,
116
117
  ) -> None:
117
118
  self.api_key = api_key
118
- self.access_token = access_token or api_key or os.environ.get("GUMLOOP_ACCESS_TOKEN")
119
+ self.access_token = access_token or api_key or EnvToken.from_env()
119
120
  self.user_id = user_id or os.environ.get("GUMLOOP_USER_ID")
120
121
  # Defaults to production; GUMLOOP_BASE_URL is an optional override.
121
122
  self.base_url = (base_url or os.environ.get("GUMLOOP_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
@@ -13,7 +13,7 @@ from httpx_sse import ServerSentEvent
13
13
  from pydantic import BaseModel
14
14
  from pydantic import ValidationError
15
15
 
16
- from gumloop.errors import AuthenticationError
16
+ from gumloop._auth import auth_headers
17
17
  from gumloop.errors import to_api_error
18
18
  from gumloop.types import StreamEvent
19
19
 
@@ -23,15 +23,6 @@ _DONE_SENTINEL = "[DONE]"
23
23
  _T = TypeVar("_T", bound=BaseModel)
24
24
 
25
25
 
26
- def _auth_headers(access_token: str | None, user_id: str | None) -> dict[str, str]:
27
- if not access_token:
28
- raise AuthenticationError("access_token, api_key, or GUMLOOP_ACCESS_TOKEN is required")
29
- headers = {"Authorization": f"Bearer {access_token}"}
30
- if user_id:
31
- headers["x-auth-key"] = user_id
32
- return headers
33
-
34
-
35
26
  def _omit_none_params(params: Mapping[str, Any] | None) -> dict[str, Any] | None:
36
27
  # Backend treats absent ``?foo`` and ``?foo=`` as different signals
37
28
  # ("not provided" vs "empty string"); drop None values so the wire URL
@@ -110,6 +101,9 @@ class HttpClient:
110
101
  ) -> Any:
111
102
  return self._request("PATCH", path, json=json, data=_omit_none_params(data), files=files)
112
103
 
104
+ def put(self, path: str, *, json: Any = None) -> Any:
105
+ return self._request("PUT", path, json=json)
106
+
113
107
  def delete(self, path: str) -> Any:
114
108
  return self._request("DELETE", path)
115
109
 
@@ -117,7 +111,7 @@ class HttpClient:
117
111
  # Endpoints whose streaming variant lives on the stream host (e.g. chat
118
112
  # completions) must accept their unary counterparts at the same host —
119
113
  # the api host has no handler for them.
120
- headers = _auth_headers(self.access_token, self.user_id)
114
+ headers = auth_headers(self.access_token, self.user_id)
121
115
  headers["Content-Type"] = "application/json"
122
116
  response = self._client.post(
123
117
  f"{self._stream_base_url}/{path.lstrip('/')}",
@@ -137,7 +131,7 @@ class HttpClient:
137
131
  json: Any = None,
138
132
  params: Mapping[str, Any] | None = None,
139
133
  ) -> Iterator[StreamEvent]:
140
- headers = {**_auth_headers(self.access_token, self.user_id), "Accept": "text/event-stream"}
134
+ headers = {**auth_headers(self.access_token, self.user_id), "Accept": "text/event-stream"}
141
135
  with self._client.stream(
142
136
  method,
143
137
  f"{self._stream_base_url}/{path.lstrip('/')}",
@@ -163,7 +157,7 @@ class HttpClient:
163
157
  ) -> Iterator[_T]:
164
158
  # Skips the StreamEvent envelope and honors OpenRouter's `data: [DONE]`
165
159
  # terminator. Unparseable events (keep-alives, comments) are skipped.
166
- headers = {**_auth_headers(self.access_token, self.user_id), "Accept": "text/event-stream"}
160
+ headers = {**auth_headers(self.access_token, self.user_id), "Accept": "text/event-stream"}
167
161
  with self._client.stream(
168
162
  method,
169
163
  f"{self._stream_base_url}/{path.lstrip('/')}",
@@ -191,7 +185,7 @@ class HttpClient:
191
185
  def _request(self, method: str, path: str, **kwargs: Any) -> Any:
192
186
  # Headers are rebuilt per request so ``access_token`` / ``user_id``
193
187
  # can be rotated on a live client without reconstructing it.
194
- headers = _auth_headers(self.access_token, self.user_id)
188
+ headers = auth_headers(self.access_token, self.user_id)
195
189
  if not kwargs.get("files"):
196
190
  headers["Content-Type"] = "application/json"
197
191
  response = self._client.request(method, path, headers=headers, **kwargs)
@@ -251,11 +245,14 @@ class AsyncHttpClient:
251
245
  ) -> Any:
252
246
  return await self._request("PATCH", path, json=json, data=_omit_none_params(data), files=files)
253
247
 
248
+ async def put(self, path: str, *, json: Any = None) -> Any:
249
+ return await self._request("PUT", path, json=json)
250
+
254
251
  async def delete(self, path: str) -> Any:
255
252
  return await self._request("DELETE", path)
256
253
 
257
254
  async def post_to_stream_host(self, path: str, *, json: Any = None) -> Any:
258
- headers = _auth_headers(self.access_token, self.user_id)
255
+ headers = auth_headers(self.access_token, self.user_id)
259
256
  headers["Content-Type"] = "application/json"
260
257
  response = await self._client.post(
261
258
  f"{self._stream_base_url}/{path.lstrip('/')}",
@@ -275,7 +272,7 @@ class AsyncHttpClient:
275
272
  json: Any = None,
276
273
  params: Mapping[str, Any] | None = None,
277
274
  ) -> AsyncIterator[StreamEvent]:
278
- headers = {**_auth_headers(self.access_token, self.user_id), "Accept": "text/event-stream"}
275
+ headers = {**auth_headers(self.access_token, self.user_id), "Accept": "text/event-stream"}
279
276
  async with self._client.stream(
280
277
  method,
281
278
  f"{self._stream_base_url}/{path.lstrip('/')}",
@@ -299,7 +296,7 @@ class AsyncHttpClient:
299
296
  json: Any = None,
300
297
  params: Mapping[str, Any] | None = None,
301
298
  ) -> AsyncIterator[_T]:
302
- headers = {**_auth_headers(self.access_token, self.user_id), "Accept": "text/event-stream"}
299
+ headers = {**auth_headers(self.access_token, self.user_id), "Accept": "text/event-stream"}
303
300
  async with self._client.stream(
304
301
  method,
305
302
  f"{self._stream_base_url}/{path.lstrip('/')}",
@@ -325,7 +322,7 @@ class AsyncHttpClient:
325
322
  continue
326
323
 
327
324
  async def _request(self, method: str, path: str, **kwargs: Any) -> Any:
328
- headers = _auth_headers(self.access_token, self.user_id)
325
+ headers = auth_headers(self.access_token, self.user_id)
329
326
  if not kwargs.get("files"):
330
327
  headers["Content-Type"] = "application/json"
331
328
  response = await self._client.request(method, path, headers=headers, **kwargs)
@@ -144,6 +144,10 @@ def create_agent(
144
144
  str | None,
145
145
  typer.Option("--tools-file", help="Path to a JSON file containing the tools array."),
146
146
  ] = None,
147
+ skill_ids: Annotated[
148
+ str | None,
149
+ typer.Option("--skill-ids", help="Comma-separated ids of existing skills to attach."),
150
+ ] = None,
147
151
  json_output: Annotated[
148
152
  bool,
149
153
  typer.Option("--json", help="Print the raw SDK response as JSON."),
@@ -186,6 +190,12 @@ def create_agent(
186
190
  raise GumloopError("Tools JSON must be an array at the top level.")
187
191
  tools = parsed
188
192
 
193
+ parsed_skill_ids = (
194
+ [skill_id.strip() for skill_id in skill_ids.split(",") if skill_id.strip()]
195
+ if skill_ids is not None
196
+ else None
197
+ )
198
+
189
199
  response = cli.call_with_refresh(
190
200
  lambda client: client.agents.create(
191
201
  name=name,
@@ -193,6 +203,7 @@ def create_agent(
193
203
  description=description,
194
204
  system_prompt=resolved_prompt,
195
205
  tools=tools,
206
+ skill_ids=parsed_skill_ids,
196
207
  team_id=cli.effective_team_id,
197
208
  )
198
209
  )
@@ -233,11 +244,23 @@ def update_agent(
233
244
  ] = None,
234
245
  tools_json: Annotated[
235
246
  str | None,
236
- typer.Option("--tools-json", help="Inline JSON array of tool config objects."),
247
+ typer.Option(
248
+ "--tools-json",
249
+ help=(
250
+ "Inline JSON array of tool config objects."
251
+ " (legacy: replaces the entire tools list; prefer attach-mcp-server/detach-mcp-server)"
252
+ ),
253
+ ),
237
254
  ] = None,
238
255
  tools_file: Annotated[
239
256
  str | None,
240
- typer.Option("--tools-file", help="Path to a JSON file containing the tools array."),
257
+ typer.Option(
258
+ "--tools-file",
259
+ help=(
260
+ "Path to a JSON file containing the tools array."
261
+ " (legacy: replaces the entire tools list; prefer attach-mcp-server/detach-mcp-server)"
262
+ ),
263
+ ),
241
264
  ] = None,
242
265
  is_active: Annotated[
243
266
  bool | None,
@@ -303,3 +326,125 @@ def update_agent(
303
326
  return
304
327
 
305
328
  console.print(f"[green]Updated agent[/green] {agent_id}")
329
+
330
+
331
+ @agents_app.command(
332
+ "attach-skills",
333
+ epilog="Example:\n gumloop agents attach-skills agent_abc skill_1 skill_2",
334
+ )
335
+ def attach_skills(
336
+ ctx: typer.Context,
337
+ agent_id: Annotated[str, typer.Argument(help="ID of the agent to attach skills to.")],
338
+ skill_ids: Annotated[list[str], typer.Argument(help="One or more skill ids to attach.")],
339
+ json_output: Annotated[
340
+ bool,
341
+ typer.Option("--json", help="Print the raw SDK response as JSON."),
342
+ ] = False,
343
+ ) -> None:
344
+ """Attach one or more skills to an agent (idempotent)."""
345
+ cli: CliContext = ctx.obj
346
+ try:
347
+ response = cli.call_with_refresh(lambda client: client.agents.attach_skills(agent_id, skill_ids))
348
+ except GumloopError as error:
349
+ exit_with_error(error, json_output=json_output)
350
+
351
+ if json_output:
352
+ print_json(response)
353
+ return
354
+
355
+ console.print(f"[green]Attached[/green] {len(response.attached)} skill(s) to {agent_id}")
356
+ if response.already_attached:
357
+ console.print(f" Already attached: {', '.join(response.already_attached)}", markup=False, highlight=False)
358
+
359
+
360
+ @agents_app.command(
361
+ "detach-skills",
362
+ epilog="Example:\n gumloop agents detach-skills agent_abc skill_1 skill_2",
363
+ )
364
+ def detach_skills(
365
+ ctx: typer.Context,
366
+ agent_id: Annotated[str, typer.Argument(help="ID of the agent to detach skills from.")],
367
+ skill_ids: Annotated[list[str], typer.Argument(help="One or more skill ids to detach.")],
368
+ json_output: Annotated[
369
+ bool,
370
+ typer.Option("--json", help="Print the raw SDK response as JSON."),
371
+ ] = False,
372
+ ) -> None:
373
+ """Detach one or more skills from an agent (idempotent)."""
374
+ cli: CliContext = ctx.obj
375
+ try:
376
+ response = cli.call_with_refresh(lambda client: client.agents.detach_skills(agent_id, skill_ids))
377
+ except GumloopError as error:
378
+ exit_with_error(error, json_output=json_output)
379
+
380
+ if json_output:
381
+ print_json(response)
382
+ return
383
+
384
+ console.print(f"[green]Detached[/green] {len(response.detached)} skill(s) from {agent_id}")
385
+ if response.already_detached:
386
+ console.print(f" Already detached: {', '.join(response.already_detached)}", markup=False, highlight=False)
387
+
388
+
389
+ @agents_app.command(
390
+ "attach-mcp-server",
391
+ epilog="Example:\n gumloop agents attach-mcp-server agent_abc gmail --approval-mode off",
392
+ )
393
+ def attach_mcp_server(
394
+ ctx: typer.Context,
395
+ agent_id: Annotated[str, typer.Argument(help="ID of the agent to attach the MCP server to.")],
396
+ server_id: Annotated[str, typer.Argument(help="ID of the MCP server to attach.")],
397
+ approval_mode: Annotated[
398
+ str | None,
399
+ typer.Option("--approval-mode", help="Tool-call approval mode for this server."),
400
+ ] = None,
401
+ json_output: Annotated[
402
+ bool,
403
+ typer.Option("--json", help="Print the raw SDK response as JSON."),
404
+ ] = False,
405
+ ) -> None:
406
+ """Attach an MCP server to an agent, or update its config (idempotent upsert)."""
407
+ cli: CliContext = ctx.obj
408
+ try:
409
+ response = cli.call_with_refresh(
410
+ lambda client: client.agents.attach_mcp_server(agent_id, server_id, approval_mode=approval_mode)
411
+ )
412
+ except GumloopError as error:
413
+ exit_with_error(error, json_output=json_output)
414
+
415
+ if json_output:
416
+ print_json(response)
417
+ return
418
+
419
+ verb = "Attached" if response.created else "Updated"
420
+ console.print(f"[green]{verb}[/green] MCP server {escape_markup(server_id)} on {agent_id}")
421
+ if response.auth_status:
422
+ console.print(f" Auth status: {response.auth_status}", markup=False, highlight=False)
423
+
424
+
425
+ @agents_app.command(
426
+ "detach-mcp-server",
427
+ epilog="Example:\n gumloop agents detach-mcp-server agent_abc gmail",
428
+ )
429
+ def detach_mcp_server(
430
+ ctx: typer.Context,
431
+ agent_id: Annotated[str, typer.Argument(help="ID of the agent to detach the MCP server from.")],
432
+ server_id: Annotated[str, typer.Argument(help="ID of the MCP server to detach.")],
433
+ json_output: Annotated[
434
+ bool,
435
+ typer.Option("--json", help="Print the raw SDK response as JSON."),
436
+ ] = False,
437
+ ) -> None:
438
+ """Detach an MCP server from an agent (idempotent)."""
439
+ cli: CliContext = ctx.obj
440
+ try:
441
+ response = cli.call_with_refresh(lambda client: client.agents.detach_mcp_server(agent_id, server_id))
442
+ except GumloopError as error:
443
+ exit_with_error(error, json_output=json_output)
444
+
445
+ if json_output:
446
+ print_json(response)
447
+ return
448
+
449
+ console.print(f"[green]Detached[/green] MCP server {escape_markup(server_id)} from {agent_id}")
450
+ console.print(f" detached: {'true' if response.detached else 'false'}", markup=False, highlight=False)
@@ -1,19 +1,29 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from collections.abc import Mapping
4
+ from collections.abc import Sequence
4
5
  from typing import Any
5
6
 
6
7
  from gumloop._http import AsyncHttpClient
7
8
  from gumloop._http import HttpClient
8
9
  from gumloop.types import AgentCreateRequest
9
10
  from gumloop.types import AgentListResponse
11
+ from gumloop.types import AgentMcpServerDetachResponse
12
+ from gumloop.types import AgentMcpServerResponse
13
+ from gumloop.types import AgentMcpServersResponse
10
14
  from gumloop.types import AgentResponse
15
+ from gumloop.types import AgentSkillsResponse
11
16
  from gumloop.types import AgentUpdateRequest
12
17
  from gumloop.types import EvaluationConfigResponse
13
18
  from gumloop.types import EvaluationConfigUpdateRequest
14
19
  from gumloop.types import EvaluationResultListResponse
15
20
  from gumloop.types import EvaluationResultResponse
16
21
  from gumloop.types import ModelListResponse
22
+ from gumloop.types import SkillListResponse
23
+
24
+
25
+ def _skill_id_list(skill_ids: str | Sequence[str]) -> list[str]:
26
+ return [skill_ids] if isinstance(skill_ids, str) else list(skill_ids)
17
27
 
18
28
 
19
29
  class Agents:
@@ -49,10 +59,48 @@ class Agents:
49
59
  request: AgentUpdateRequest | Mapping[str, Any] | None = None,
50
60
  **kwargs: Any,
51
61
  ) -> AgentResponse:
62
+ """``tools`` replaces the entire list when provided (legacy bulk path); prefer
63
+ attach_mcp_server/detach_mcp_server for individual MCP servers and
64
+ attach_skills/detach_skills for skills."""
52
65
  return AgentResponse.model_validate(
53
66
  self._client.patch(f"agents/{agent_id}", json=AgentUpdateRequest.build(request, **kwargs))
54
67
  )
55
68
 
69
+ def attach_skills(self, agent_id: str, skill_ids: str | Sequence[str]) -> AgentSkillsResponse:
70
+ """Attach skills to an agent. Idempotent: safe to retry, already-attached ids are reported."""
71
+ return AgentSkillsResponse.model_validate(
72
+ self._client.patch(f"agents/{agent_id}/skills", json={"attach": _skill_id_list(skill_ids)})
73
+ )
74
+
75
+ def detach_skills(self, agent_id: str, skill_ids: str | Sequence[str]) -> AgentSkillsResponse:
76
+ """Detach skills from an agent. Idempotent: safe to retry, already-detached ids are reported."""
77
+ return AgentSkillsResponse.model_validate(
78
+ self._client.patch(f"agents/{agent_id}/skills", json={"detach": _skill_id_list(skill_ids)})
79
+ )
80
+
81
+ def list_skills(self, agent_id: str, **kwargs: Any) -> SkillListResponse:
82
+ return SkillListResponse.model_validate(
83
+ self._client.get("skills", params={"agent_id": agent_id, **kwargs})
84
+ )
85
+
86
+ def attach_mcp_server(self, agent_id: str, server_id: str, **config: Any) -> AgentMcpServerResponse:
87
+ """Attach an MCP server, or update its config if already attached (idempotent upsert)."""
88
+ return AgentMcpServerResponse.model_validate(
89
+ self._client.put(
90
+ f"agents/{agent_id}/mcp-servers/{server_id}",
91
+ json={k: v for k, v in config.items() if v is not None},
92
+ )
93
+ )
94
+
95
+ def detach_mcp_server(self, agent_id: str, server_id: str) -> AgentMcpServerDetachResponse:
96
+ """Detach an MCP server from an agent. Idempotent: safe to retry."""
97
+ return AgentMcpServerDetachResponse.model_validate(
98
+ self._client.delete(f"agents/{agent_id}/mcp-servers/{server_id}")
99
+ )
100
+
101
+ def list_mcp_servers(self, agent_id: str) -> AgentMcpServersResponse:
102
+ return AgentMcpServersResponse.model_validate(self._client.get(f"agents/{agent_id}/mcp-servers"))
103
+
56
104
  def get_evaluation_config(self, agent_id: str) -> EvaluationConfigResponse:
57
105
  return EvaluationConfigResponse.model_validate(self._client.get(f"agents/{agent_id}/evaluation-config"))
58
106
 
@@ -133,9 +181,43 @@ class AsyncAgents:
133
181
  request: AgentUpdateRequest | Mapping[str, Any] | None = None,
134
182
  **kwargs: Any,
135
183
  ) -> AgentResponse:
184
+ """``tools`` replaces the entire list when provided (legacy bulk path); prefer
185
+ attach_mcp_server/detach_mcp_server for individual MCP servers and
186
+ attach_skills/detach_skills for skills."""
136
187
  data = await self._client.patch(f"agents/{agent_id}", json=AgentUpdateRequest.build(request, **kwargs))
137
188
  return AgentResponse.model_validate(data)
138
189
 
190
+ async def attach_skills(self, agent_id: str, skill_ids: str | Sequence[str]) -> AgentSkillsResponse:
191
+ """Attach skills to an agent. Idempotent: safe to retry, already-attached ids are reported."""
192
+ data = await self._client.patch(f"agents/{agent_id}/skills", json={"attach": _skill_id_list(skill_ids)})
193
+ return AgentSkillsResponse.model_validate(data)
194
+
195
+ async def detach_skills(self, agent_id: str, skill_ids: str | Sequence[str]) -> AgentSkillsResponse:
196
+ """Detach skills from an agent. Idempotent: safe to retry, already-detached ids are reported."""
197
+ data = await self._client.patch(f"agents/{agent_id}/skills", json={"detach": _skill_id_list(skill_ids)})
198
+ return AgentSkillsResponse.model_validate(data)
199
+
200
+ async def list_skills(self, agent_id: str, **kwargs: Any) -> SkillListResponse:
201
+ data = await self._client.get("skills", params={"agent_id": agent_id, **kwargs})
202
+ return SkillListResponse.model_validate(data)
203
+
204
+ async def attach_mcp_server(self, agent_id: str, server_id: str, **config: Any) -> AgentMcpServerResponse:
205
+ """Attach an MCP server, or update its config if already attached (idempotent upsert)."""
206
+ data = await self._client.put(
207
+ f"agents/{agent_id}/mcp-servers/{server_id}",
208
+ json={k: v for k, v in config.items() if v is not None},
209
+ )
210
+ return AgentMcpServerResponse.model_validate(data)
211
+
212
+ async def detach_mcp_server(self, agent_id: str, server_id: str) -> AgentMcpServerDetachResponse:
213
+ """Detach an MCP server from an agent. Idempotent: safe to retry."""
214
+ data = await self._client.delete(f"agents/{agent_id}/mcp-servers/{server_id}")
215
+ return AgentMcpServerDetachResponse.model_validate(data)
216
+
217
+ async def list_mcp_servers(self, agent_id: str) -> AgentMcpServersResponse:
218
+ data = await self._client.get(f"agents/{agent_id}/mcp-servers")
219
+ return AgentMcpServersResponse.model_validate(data)
220
+
139
221
  async def get_evaluation_config(self, agent_id: str) -> EvaluationConfigResponse:
140
222
  data = await self._client.get(f"agents/{agent_id}/evaluation-config")
141
223
  return EvaluationConfigResponse.model_validate(data)
@@ -53,6 +53,7 @@ class AgentCreateRequest(_Model):
53
53
  system_prompt: str | None = None
54
54
  tools: list[dict[str, Any]] = Field(default_factory=list)
55
55
  resources: list[dict[str, Any]] = Field(default_factory=list)
56
+ skill_ids: list[str] | None = None
56
57
  metadata: dict[str, Any] | None = None
57
58
  folder_id: str | None = None
58
59
  is_active: bool = True
@@ -80,6 +81,7 @@ class Agent(_Model):
80
81
  is_active: bool = False
81
82
  tools: list[dict[str, Any]] = Field(default_factory=list)
82
83
  resources: list[dict[str, Any]] = Field(default_factory=list)
84
+ skill_ids: list[str] | None = None
83
85
  metadata: dict[str, Any] = Field(default_factory=dict)
84
86
  model_name: str | None = None
85
87
  system_prompt: str | None = None
@@ -90,6 +92,33 @@ class Agent(_Model):
90
92
  creator: CreatorPayload | None = None
91
93
 
92
94
 
95
+ class AgentSkillsResponse(_Model):
96
+ agent_id: str
97
+ skill_ids: list[str] = Field(default_factory=list)
98
+ attached: list[str] = Field(default_factory=list)
99
+ detached: list[str] = Field(default_factory=list)
100
+ already_attached: list[str] = Field(default_factory=list)
101
+ already_detached: list[str] = Field(default_factory=list)
102
+
103
+
104
+ class AgentMcpServerResponse(_Model):
105
+ agent_id: str
106
+ mcp_server: dict[str, Any] = Field(default_factory=dict)
107
+ created: bool = False
108
+ auth_status: str | None = None
109
+
110
+
111
+ class AgentMcpServerDetachResponse(_Model):
112
+ agent_id: str
113
+ server_id: str
114
+ detached: bool = False
115
+
116
+
117
+ class AgentMcpServersResponse(_Model):
118
+ agent_id: str
119
+ mcp_servers: list[dict[str, Any]] = Field(default_factory=list)
120
+
121
+
93
122
  class AgentResponse(_Model):
94
123
  agent: Agent
95
124
 
@@ -123,6 +123,73 @@ def test_agents_update_only_sends_specified_fields(cli_runner: CliRunner) -> Non
123
123
  assert sent == {"name": "Renamed", "is_active": False}
124
124
 
125
125
 
126
+ @respx.mock
127
+ def test_agents_attach_skills_command(cli_runner: CliRunner) -> None:
128
+ route = respx.patch(f"{API_BASE}/agents/agent_abc/skills").mock(
129
+ return_value=httpx.Response(200, json={"agent_id": "agent_abc", "attached": ["s1", "s2"]})
130
+ )
131
+ save_credentials(Credentials(api_key="key"))
132
+
133
+ result = cli_runner.invoke(app, ["agents", "attach-skills", "agent_abc", "s1", "s2"])
134
+
135
+ assert result.exit_code == 0, result.output
136
+ assert json.loads(route.calls[0].request.content) == {"attach": ["s1", "s2"]}
137
+
138
+
139
+ @respx.mock
140
+ def test_agents_detach_skills_command(cli_runner: CliRunner) -> None:
141
+ route = respx.patch(f"{API_BASE}/agents/agent_abc/skills").mock(
142
+ return_value=httpx.Response(200, json={"agent_id": "agent_abc", "detached": ["s1"]})
143
+ )
144
+ save_credentials(Credentials(api_key="key"))
145
+
146
+ result = cli_runner.invoke(app, ["agents", "detach-skills", "agent_abc", "s1"])
147
+
148
+ assert result.exit_code == 0, result.output
149
+ assert json.loads(route.calls[0].request.content) == {"detach": ["s1"]}
150
+
151
+
152
+ @respx.mock
153
+ def test_agents_attach_mcp_server_command(cli_runner: CliRunner) -> None:
154
+ route = respx.put(f"{API_BASE}/agents/agent_abc/mcp-servers/gmail").mock(
155
+ return_value=httpx.Response(
156
+ 200, json={"agent_id": "agent_abc", "created": True, "auth_status": "connected"}
157
+ )
158
+ )
159
+ save_credentials(Credentials(api_key="key"))
160
+
161
+ result = cli_runner.invoke(
162
+ app, ["agents", "attach-mcp-server", "agent_abc", "gmail", "--approval-mode", "off"]
163
+ )
164
+
165
+ assert result.exit_code == 0, result.output
166
+ assert json.loads(route.calls[0].request.content) == {"approval_mode": "off"}
167
+ assert "connected" in result.output
168
+
169
+
170
+ @respx.mock
171
+ def test_agents_detach_mcp_server_command(cli_runner: CliRunner) -> None:
172
+ route = respx.delete(f"{API_BASE}/agents/agent_abc/mcp-servers/gmail").mock(
173
+ return_value=httpx.Response(200, json={"agent_id": "agent_abc", "server_id": "gmail", "detached": True})
174
+ )
175
+ save_credentials(Credentials(api_key="key"))
176
+
177
+ result = cli_runner.invoke(app, ["agents", "detach-mcp-server", "agent_abc", "gmail"])
178
+
179
+ assert result.exit_code == 0, result.output
180
+ assert route.call_count == 1
181
+ assert "true" in result.output
182
+
183
+
184
+ @respx.mock
185
+ def test_agents_update_rejects_skill_ids_option(cli_runner: CliRunner) -> None:
186
+ save_credentials(Credentials(api_key="key"))
187
+
188
+ result = cli_runner.invoke(app, ["agents", "update", "agent_abc", "--skill-ids", "s1"])
189
+
190
+ assert result.exit_code != 0
191
+
192
+
126
193
  @respx.mock
127
194
  def test_agents_create_uses_effective_team_id(cli_runner: CliRunner, monkeypatch) -> None:
128
195
  route = respx.post(f"{API_BASE}/agents").mock(