gumloop 0.3.5__tar.gz → 0.4.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. {gumloop-0.3.5 → gumloop-0.4.2}/PKG-INFO +21 -1
  2. {gumloop-0.3.5 → gumloop-0.4.2}/README.md +20 -0
  3. {gumloop-0.3.5 → gumloop-0.4.2}/pyproject.toml +2 -1
  4. {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/__init__.py +2 -1
  5. {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/_client.py +11 -1
  6. {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/_http.py +98 -4
  7. gumloop-0.4.2/src/gumloop/_version.py +3 -0
  8. gumloop-0.4.2/src/gumloop/cli/commands/_sync_output.py +328 -0
  9. gumloop-0.4.2/src/gumloop/cli/commands/sync.py +335 -0
  10. {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/cli/main.py +2 -0
  11. {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/resources/__init__.py +2 -0
  12. {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/resources/agents.py +10 -4
  13. {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/resources/mcp.py +8 -0
  14. {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/resources/skills.py +4 -0
  15. gumloop-0.4.2/src/gumloop/resources/sync.py +94 -0
  16. gumloop-0.4.2/src/gumloop/sync/__init__.py +1 -0
  17. gumloop-0.4.2/src/gumloop/sync/archive.py +131 -0
  18. gumloop-0.4.2/src/gumloop/sync/bundle.py +71 -0
  19. gumloop-0.4.2/src/gumloop/sync/bundle_validation.py +121 -0
  20. gumloop-0.4.2/src/gumloop/sync/errors.py +20 -0
  21. gumloop-0.4.2/src/gumloop/sync/hashing.py +65 -0
  22. gumloop-0.4.2/src/gumloop/sync/installed.py +141 -0
  23. gumloop-0.4.2/src/gumloop/sync/local_state.py +161 -0
  24. gumloop-0.4.2/src/gumloop/sync/lock.py +50 -0
  25. gumloop-0.4.2/src/gumloop/sync/markers.py +159 -0
  26. gumloop-0.4.2/src/gumloop/sync/reconcile.py +219 -0
  27. gumloop-0.4.2/src/gumloop/sync/reporting.py +136 -0
  28. gumloop-0.4.2/src/gumloop/sync/results.py +67 -0
  29. gumloop-0.4.2/src/gumloop/sync/run.py +556 -0
  30. gumloop-0.4.2/src/gumloop/sync/scheduler.py +294 -0
  31. gumloop-0.4.2/src/gumloop/sync/target_files.py +421 -0
  32. gumloop-0.4.2/src/gumloop/sync/targets.py +63 -0
  33. gumloop-0.4.2/src/gumloop/sync/wire.py +15 -0
  34. {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/types.py +67 -0
  35. gumloop-0.4.2/tests/cli/__init__.py +1 -0
  36. gumloop-0.4.2/tests/cli/conftest.py +116 -0
  37. gumloop-0.4.2/tests/cli/sync_scenario_helpers.py +342 -0
  38. gumloop-0.4.2/tests/cli/sync_test_fakes.py +85 -0
  39. gumloop-0.4.2/tests/cli/test_home_isolation.py +49 -0
  40. gumloop-0.4.2/tests/cli/test_sync_bundle.py +359 -0
  41. gumloop-0.4.2/tests/cli/test_sync_command.py +744 -0
  42. gumloop-0.4.2/tests/cli/test_sync_command_safety.py +371 -0
  43. gumloop-0.4.2/tests/cli/test_sync_contract_fixtures.py +187 -0
  44. gumloop-0.4.2/tests/cli/test_sync_enrollment.py +671 -0
  45. gumloop-0.4.2/tests/cli/test_sync_fixture_corpus.py +32 -0
  46. gumloop-0.4.2/tests/cli/test_sync_hashing.py +65 -0
  47. gumloop-0.4.2/tests/cli/test_sync_installed.py +250 -0
  48. gumloop-0.4.2/tests/cli/test_sync_reconciliation_safety.py +206 -0
  49. gumloop-0.4.2/tests/cli/test_sync_scheduler_macos.py +350 -0
  50. gumloop-0.4.2/tests/cli/test_sync_targets.py +217 -0
  51. gumloop-0.4.2/tests/fixtures/skill_sync/v1/README.md +19 -0
  52. gumloop-0.4.2/tests/fixtures/skill_sync/v1/archives/generated-bundle-transfer-limit.json +10 -0
  53. gumloop-0.4.2/tests/fixtures/skill_sync/v1/archives/invalid-absolute-path.zip +0 -0
  54. gumloop-0.4.2/tests/fixtures/skill_sync/v1/archives/invalid-bytes-per-file-limit.zip +0 -0
  55. gumloop-0.4.2/tests/fixtures/skill_sync/v1/archives/invalid-device.zip +0 -0
  56. gumloop-0.4.2/tests/fixtures/skill_sync/v1/archives/invalid-duplicate-entry.zip +0 -0
  57. gumloop-0.4.2/tests/fixtures/skill_sync/v1/archives/invalid-fifo.zip +0 -0
  58. gumloop-0.4.2/tests/fixtures/skill_sync/v1/archives/invalid-files-per-skill-limit.zip +0 -0
  59. gumloop-0.4.2/tests/fixtures/skill_sync/v1/archives/invalid-nul-name.zip +0 -0
  60. gumloop-0.4.2/tests/fixtures/skill_sync/v1/archives/invalid-parent-traversal.zip +0 -0
  61. gumloop-0.4.2/tests/fixtures/skill_sync/v1/archives/invalid-reserved-marker.zip +0 -0
  62. gumloop-0.4.2/tests/fixtures/skill_sync/v1/archives/invalid-socket.zip +0 -0
  63. gumloop-0.4.2/tests/fixtures/skill_sync/v1/archives/invalid-symlink.zip +0 -0
  64. gumloop-0.4.2/tests/fixtures/skill_sync/v1/archives/invalid-total-uncompressed-limit.zip +0 -0
  65. gumloop-0.4.2/tests/fixtures/skill_sync/v1/archives/invalid-windows-drive-prefix.zip +0 -0
  66. gumloop-0.4.2/tests/fixtures/skill_sync/v1/archives/valid-single-skill-stored.zip +0 -0
  67. gumloop-0.4.2/tests/fixtures/skill_sync/v1/archives/valid-single-skill.zip +0 -0
  68. gumloop-0.4.2/tests/fixtures/skill_sync/v1/bundles/newer-than-plan.zip +0 -0
  69. gumloop-0.4.2/tests/fixtures/skill_sync/v1/bundles/normal.zip +0 -0
  70. gumloop-0.4.2/tests/fixtures/skill_sync/v1/catalog.json +179 -0
  71. gumloop-0.4.2/tests/fixtures/skill_sync/v1/corpus.sha256 +1 -0
  72. gumloop-0.4.2/tests/fixtures/skill_sync/v1/hashes/content-v1.json +331 -0
  73. gumloop-0.4.2/tests/fixtures/skill_sync/v1/hashes/manifest-v1.json +130 -0
  74. gumloop-0.4.2/tests/fixtures/skill_sync/v1/manifests/invalid-duplicate-identity.json +32 -0
  75. gumloop-0.4.2/tests/fixtures/skill_sync/v1/manifests/invalid-duplicate-install-name.json +32 -0
  76. gumloop-0.4.2/tests/fixtures/skill_sync/v1/manifests/invalid-manifest-hash.json +26 -0
  77. gumloop-0.4.2/tests/fixtures/skill_sync/v1/manifests/newer-than-plan.json +26 -0
  78. gumloop-0.4.2/tests/fixtures/skill_sync/v1/manifests/normal-bundle.json +26 -0
  79. gumloop-0.4.2/tests/fixtures/skill_sync/v1/responses/below-pro.json +9 -0
  80. gumloop-0.4.2/tests/fixtures/skill_sync/v1/responses/cli-upgrade-required.json +12 -0
  81. gumloop-0.4.2/tests/fixtures/skill_sync/v1/responses/lost-membership.json +9 -0
  82. gumloop-0.4.2/tests/fixtures/skill_sync/v1/responses/normal-empty-plan.json +19 -0
  83. gumloop-0.4.2/tests/fixtures/skill_sync/v1/responses/normal-plan.json +19 -0
  84. {gumloop-0.3.5 → gumloop-0.4.2}/tests/sdk/test_client.py +121 -0
  85. gumloop-0.4.2/tests/sdk/test_sync.py +309 -0
  86. gumloop-0.4.2/tests/skill_sync_fixtures.py +35 -0
  87. {gumloop-0.3.5 → gumloop-0.4.2}/uv.lock +1 -1
  88. gumloop-0.3.5/tests/cli/__init__.py +0 -0
  89. gumloop-0.3.5/tests/cli/conftest.py +0 -55
  90. {gumloop-0.3.5 → gumloop-0.4.2}/.env.example +0 -0
  91. {gumloop-0.3.5 → gumloop-0.4.2}/.gitignore +0 -0
  92. {gumloop-0.3.5 → gumloop-0.4.2}/.pre-commit-config.yaml +0 -0
  93. {gumloop-0.3.5 → gumloop-0.4.2}/.python-version +0 -0
  94. {gumloop-0.3.5 → gumloop-0.4.2}/LICENSE +0 -0
  95. {gumloop-0.3.5 → gumloop-0.4.2}/scripts/check +0 -0
  96. {gumloop-0.3.5 → gumloop-0.4.2}/scripts/fmt +0 -0
  97. {gumloop-0.3.5 → gumloop-0.4.2}/scripts/sdk_examples.py +0 -0
  98. {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/_auth.py +0 -0
  99. {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/cli/__init__.py +0 -0
  100. {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/cli/commands/__init__.py +0 -0
  101. {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/cli/commands/_args_input.py +0 -0
  102. {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/cli/commands/_downloads.py +0 -0
  103. {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/cli/commands/agents.py +0 -0
  104. {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/cli/commands/artifacts.py +0 -0
  105. {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/cli/commands/auth.py +0 -0
  106. {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/cli/commands/brain.py +0 -0
  107. {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/cli/commands/chat.py +0 -0
  108. {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/cli/commands/mcp.py +0 -0
  109. {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/cli/commands/sessions.py +0 -0
  110. {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/cli/commands/skills.py +0 -0
  111. {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/cli/console.py +0 -0
  112. {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/cli/context.py +0 -0
  113. {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/cli/credentials.py +0 -0
  114. {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/cli/errors.py +0 -0
  115. {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/cli/oauth.py +0 -0
  116. {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/errors.py +0 -0
  117. {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/oauth.py +0 -0
  118. {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/py.typed +0 -0
  119. {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/resources/artifacts.py +0 -0
  120. {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/resources/brain.py +0 -0
  121. {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/resources/chat.py +0 -0
  122. {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/resources/sessions.py +0 -0
  123. {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/resources/teams.py +0 -0
  124. {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/spec/__init__.py +0 -0
  125. {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/spec/_compat.py +0 -0
  126. {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/spec/_extensions.py +0 -0
  127. {gumloop-0.3.5 → gumloop-0.4.2}/tests/__init__.py +0 -0
  128. {gumloop-0.3.5 → gumloop-0.4.2}/tests/cli/test_agents.py +0 -0
  129. {gumloop-0.3.5 → gumloop-0.4.2}/tests/cli/test_args_input.py +0 -0
  130. {gumloop-0.3.5 → gumloop-0.4.2}/tests/cli/test_artifacts.py +0 -0
  131. {gumloop-0.3.5 → gumloop-0.4.2}/tests/cli/test_brain.py +0 -0
  132. {gumloop-0.3.5 → gumloop-0.4.2}/tests/cli/test_chat.py +0 -0
  133. {gumloop-0.3.5 → gumloop-0.4.2}/tests/cli/test_context.py +0 -0
  134. {gumloop-0.3.5 → gumloop-0.4.2}/tests/cli/test_credentials.py +0 -0
  135. {gumloop-0.3.5 → gumloop-0.4.2}/tests/cli/test_login.py +0 -0
  136. {gumloop-0.3.5 → gumloop-0.4.2}/tests/cli/test_main.py +0 -0
  137. {gumloop-0.3.5 → gumloop-0.4.2}/tests/cli/test_mcp.py +0 -0
  138. {gumloop-0.3.5 → gumloop-0.4.2}/tests/cli/test_sessions.py +0 -0
  139. {gumloop-0.3.5 → gumloop-0.4.2}/tests/cli/test_skills.py +0 -0
  140. {gumloop-0.3.5 → gumloop-0.4.2}/tests/conftest.py +0 -0
  141. {gumloop-0.3.5 → gumloop-0.4.2}/tests/integration/conftest.py +0 -0
  142. {gumloop-0.3.5 → gumloop-0.4.2}/tests/integration/test_live.py +0 -0
  143. {gumloop-0.3.5 → gumloop-0.4.2}/tests/sdk/__init__.py +0 -0
  144. {gumloop-0.3.5 → gumloop-0.4.2}/tests/sdk/conftest.py +0 -0
  145. {gumloop-0.3.5 → gumloop-0.4.2}/tests/sdk/helpers.py +0 -0
  146. {gumloop-0.3.5 → gumloop-0.4.2}/tests/sdk/test_agents.py +0 -0
  147. {gumloop-0.3.5 → gumloop-0.4.2}/tests/sdk/test_artifacts.py +0 -0
  148. {gumloop-0.3.5 → gumloop-0.4.2}/tests/sdk/test_brain.py +0 -0
  149. {gumloop-0.3.5 → gumloop-0.4.2}/tests/sdk/test_chat.py +0 -0
  150. {gumloop-0.3.5 → gumloop-0.4.2}/tests/sdk/test_mcp.py +0 -0
  151. {gumloop-0.3.5 → gumloop-0.4.2}/tests/sdk/test_oauth.py +0 -0
  152. {gumloop-0.3.5 → gumloop-0.4.2}/tests/sdk/test_sessions.py +0 -0
  153. {gumloop-0.3.5 → gumloop-0.4.2}/tests/sdk/test_skills.py +0 -0
  154. {gumloop-0.3.5 → gumloop-0.4.2}/tests/sdk/test_teams.py +0 -0
  155. {gumloop-0.3.5 → gumloop-0.4.2}/tests/test_client.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gumloop
3
- Version: 0.3.5
3
+ Version: 0.4.2
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
@@ -57,6 +57,26 @@ output = client.run_flow(
57
57
  print(output)
58
58
  ```
59
59
 
60
+ ## Authenticate with a team API key
61
+
62
+ Team (workspace) API keys are scoped to a single team. Pass the team's ID and
63
+ the acting member's user ID — every request is validated against that team and
64
+ only reaches resources the team owns.
65
+
66
+ ```python
67
+ from gumloop import Gumloop
68
+
69
+ client = Gumloop(
70
+ api_key="your_team_api_key",
71
+ user_id="your_user_id", # must be a member of the team
72
+ team_id="your_team_id",
73
+ )
74
+
75
+ agents = client.agents.list() # scoped to the team
76
+ ```
77
+
78
+ `team_id` can also be provided via the `GUMLOOP_TEAM_ID` environment variable.
79
+
60
80
  ## Chat with an agent (streaming)
61
81
 
62
82
  ```python
@@ -32,6 +32,26 @@ output = client.run_flow(
32
32
  print(output)
33
33
  ```
34
34
 
35
+ ## Authenticate with a team API key
36
+
37
+ Team (workspace) API keys are scoped to a single team. Pass the team's ID and
38
+ the acting member's user ID — every request is validated against that team and
39
+ only reaches resources the team owns.
40
+
41
+ ```python
42
+ from gumloop import Gumloop
43
+
44
+ client = Gumloop(
45
+ api_key="your_team_api_key",
46
+ user_id="your_user_id", # must be a member of the team
47
+ team_id="your_team_id",
48
+ )
49
+
50
+ agents = client.agents.list() # scoped to the team
51
+ ```
52
+
53
+ `team_id` can also be provided via the `GUMLOOP_TEAM_ID` environment variable.
54
+
35
55
  ## Chat with an agent (streaming)
36
56
 
37
57
  ```python
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "gumloop"
7
- version = "0.3.5"
7
+ version = "0.4.2"
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.5"
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
  ]
@@ -28,10 +28,11 @@ from gumloop.resources import Chat
28
28
  from gumloop.resources import Models
29
29
  from gumloop.resources import Sessions
30
30
  from gumloop.resources import Skills
31
+ from gumloop.resources import Sync
31
32
  from gumloop.resources import Teams
32
33
 
33
34
  DEFAULT_BASE_URL = "https://api.gumloop.com/api/v1"
34
- DEFAULT_TIMEOUT = 30.0
35
+ DEFAULT_TIMEOUT = 90.0
35
36
  DEFAULT_STREAM_TIMEOUT = 3600.0
36
37
 
37
38
 
@@ -54,6 +55,7 @@ class Gumloop:
54
55
  *,
55
56
  access_token: str | None = None,
56
57
  user_id: str | None = None,
58
+ team_id: str | None = None,
57
59
  base_url: str | None = None,
58
60
  stream_base_url: str | None = None,
59
61
  timeout: float = DEFAULT_TIMEOUT,
@@ -65,6 +67,8 @@ class Gumloop:
65
67
  # x-auth-key header to look up the owner's stored secret. OAuth
66
68
  # callers omit it because the bearer token already identifies them.
67
69
  self.user_id = user_id or os.environ.get("GUMLOOP_USER_ID")
70
+ # Team API keys are validated against ``team_id``; ``user_id`` stays the acting member.
71
+ self.team_id = team_id or os.environ.get("GUMLOOP_TEAM_ID")
68
72
  # Defaults to production; GUMLOOP_BASE_URL is an optional override.
69
73
  self.base_url = (base_url or os.environ.get("GUMLOOP_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
70
74
  self.stream_base_url = (stream_base_url or _derive_stream_base_url(self.base_url)).rstrip("/")
@@ -76,6 +80,7 @@ class Gumloop:
76
80
  stream_base_url=self.stream_base_url,
77
81
  access_token=self.access_token,
78
82
  user_id=self.user_id,
83
+ team_id=self.team_id,
79
84
  timeout=self.timeout,
80
85
  stream_timeout=self.stream_timeout,
81
86
  )
@@ -87,6 +92,7 @@ class Gumloop:
87
92
  self.mcp = MCP(self._http)
88
93
  self.teams = Teams(self._http)
89
94
  self.skills = Skills(self._http)
95
+ self.sync = Sync(self._http)
90
96
  self.artifacts = Artifacts(self._http)
91
97
  self.brain = Brain(self._http)
92
98
  self.oauth = OAuth(base_url=self.base_url, timeout=self.timeout)
@@ -110,6 +116,7 @@ class AsyncGumloop:
110
116
  *,
111
117
  access_token: str | None = None,
112
118
  user_id: str | None = None,
119
+ team_id: str | None = None,
113
120
  base_url: str | None = None,
114
121
  stream_base_url: str | None = None,
115
122
  timeout: float = DEFAULT_TIMEOUT,
@@ -118,6 +125,8 @@ class AsyncGumloop:
118
125
  self.api_key = api_key
119
126
  self.access_token = access_token or api_key or EnvToken.from_env()
120
127
  self.user_id = user_id or os.environ.get("GUMLOOP_USER_ID")
128
+ # Team API keys are validated against ``team_id``; ``user_id`` stays the acting member.
129
+ self.team_id = team_id or os.environ.get("GUMLOOP_TEAM_ID")
121
130
  # Defaults to production; GUMLOOP_BASE_URL is an optional override.
122
131
  self.base_url = (base_url or os.environ.get("GUMLOOP_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
123
132
  self.stream_base_url = (stream_base_url or _derive_stream_base_url(self.base_url)).rstrip("/")
@@ -129,6 +138,7 @@ class AsyncGumloop:
129
138
  stream_base_url=self.stream_base_url,
130
139
  access_token=self.access_token,
131
140
  user_id=self.user_id,
141
+ team_id=self.team_id,
132
142
  timeout=self.timeout,
133
143
  stream_timeout=self.stream_timeout,
134
144
  )
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import logging
4
+ import os
4
5
  from collections.abc import AsyncIterator
5
6
  from collections.abc import Iterator
6
7
  from collections.abc import Mapping
@@ -19,10 +20,37 @@ from gumloop.types import StreamEvent
19
20
 
20
21
  logger = logging.getLogger(__name__)
21
22
 
23
+
24
+ def _inject_trace_context(request: httpx.Request) -> None:
25
+ """Request event hook: attach W3C trace context when the host sets TRACEPARENT.
26
+
27
+ Read per request: sandbox runtimes rotate env between calls. setdefault so
28
+ an explicitly provided header wins.
29
+ """
30
+ traceparent = os.environ.get("TRACEPARENT")
31
+ if not traceparent:
32
+ return
33
+ request.headers.setdefault("traceparent", traceparent)
34
+ tracestate = os.environ.get("TRACESTATE")
35
+ if tracestate:
36
+ request.headers.setdefault("tracestate", tracestate)
37
+
38
+
39
+ async def _inject_trace_context_async(request: httpx.Request) -> None:
40
+ _inject_trace_context(request)
41
+
42
+
43
+ _TRACE_EVENT_HOOKS = {"request": [_inject_trace_context]}
44
+ _ASYNC_TRACE_EVENT_HOOKS = {"request": [_inject_trace_context_async]}
45
+
22
46
  _DONE_SENTINEL = "[DONE]"
23
47
  _T = TypeVar("_T", bound=BaseModel)
24
48
 
25
49
 
50
+ class ResponseSizeExceededError(Exception):
51
+ """Raised when a streamed response body exceeds the caller's byte limit."""
52
+
53
+
26
54
  def _omit_none_params(params: Mapping[str, Any] | None) -> dict[str, Any] | None:
27
55
  # Backend treats absent ``?foo`` and ``?foo=`` as different signals
28
56
  # ("not provided" vs "empty string"); drop None values so the wire URL
@@ -60,14 +88,24 @@ class HttpClient:
60
88
  stream_base_url: str,
61
89
  access_token: str | None,
62
90
  user_id: str | None,
91
+ team_id: str | None = None,
63
92
  timeout: float,
64
93
  stream_timeout: float | None,
65
94
  ) -> None:
66
95
  self.access_token = access_token
67
96
  self.user_id = user_id
97
+ self.team_id = team_id
68
98
  self._stream_base_url = stream_base_url.rstrip("/")
69
99
  self._stream_timeout = stream_timeout
70
- self._client = httpx.Client(base_url=base_url.rstrip("/"), timeout=timeout)
100
+ self._client = httpx.Client(base_url=base_url.rstrip("/"), timeout=timeout, event_hooks=_TRACE_EVENT_HOOKS)
101
+
102
+ def _scoped_params(self, params: dict[str, Any] | None) -> dict[str, Any] | None:
103
+ # Team keys are validated against ``team_id``, so it rides on every request.
104
+ if not self.team_id:
105
+ return params
106
+ merged = dict(params or {})
107
+ merged.setdefault("team_id", self.team_id)
108
+ return merged
71
109
 
72
110
  def close(self) -> None:
73
111
  self._client.close()
@@ -88,8 +126,16 @@ class HttpClient:
88
126
  json: Any = None,
89
127
  data: Mapping[str, Any] | None = None,
90
128
  files: list[tuple[str, Any]] | None = None,
129
+ extra_headers: Mapping[str, str] | None = None,
91
130
  ) -> Any:
92
- return self._request("POST", path, json=json, data=_omit_none_params(data), files=files)
131
+ return self._request(
132
+ "POST",
133
+ path,
134
+ json=json,
135
+ data=_omit_none_params(data),
136
+ files=files,
137
+ extra_headers=extra_headers,
138
+ )
93
139
 
94
140
  def patch(
95
141
  self,
@@ -107,6 +153,31 @@ class HttpClient:
107
153
  def delete(self, path: str) -> Any:
108
154
  return self._request("DELETE", path)
109
155
 
156
+ def post_bytes(
157
+ self,
158
+ path: str,
159
+ *,
160
+ json: Any = None,
161
+ extra_headers: Mapping[str, str] | None = None,
162
+ max_bytes: int,
163
+ ) -> tuple[bytes, httpx.Headers]:
164
+ headers = auth_headers(self.access_token, self.user_id)
165
+ headers["Content-Type"] = "application/json"
166
+ if extra_headers:
167
+ headers.update(extra_headers)
168
+ with self._client.stream("POST", path, headers=headers, json=json) as response:
169
+ if response.status_code >= 400:
170
+ response.read()
171
+ raise to_api_error(response)
172
+ chunks: list[bytes] = []
173
+ total = 0
174
+ for chunk in response.iter_bytes():
175
+ total += len(chunk)
176
+ if total > max_bytes:
177
+ raise ResponseSizeExceededError(f"response body exceeds {max_bytes} bytes")
178
+ chunks.append(chunk)
179
+ return b"".join(chunks), response.headers
180
+
110
181
  def post_to_stream_host(self, path: str, *, json: Any = None) -> Any:
111
182
  # Endpoints whose streaming variant lives on the stream host (e.g. chat
112
183
  # completions) must accept their unary counterparts at the same host —
@@ -182,12 +253,22 @@ class HttpClient:
182
253
  logger.debug("dropped non-%s SSE: %s", response_model.__name__, event.data)
183
254
  continue
184
255
 
185
- def _request(self, method: str, path: str, **kwargs: Any) -> Any:
256
+ def _request(
257
+ self,
258
+ method: str,
259
+ path: str,
260
+ *,
261
+ extra_headers: Mapping[str, str] | None = None,
262
+ **kwargs: Any,
263
+ ) -> Any:
186
264
  # Headers are rebuilt per request so ``access_token`` / ``user_id``
187
265
  # can be rotated on a live client without reconstructing it.
188
266
  headers = auth_headers(self.access_token, self.user_id)
189
267
  if not kwargs.get("files"):
190
268
  headers["Content-Type"] = "application/json"
269
+ if extra_headers:
270
+ headers.update(extra_headers)
271
+ kwargs["params"] = self._scoped_params(kwargs.get("params"))
191
272
  response = self._client.request(method, path, headers=headers, **kwargs)
192
273
  if response.status_code >= 400:
193
274
  raise to_api_error(response)
@@ -204,14 +285,26 @@ class AsyncHttpClient:
204
285
  stream_base_url: str,
205
286
  access_token: str | None,
206
287
  user_id: str | None,
288
+ team_id: str | None = None,
207
289
  timeout: float,
208
290
  stream_timeout: float | None,
209
291
  ) -> None:
210
292
  self.access_token = access_token
211
293
  self.user_id = user_id
294
+ self.team_id = team_id
212
295
  self._stream_base_url = stream_base_url.rstrip("/")
213
296
  self._stream_timeout = stream_timeout
214
- self._client = httpx.AsyncClient(base_url=base_url.rstrip("/"), timeout=timeout)
297
+ self._client = httpx.AsyncClient(
298
+ base_url=base_url.rstrip("/"), timeout=timeout, event_hooks=_ASYNC_TRACE_EVENT_HOOKS
299
+ )
300
+
301
+ def _scoped_params(self, params: dict[str, Any] | None) -> dict[str, Any] | None:
302
+ # Team keys are validated against ``team_id``, so it rides on every request.
303
+ if not self.team_id:
304
+ return params
305
+ merged = dict(params or {})
306
+ merged.setdefault("team_id", self.team_id)
307
+ return merged
215
308
 
216
309
  async def aclose(self) -> None:
217
310
  await self._client.aclose()
@@ -325,6 +418,7 @@ class AsyncHttpClient:
325
418
  headers = auth_headers(self.access_token, self.user_id)
326
419
  if not kwargs.get("files"):
327
420
  headers["Content-Type"] = "application/json"
421
+ kwargs["params"] = self._scoped_params(kwargs.get("params"))
328
422
  response = await self._client.request(method, path, headers=headers, **kwargs)
329
423
  if response.status_code >= 400:
330
424
  raise to_api_error(response)
@@ -0,0 +1,3 @@
1
+ from __future__ import annotations
2
+
3
+ __version__ = "0.4.2"
@@ -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"