gumloop 0.3.4__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 (155) hide show
  1. {gumloop-0.3.4 → gumloop-0.4.0}/PKG-INFO +1 -1
  2. {gumloop-0.3.4 → gumloop-0.4.0}/pyproject.toml +2 -1
  3. {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/__init__.py +2 -1
  4. gumloop-0.4.0/src/gumloop/_auth.py +35 -0
  5. {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/_client.py +5 -2
  6. {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/_http.py +55 -18
  7. gumloop-0.4.0/src/gumloop/_version.py +3 -0
  8. gumloop-0.4.0/src/gumloop/cli/commands/_sync_output.py +328 -0
  9. gumloop-0.4.0/src/gumloop/cli/commands/sync.py +335 -0
  10. {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/cli/main.py +2 -0
  11. {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/resources/__init__.py +2 -0
  12. gumloop-0.4.0/src/gumloop/resources/sync.py +94 -0
  13. gumloop-0.4.0/src/gumloop/sync/__init__.py +1 -0
  14. gumloop-0.4.0/src/gumloop/sync/archive.py +131 -0
  15. gumloop-0.4.0/src/gumloop/sync/bundle.py +71 -0
  16. gumloop-0.4.0/src/gumloop/sync/bundle_validation.py +121 -0
  17. gumloop-0.4.0/src/gumloop/sync/errors.py +20 -0
  18. gumloop-0.4.0/src/gumloop/sync/hashing.py +65 -0
  19. gumloop-0.4.0/src/gumloop/sync/installed.py +141 -0
  20. gumloop-0.4.0/src/gumloop/sync/local_state.py +161 -0
  21. gumloop-0.4.0/src/gumloop/sync/lock.py +50 -0
  22. gumloop-0.4.0/src/gumloop/sync/markers.py +159 -0
  23. gumloop-0.4.0/src/gumloop/sync/reconcile.py +219 -0
  24. gumloop-0.4.0/src/gumloop/sync/reporting.py +136 -0
  25. gumloop-0.4.0/src/gumloop/sync/results.py +67 -0
  26. gumloop-0.4.0/src/gumloop/sync/run.py +556 -0
  27. gumloop-0.4.0/src/gumloop/sync/scheduler.py +294 -0
  28. gumloop-0.4.0/src/gumloop/sync/target_files.py +421 -0
  29. gumloop-0.4.0/src/gumloop/sync/targets.py +63 -0
  30. gumloop-0.4.0/src/gumloop/sync/wire.py +15 -0
  31. {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/types.py +67 -0
  32. gumloop-0.4.0/tests/cli/__init__.py +1 -0
  33. gumloop-0.4.0/tests/cli/conftest.py +116 -0
  34. gumloop-0.4.0/tests/cli/sync_scenario_helpers.py +342 -0
  35. gumloop-0.4.0/tests/cli/sync_test_fakes.py +85 -0
  36. gumloop-0.4.0/tests/cli/test_home_isolation.py +49 -0
  37. gumloop-0.4.0/tests/cli/test_sync_bundle.py +359 -0
  38. gumloop-0.4.0/tests/cli/test_sync_command.py +744 -0
  39. gumloop-0.4.0/tests/cli/test_sync_command_safety.py +371 -0
  40. gumloop-0.4.0/tests/cli/test_sync_contract_fixtures.py +187 -0
  41. gumloop-0.4.0/tests/cli/test_sync_enrollment.py +671 -0
  42. gumloop-0.4.0/tests/cli/test_sync_fixture_corpus.py +32 -0
  43. gumloop-0.4.0/tests/cli/test_sync_hashing.py +65 -0
  44. gumloop-0.4.0/tests/cli/test_sync_installed.py +250 -0
  45. gumloop-0.4.0/tests/cli/test_sync_reconciliation_safety.py +206 -0
  46. gumloop-0.4.0/tests/cli/test_sync_scheduler_macos.py +350 -0
  47. gumloop-0.4.0/tests/cli/test_sync_targets.py +217 -0
  48. gumloop-0.4.0/tests/fixtures/skill_sync/v1/README.md +19 -0
  49. gumloop-0.4.0/tests/fixtures/skill_sync/v1/archives/generated-bundle-transfer-limit.json +10 -0
  50. gumloop-0.4.0/tests/fixtures/skill_sync/v1/archives/invalid-absolute-path.zip +0 -0
  51. gumloop-0.4.0/tests/fixtures/skill_sync/v1/archives/invalid-bytes-per-file-limit.zip +0 -0
  52. gumloop-0.4.0/tests/fixtures/skill_sync/v1/archives/invalid-device.zip +0 -0
  53. gumloop-0.4.0/tests/fixtures/skill_sync/v1/archives/invalid-duplicate-entry.zip +0 -0
  54. gumloop-0.4.0/tests/fixtures/skill_sync/v1/archives/invalid-fifo.zip +0 -0
  55. gumloop-0.4.0/tests/fixtures/skill_sync/v1/archives/invalid-files-per-skill-limit.zip +0 -0
  56. gumloop-0.4.0/tests/fixtures/skill_sync/v1/archives/invalid-nul-name.zip +0 -0
  57. gumloop-0.4.0/tests/fixtures/skill_sync/v1/archives/invalid-parent-traversal.zip +0 -0
  58. gumloop-0.4.0/tests/fixtures/skill_sync/v1/archives/invalid-reserved-marker.zip +0 -0
  59. gumloop-0.4.0/tests/fixtures/skill_sync/v1/archives/invalid-socket.zip +0 -0
  60. gumloop-0.4.0/tests/fixtures/skill_sync/v1/archives/invalid-symlink.zip +0 -0
  61. gumloop-0.4.0/tests/fixtures/skill_sync/v1/archives/invalid-total-uncompressed-limit.zip +0 -0
  62. gumloop-0.4.0/tests/fixtures/skill_sync/v1/archives/invalid-windows-drive-prefix.zip +0 -0
  63. gumloop-0.4.0/tests/fixtures/skill_sync/v1/archives/valid-single-skill-stored.zip +0 -0
  64. gumloop-0.4.0/tests/fixtures/skill_sync/v1/archives/valid-single-skill.zip +0 -0
  65. gumloop-0.4.0/tests/fixtures/skill_sync/v1/bundles/newer-than-plan.zip +0 -0
  66. gumloop-0.4.0/tests/fixtures/skill_sync/v1/bundles/normal.zip +0 -0
  67. gumloop-0.4.0/tests/fixtures/skill_sync/v1/catalog.json +179 -0
  68. gumloop-0.4.0/tests/fixtures/skill_sync/v1/corpus.sha256 +1 -0
  69. gumloop-0.4.0/tests/fixtures/skill_sync/v1/hashes/content-v1.json +331 -0
  70. gumloop-0.4.0/tests/fixtures/skill_sync/v1/hashes/manifest-v1.json +130 -0
  71. gumloop-0.4.0/tests/fixtures/skill_sync/v1/manifests/invalid-duplicate-identity.json +32 -0
  72. gumloop-0.4.0/tests/fixtures/skill_sync/v1/manifests/invalid-duplicate-install-name.json +32 -0
  73. gumloop-0.4.0/tests/fixtures/skill_sync/v1/manifests/invalid-manifest-hash.json +26 -0
  74. gumloop-0.4.0/tests/fixtures/skill_sync/v1/manifests/newer-than-plan.json +26 -0
  75. gumloop-0.4.0/tests/fixtures/skill_sync/v1/manifests/normal-bundle.json +26 -0
  76. gumloop-0.4.0/tests/fixtures/skill_sync/v1/responses/below-pro.json +9 -0
  77. gumloop-0.4.0/tests/fixtures/skill_sync/v1/responses/cli-upgrade-required.json +12 -0
  78. gumloop-0.4.0/tests/fixtures/skill_sync/v1/responses/lost-membership.json +9 -0
  79. gumloop-0.4.0/tests/fixtures/skill_sync/v1/responses/normal-empty-plan.json +19 -0
  80. gumloop-0.4.0/tests/fixtures/skill_sync/v1/responses/normal-plan.json +19 -0
  81. {gumloop-0.3.4 → gumloop-0.4.0}/tests/sdk/test_client.py +69 -0
  82. gumloop-0.4.0/tests/sdk/test_sync.py +309 -0
  83. gumloop-0.4.0/tests/skill_sync_fixtures.py +35 -0
  84. {gumloop-0.3.4 → gumloop-0.4.0}/uv.lock +1 -1
  85. gumloop-0.3.4/tests/cli/__init__.py +0 -0
  86. gumloop-0.3.4/tests/cli/conftest.py +0 -55
  87. {gumloop-0.3.4 → gumloop-0.4.0}/.env.example +0 -0
  88. {gumloop-0.3.4 → gumloop-0.4.0}/.gitignore +0 -0
  89. {gumloop-0.3.4 → gumloop-0.4.0}/.pre-commit-config.yaml +0 -0
  90. {gumloop-0.3.4 → gumloop-0.4.0}/.python-version +0 -0
  91. {gumloop-0.3.4 → gumloop-0.4.0}/LICENSE +0 -0
  92. {gumloop-0.3.4 → gumloop-0.4.0}/README.md +0 -0
  93. {gumloop-0.3.4 → gumloop-0.4.0}/scripts/check +0 -0
  94. {gumloop-0.3.4 → gumloop-0.4.0}/scripts/fmt +0 -0
  95. {gumloop-0.3.4 → gumloop-0.4.0}/scripts/sdk_examples.py +0 -0
  96. {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/cli/__init__.py +0 -0
  97. {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/cli/commands/__init__.py +0 -0
  98. {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/cli/commands/_args_input.py +0 -0
  99. {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/cli/commands/_downloads.py +0 -0
  100. {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/cli/commands/agents.py +0 -0
  101. {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/cli/commands/artifacts.py +0 -0
  102. {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/cli/commands/auth.py +0 -0
  103. {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/cli/commands/brain.py +0 -0
  104. {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/cli/commands/chat.py +0 -0
  105. {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/cli/commands/mcp.py +0 -0
  106. {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/cli/commands/sessions.py +0 -0
  107. {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/cli/commands/skills.py +0 -0
  108. {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/cli/console.py +0 -0
  109. {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/cli/context.py +0 -0
  110. {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/cli/credentials.py +0 -0
  111. {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/cli/errors.py +0 -0
  112. {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/cli/oauth.py +0 -0
  113. {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/errors.py +0 -0
  114. {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/oauth.py +0 -0
  115. {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/py.typed +0 -0
  116. {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/resources/agents.py +0 -0
  117. {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/resources/artifacts.py +0 -0
  118. {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/resources/brain.py +0 -0
  119. {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/resources/chat.py +0 -0
  120. {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/resources/mcp.py +0 -0
  121. {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/resources/sessions.py +0 -0
  122. {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/resources/skills.py +0 -0
  123. {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/resources/teams.py +0 -0
  124. {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/spec/__init__.py +0 -0
  125. {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/spec/_compat.py +0 -0
  126. {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/spec/_extensions.py +0 -0
  127. {gumloop-0.3.4 → gumloop-0.4.0}/tests/__init__.py +0 -0
  128. {gumloop-0.3.4 → gumloop-0.4.0}/tests/cli/test_agents.py +0 -0
  129. {gumloop-0.3.4 → gumloop-0.4.0}/tests/cli/test_args_input.py +0 -0
  130. {gumloop-0.3.4 → gumloop-0.4.0}/tests/cli/test_artifacts.py +0 -0
  131. {gumloop-0.3.4 → gumloop-0.4.0}/tests/cli/test_brain.py +0 -0
  132. {gumloop-0.3.4 → gumloop-0.4.0}/tests/cli/test_chat.py +0 -0
  133. {gumloop-0.3.4 → gumloop-0.4.0}/tests/cli/test_context.py +0 -0
  134. {gumloop-0.3.4 → gumloop-0.4.0}/tests/cli/test_credentials.py +0 -0
  135. {gumloop-0.3.4 → gumloop-0.4.0}/tests/cli/test_login.py +0 -0
  136. {gumloop-0.3.4 → gumloop-0.4.0}/tests/cli/test_main.py +0 -0
  137. {gumloop-0.3.4 → gumloop-0.4.0}/tests/cli/test_mcp.py +0 -0
  138. {gumloop-0.3.4 → gumloop-0.4.0}/tests/cli/test_sessions.py +0 -0
  139. {gumloop-0.3.4 → gumloop-0.4.0}/tests/cli/test_skills.py +0 -0
  140. {gumloop-0.3.4 → gumloop-0.4.0}/tests/conftest.py +0 -0
  141. {gumloop-0.3.4 → gumloop-0.4.0}/tests/integration/conftest.py +0 -0
  142. {gumloop-0.3.4 → gumloop-0.4.0}/tests/integration/test_live.py +0 -0
  143. {gumloop-0.3.4 → gumloop-0.4.0}/tests/sdk/__init__.py +0 -0
  144. {gumloop-0.3.4 → gumloop-0.4.0}/tests/sdk/conftest.py +0 -0
  145. {gumloop-0.3.4 → gumloop-0.4.0}/tests/sdk/helpers.py +0 -0
  146. {gumloop-0.3.4 → gumloop-0.4.0}/tests/sdk/test_agents.py +0 -0
  147. {gumloop-0.3.4 → gumloop-0.4.0}/tests/sdk/test_artifacts.py +0 -0
  148. {gumloop-0.3.4 → gumloop-0.4.0}/tests/sdk/test_brain.py +0 -0
  149. {gumloop-0.3.4 → gumloop-0.4.0}/tests/sdk/test_chat.py +0 -0
  150. {gumloop-0.3.4 → gumloop-0.4.0}/tests/sdk/test_mcp.py +0 -0
  151. {gumloop-0.3.4 → gumloop-0.4.0}/tests/sdk/test_oauth.py +0 -0
  152. {gumloop-0.3.4 → gumloop-0.4.0}/tests/sdk/test_sessions.py +0 -0
  153. {gumloop-0.3.4 → gumloop-0.4.0}/tests/sdk/test_skills.py +0 -0
  154. {gumloop-0.3.4 → gumloop-0.4.0}/tests/sdk/test_teams.py +0 -0
  155. {gumloop-0.3.4 → gumloop-0.4.0}/tests/test_client.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gumloop
3
- Version: 0.3.4
3
+ Version: 0.4.0
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "gumloop"
7
- version = "0.3.4"
7
+ version = "0.4.0"
8
8
  description = "Python client for the Gumloop API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -60,6 +60,7 @@ build = [
60
60
  testpaths = ["tests"]
61
61
  markers = [
62
62
  "live: hits the real Gumloop API and upstream providers; opt-in via `-m live`",
63
+ "implementation: asserts internals or invariants beyond the public contract",
63
64
  ]
64
65
 
65
66
  [tool.ruff]
@@ -1,12 +1,12 @@
1
1
  from gumloop._client import AsyncGumloop
2
2
  from gumloop._client import Gumloop
3
3
  from gumloop._client import GumloopClient
4
+ from gumloop._version import __version__
4
5
  from gumloop.errors import APIStatusError
5
6
  from gumloop.errors import AuthenticationError
6
7
  from gumloop.errors import GumloopError
7
8
  from gumloop.oauth import OAuth
8
9
 
9
- __version__ = "0.3.3"
10
10
  __all__ = [
11
11
  "APIStatusError",
12
12
  "AsyncGumloop",
@@ -15,4 +15,5 @@ __all__ = [
15
15
  "GumloopClient",
16
16
  "GumloopError",
17
17
  "OAuth",
18
+ "__version__",
18
19
  ]
@@ -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
@@ -27,6 +28,7 @@ from gumloop.resources import Chat
27
28
  from gumloop.resources import Models
28
29
  from gumloop.resources import Sessions
29
30
  from gumloop.resources import Skills
31
+ from gumloop.resources import Sync
30
32
  from gumloop.resources import Teams
31
33
 
32
34
  DEFAULT_BASE_URL = "https://api.gumloop.com/api/v1"
@@ -59,7 +61,7 @@ class Gumloop:
59
61
  stream_timeout: float | None = DEFAULT_STREAM_TIMEOUT,
60
62
  ) -> None:
61
63
  self.api_key = api_key
62
- self.access_token = access_token or api_key or os.environ.get("GUMLOOP_ACCESS_TOKEN")
64
+ self.access_token = access_token or api_key or EnvToken.from_env()
63
65
  # Personal Gumloop API keys are user-scoped; the backend uses the
64
66
  # x-auth-key header to look up the owner's stored secret. OAuth
65
67
  # callers omit it because the bearer token already identifies them.
@@ -86,6 +88,7 @@ class Gumloop:
86
88
  self.mcp = MCP(self._http)
87
89
  self.teams = Teams(self._http)
88
90
  self.skills = Skills(self._http)
91
+ self.sync = Sync(self._http)
89
92
  self.artifacts = Artifacts(self._http)
90
93
  self.brain = Brain(self._http)
91
94
  self.oauth = OAuth(base_url=self.base_url, timeout=self.timeout)
@@ -115,7 +118,7 @@ class AsyncGumloop:
115
118
  stream_timeout: float | None = DEFAULT_STREAM_TIMEOUT,
116
119
  ) -> None:
117
120
  self.api_key = api_key
118
- self.access_token = access_token or api_key or os.environ.get("GUMLOOP_ACCESS_TOKEN")
121
+ self.access_token = access_token or api_key or EnvToken.from_env()
119
122
  self.user_id = user_id or os.environ.get("GUMLOOP_USER_ID")
120
123
  # Defaults to production; GUMLOOP_BASE_URL is an optional override.
121
124
  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,13 +23,8 @@ _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
26
+ class ResponseSizeExceededError(Exception):
27
+ """Raised when a streamed response body exceeds the caller's byte limit."""
33
28
 
34
29
 
35
30
  def _omit_none_params(params: Mapping[str, Any] | None) -> dict[str, Any] | None:
@@ -97,8 +92,16 @@ class HttpClient:
97
92
  json: Any = None,
98
93
  data: Mapping[str, Any] | None = None,
99
94
  files: list[tuple[str, Any]] | None = None,
95
+ extra_headers: Mapping[str, str] | None = None,
100
96
  ) -> Any:
101
- return self._request("POST", path, json=json, data=_omit_none_params(data), files=files)
97
+ return self._request(
98
+ "POST",
99
+ path,
100
+ json=json,
101
+ data=_omit_none_params(data),
102
+ files=files,
103
+ extra_headers=extra_headers,
104
+ )
102
105
 
103
106
  def patch(
104
107
  self,
@@ -116,11 +119,36 @@ class HttpClient:
116
119
  def delete(self, path: str) -> Any:
117
120
  return self._request("DELETE", path)
118
121
 
122
+ def post_bytes(
123
+ self,
124
+ path: str,
125
+ *,
126
+ json: Any = None,
127
+ extra_headers: Mapping[str, str] | None = None,
128
+ max_bytes: int,
129
+ ) -> tuple[bytes, httpx.Headers]:
130
+ headers = auth_headers(self.access_token, self.user_id)
131
+ headers["Content-Type"] = "application/json"
132
+ if extra_headers:
133
+ headers.update(extra_headers)
134
+ with self._client.stream("POST", path, headers=headers, json=json) as response:
135
+ if response.status_code >= 400:
136
+ response.read()
137
+ raise to_api_error(response)
138
+ chunks: list[bytes] = []
139
+ total = 0
140
+ for chunk in response.iter_bytes():
141
+ total += len(chunk)
142
+ if total > max_bytes:
143
+ raise ResponseSizeExceededError(f"response body exceeds {max_bytes} bytes")
144
+ chunks.append(chunk)
145
+ return b"".join(chunks), response.headers
146
+
119
147
  def post_to_stream_host(self, path: str, *, json: Any = None) -> Any:
120
148
  # Endpoints whose streaming variant lives on the stream host (e.g. chat
121
149
  # completions) must accept their unary counterparts at the same host —
122
150
  # the api host has no handler for them.
123
- headers = _auth_headers(self.access_token, self.user_id)
151
+ headers = auth_headers(self.access_token, self.user_id)
124
152
  headers["Content-Type"] = "application/json"
125
153
  response = self._client.post(
126
154
  f"{self._stream_base_url}/{path.lstrip('/')}",
@@ -140,7 +168,7 @@ class HttpClient:
140
168
  json: Any = None,
141
169
  params: Mapping[str, Any] | None = None,
142
170
  ) -> Iterator[StreamEvent]:
143
- headers = {**_auth_headers(self.access_token, self.user_id), "Accept": "text/event-stream"}
171
+ headers = {**auth_headers(self.access_token, self.user_id), "Accept": "text/event-stream"}
144
172
  with self._client.stream(
145
173
  method,
146
174
  f"{self._stream_base_url}/{path.lstrip('/')}",
@@ -166,7 +194,7 @@ class HttpClient:
166
194
  ) -> Iterator[_T]:
167
195
  # Skips the StreamEvent envelope and honors OpenRouter's `data: [DONE]`
168
196
  # terminator. Unparseable events (keep-alives, comments) are skipped.
169
- headers = {**_auth_headers(self.access_token, self.user_id), "Accept": "text/event-stream"}
197
+ headers = {**auth_headers(self.access_token, self.user_id), "Accept": "text/event-stream"}
170
198
  with self._client.stream(
171
199
  method,
172
200
  f"{self._stream_base_url}/{path.lstrip('/')}",
@@ -191,12 +219,21 @@ class HttpClient:
191
219
  logger.debug("dropped non-%s SSE: %s", response_model.__name__, event.data)
192
220
  continue
193
221
 
194
- def _request(self, method: str, path: str, **kwargs: Any) -> Any:
222
+ def _request(
223
+ self,
224
+ method: str,
225
+ path: str,
226
+ *,
227
+ extra_headers: Mapping[str, str] | None = None,
228
+ **kwargs: Any,
229
+ ) -> Any:
195
230
  # Headers are rebuilt per request so ``access_token`` / ``user_id``
196
231
  # can be rotated on a live client without reconstructing it.
197
- headers = _auth_headers(self.access_token, self.user_id)
232
+ headers = auth_headers(self.access_token, self.user_id)
198
233
  if not kwargs.get("files"):
199
234
  headers["Content-Type"] = "application/json"
235
+ if extra_headers:
236
+ headers.update(extra_headers)
200
237
  response = self._client.request(method, path, headers=headers, **kwargs)
201
238
  if response.status_code >= 400:
202
239
  raise to_api_error(response)
@@ -261,7 +298,7 @@ class AsyncHttpClient:
261
298
  return await self._request("DELETE", path)
262
299
 
263
300
  async def post_to_stream_host(self, path: str, *, json: Any = None) -> Any:
264
- headers = _auth_headers(self.access_token, self.user_id)
301
+ headers = auth_headers(self.access_token, self.user_id)
265
302
  headers["Content-Type"] = "application/json"
266
303
  response = await self._client.post(
267
304
  f"{self._stream_base_url}/{path.lstrip('/')}",
@@ -281,7 +318,7 @@ class AsyncHttpClient:
281
318
  json: Any = None,
282
319
  params: Mapping[str, Any] | None = None,
283
320
  ) -> AsyncIterator[StreamEvent]:
284
- headers = {**_auth_headers(self.access_token, self.user_id), "Accept": "text/event-stream"}
321
+ headers = {**auth_headers(self.access_token, self.user_id), "Accept": "text/event-stream"}
285
322
  async with self._client.stream(
286
323
  method,
287
324
  f"{self._stream_base_url}/{path.lstrip('/')}",
@@ -305,7 +342,7 @@ class AsyncHttpClient:
305
342
  json: Any = None,
306
343
  params: Mapping[str, Any] | None = None,
307
344
  ) -> AsyncIterator[_T]:
308
- headers = {**_auth_headers(self.access_token, self.user_id), "Accept": "text/event-stream"}
345
+ headers = {**auth_headers(self.access_token, self.user_id), "Accept": "text/event-stream"}
309
346
  async with self._client.stream(
310
347
  method,
311
348
  f"{self._stream_base_url}/{path.lstrip('/')}",
@@ -331,7 +368,7 @@ class AsyncHttpClient:
331
368
  continue
332
369
 
333
370
  async def _request(self, method: str, path: str, **kwargs: Any) -> Any:
334
- headers = _auth_headers(self.access_token, self.user_id)
371
+ headers = auth_headers(self.access_token, self.user_id)
335
372
  if not kwargs.get("files"):
336
373
  headers["Content-Type"] = "application/json"
337
374
  response = await self._client.request(method, path, headers=headers, **kwargs)
@@ -0,0 +1,3 @@
1
+ from __future__ import annotations
2
+
3
+ __version__ = "0.4.0"
@@ -0,0 +1,328 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import Counter
4
+ from types import TracebackType
5
+
6
+ from rich.markup import escape as escape_markup
7
+ from rich.status import Status
8
+ from rich.table import Table
9
+ from rich.text import Text
10
+
11
+ from gumloop.cli.console import console
12
+ from gumloop.cli.console import error_console
13
+ from gumloop.sync.results import SyncExecution
14
+ from gumloop.sync.results import SyncProgress
15
+ from gumloop.sync.results import TargetOutcome
16
+ from gumloop.sync.targets import PhysicalTarget
17
+ from gumloop.types import CliSyncPlanResponse
18
+
19
+ _ACTION_LABELS = {
20
+ "adopted": "adopted",
21
+ "failed": "failed",
22
+ "installed": "installed",
23
+ "overwritten_collision": "replaced",
24
+ "overwritten_local_edit": "replaced local edit",
25
+ "removed": "removed",
26
+ "unchanged": "unchanged",
27
+ "updated": "updated",
28
+ }
29
+
30
+ _CRITICAL_ACTIONS = frozenset(
31
+ {
32
+ "failed",
33
+ "overwritten_collision",
34
+ "overwritten_local_edit",
35
+ "removed",
36
+ }
37
+ )
38
+
39
+ # Human labels match Homecrew/crew adapter names (hyphenated).
40
+ _AGENT_LABELS = {
41
+ "agent_skills": "agent-skills",
42
+ "claude_code": "claude-code",
43
+ "codex": "codex",
44
+ "cursor": "cursor",
45
+ }
46
+
47
+
48
+ class SyncOutput:
49
+ def __init__(self) -> None:
50
+ self._status: Status | None = None
51
+ self._already_current = False
52
+
53
+ def __enter__(self) -> SyncOutput:
54
+ return self
55
+
56
+ def __exit__(
57
+ self,
58
+ _exc_type: type[BaseException] | None,
59
+ _exc_value: BaseException | None,
60
+ _traceback: TracebackType | None,
61
+ ) -> None:
62
+ self._stop()
63
+
64
+ def __call__(self, progress: SyncProgress) -> None:
65
+ if progress.stage == "resolving_plan":
66
+ if progress.attempt == 1:
67
+ message = "Resolving managed Skills…"
68
+ else:
69
+ max_attempts = progress.max_attempts
70
+ retry_label = (
71
+ f"{progress.attempt}/{max_attempts}" if max_attempts is not None else str(progress.attempt)
72
+ )
73
+ message = f"Desired state changed; retrying ({retry_label})…"
74
+ self._start(message)
75
+ return
76
+ if progress.stage == "plan_resolved":
77
+ self._stop()
78
+ skill_count = progress.skill_count or 0
79
+ agent_count = progress.agent_count or 0
80
+ console.print(
81
+ "[green]✓[/green] "
82
+ f"Found {skill_count} {self._pluralize('Skill', skill_count)} "
83
+ f"for {agent_count} {self._pluralize('agent', agent_count)}"
84
+ )
85
+ return
86
+ if progress.stage == "downloading_bundle":
87
+ self._start("Downloading and verifying Skill bundle…")
88
+ return
89
+ if progress.stage == "bundle_verified":
90
+ self._stop()
91
+ console.print("[green]✓[/green] Downloaded and verified Skill bundle")
92
+ return
93
+ if progress.stage == "reconciling_target" and progress.target is not None:
94
+ agents = escape_markup(self._format_agents(progress.target))
95
+ self._start(f"Syncing {agents}…")
96
+ return
97
+ if progress.stage == "target_complete" and progress.outcome is not None:
98
+ self._stop()
99
+ self._print_target_outcome(progress.outcome)
100
+ return
101
+ if progress.stage == "already_current":
102
+ self._stop()
103
+ self._already_current = True
104
+ skill_count = progress.skill_count or 0
105
+ agent_count = progress.agent_count or 0
106
+ console.print(
107
+ "[green]✓[/green] "
108
+ f"All {skill_count} {self._pluralize('Skill', skill_count)} are already current "
109
+ f"across {agent_count} {self._pluralize('agent', agent_count)}"
110
+ )
111
+
112
+ def print_enrollment(
113
+ self,
114
+ *,
115
+ plan: CliSyncPlanResponse,
116
+ targets: tuple[PhysicalTarget, ...],
117
+ ) -> None:
118
+ table = Table(show_lines=False)
119
+ table.add_column("Skills path", style="dim")
120
+ table.add_column("Coding agents")
121
+
122
+ console.print()
123
+ console.print(
124
+ f"Enrolled in [cyan]{escape_markup(plan.organization.organization_name)}[/cyan]"
125
+ )
126
+ if not targets:
127
+ table.add_row(
128
+ Text("—"),
129
+ Text("No coding agents found"),
130
+ )
131
+ console.print(table)
132
+ return
133
+
134
+ for target in targets:
135
+ table.add_row(
136
+ Text(str(target.skills_root)),
137
+ Text(self._format_agents(target)),
138
+ )
139
+ console.print(table)
140
+
141
+ def print_background_status(self, status: str) -> None:
142
+ console.print(f"[green]✓[/green] Background sync {status} [dim](every 4 hours)[/dim]")
143
+
144
+ def print_result(self, execution: SyncExecution, *, verbose: bool) -> None:
145
+ if execution.status == "blocked":
146
+ reason = execution.result.get("blocked_reason") or "unknown"
147
+ console.print(
148
+ f"\nSync blocked: {reason}. Installed Skills were preserved.",
149
+ markup=False,
150
+ highlight=False,
151
+ )
152
+ self._print_changes(execution, verbose=verbose)
153
+ return
154
+
155
+ skill_counts = self._unique_skill_counts(execution)
156
+ failed_count = self._failed_count_for_summary(execution, skill_counts)
157
+ action_parts = [
158
+ label
159
+ for count, label in (
160
+ (skill_counts.get("installed", 0), f"{skill_counts.get('installed', 0)} installed"),
161
+ (skill_counts.get("adopted", 0), f"{skill_counts.get('adopted', 0)} adopted"),
162
+ (skill_counts.get("overwritten", 0), f"{skill_counts.get('overwritten', 0)} replaced"),
163
+ (skill_counts.get("updated", 0), f"{skill_counts.get('updated', 0)} updated"),
164
+ (skill_counts.get("removed", 0), f"{skill_counts.get('removed', 0)} removed"),
165
+ )
166
+ if count
167
+ ]
168
+ agent_count = self._detected_agent_count(execution)
169
+ agents = f"{agent_count} {self._pluralize('agent', agent_count)}"
170
+
171
+ if not action_parts and failed_count == 0:
172
+ # Fast-path already printed "already current"; avoid a redundant summary.
173
+ if not self._already_current:
174
+ console.print(f"\nSync complete: already up to date across {agents}.")
175
+ else:
176
+ status = "complete" if execution.status == "ok" else execution.status
177
+ parts = [*action_parts, f"{failed_count} failed"]
178
+ console.print(f"\nSync {status}: {', '.join(parts)} across {agents}.")
179
+
180
+ self._print_changes(execution, verbose=verbose)
181
+
182
+ def _start(self, message: str) -> None:
183
+ if self._status is None:
184
+ self._status = Status(
185
+ message,
186
+ console=console,
187
+ spinner="dots",
188
+ )
189
+ self._status.start()
190
+ else:
191
+ self._status.update(message)
192
+
193
+ def _stop(self) -> None:
194
+ status = self._status
195
+ self._status = None
196
+ if status is None:
197
+ return
198
+ try:
199
+ status.stop()
200
+ except Exception:
201
+ pass
202
+
203
+ def _unique_skill_counts(self, execution: SyncExecution) -> dict[str, int]:
204
+ """Count each Skill once, even when the same change is written for multiple agents."""
205
+ by_skill: dict[str, set[str]] = {}
206
+ changes = execution.result.get("changes")
207
+ if isinstance(changes, list):
208
+ for change in changes:
209
+ if not isinstance(change, dict):
210
+ continue
211
+ action = change.get("action")
212
+ if not isinstance(action, str) or action == "unchanged":
213
+ continue
214
+ if action in ("overwritten_collision", "overwritten_local_edit"):
215
+ bucket = "overwritten"
216
+ else:
217
+ bucket = action
218
+ skill_key = change.get("skill_id") or change.get("name")
219
+ if not isinstance(skill_key, str) or not skill_key:
220
+ skill_key = str(change.get("physical_path") or id(change))
221
+ by_skill.setdefault(skill_key, set()).add(bucket)
222
+
223
+ counts = Counter(bucket for actions in by_skill.values() for bucket in actions)
224
+ return dict(counts)
225
+
226
+ def _detected_agent_count(self, execution: SyncExecution) -> int:
227
+ detected = execution.result.get("detected_targets")
228
+ if isinstance(detected, list):
229
+ return len(detected)
230
+ return 0
231
+
232
+ def _failed_count_for_summary(
233
+ self,
234
+ execution: SyncExecution,
235
+ skill_counts: dict[str, int],
236
+ ) -> int:
237
+ """Prefer unique Skill failures; raise to target-level when partial under-reports."""
238
+ change_failed = skill_counts.get("failed", 0)
239
+ if execution.status != "partial":
240
+ return change_failed
241
+ physical_targets = execution.result.get("physical_targets")
242
+ if not isinstance(physical_targets, list):
243
+ return change_failed
244
+ target_failed = sum(
245
+ 1 for target in physical_targets if isinstance(target, dict) and target.get("error") is not None
246
+ )
247
+ return max(change_failed, target_failed)
248
+
249
+ def _print_target_outcome(self, outcome: TargetOutcome) -> None:
250
+ agents = self._format_agents(outcome.target)
251
+ if outcome.error is not None:
252
+ error_console.print(f"✗ {agents} — {outcome.error}", markup=False, highlight=False)
253
+ return
254
+
255
+ counts = Counter(change.action for change in outcome.changes)
256
+ replaced = counts["overwritten_collision"] + counts["overwritten_local_edit"]
257
+ parts = [
258
+ label
259
+ for count, label in (
260
+ (counts["installed"], f"{counts['installed']} installed"),
261
+ (counts["adopted"], f"{counts['adopted']} adopted"),
262
+ (replaced, f"{replaced} replaced"),
263
+ (counts["updated"], f"{counts['updated']} updated"),
264
+ (counts["removed"], f"{counts['removed']} removed"),
265
+ (counts["failed"], f"{counts['failed']} failed"),
266
+ )
267
+ if count
268
+ ]
269
+ detail = ", ".join(parts) if parts else "up to date"
270
+ # One line per physical write; list every agent that shares that path.
271
+ console.print(f"[green]✓[/green] {escape_markup(agents)} — {detail}")
272
+
273
+ def _print_changes(self, execution: SyncExecution, *, verbose: bool) -> None:
274
+ changes = execution.result.get("changes")
275
+ if not isinstance(changes, list):
276
+ return
277
+
278
+ visible = [
279
+ change
280
+ for change in changes
281
+ if isinstance(change, dict)
282
+ and self._change_is_visible(change, verbose=verbose)
283
+ ]
284
+ if not visible:
285
+ return
286
+
287
+ console.print("\nChanges:")
288
+ for change in visible:
289
+ action = str(change.get("action", "changed"))
290
+ label = _ACTION_LABELS.get(action, action.replace("_", " "))
291
+ agents = self._format_agent_names(change.get("targets"))
292
+ line = f" {label}: {change.get('name')}"
293
+ if agents:
294
+ line += f" ({agents})"
295
+ if change.get("backup_path"):
296
+ line += f" backup: {change['backup_path']}"
297
+ if change.get("error"):
298
+ line += f" error: {change['error']}"
299
+ error_console.print(line, markup=False, highlight=False)
300
+ else:
301
+ console.print(line, markup=False, highlight=False)
302
+
303
+ def _change_is_visible(self, change: dict[str, object], *, verbose: bool) -> bool:
304
+ action = change.get("action")
305
+ if change.get("error"):
306
+ return True
307
+ if action == "unchanged":
308
+ return False
309
+ if verbose:
310
+ return True
311
+ if change.get("backup_path"):
312
+ return True
313
+ return action in _CRITICAL_ACTIONS
314
+
315
+ def _format_agents(self, target: PhysicalTarget) -> str:
316
+ return self._format_agent_names(target.logical_targets)
317
+
318
+ def _format_agent_names(self, names: object) -> str:
319
+ if not isinstance(names, (list, tuple)):
320
+ return ""
321
+ labels = [self._agent_label(str(name)) for name in names]
322
+ return ", ".join(labels)
323
+
324
+ def _agent_label(self, name: str) -> str:
325
+ return _AGENT_LABELS.get(name, name.replace("_", "-"))
326
+
327
+ def _pluralize(self, word: str, count: int) -> str:
328
+ return word if count == 1 else f"{word}s"