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.
- {gumloop-0.3.4 → gumloop-0.4.0}/PKG-INFO +1 -1
- {gumloop-0.3.4 → gumloop-0.4.0}/pyproject.toml +2 -1
- {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/__init__.py +2 -1
- gumloop-0.4.0/src/gumloop/_auth.py +35 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/_client.py +5 -2
- {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/_http.py +55 -18
- gumloop-0.4.0/src/gumloop/_version.py +3 -0
- gumloop-0.4.0/src/gumloop/cli/commands/_sync_output.py +328 -0
- gumloop-0.4.0/src/gumloop/cli/commands/sync.py +335 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/cli/main.py +2 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/resources/__init__.py +2 -0
- gumloop-0.4.0/src/gumloop/resources/sync.py +94 -0
- gumloop-0.4.0/src/gumloop/sync/__init__.py +1 -0
- gumloop-0.4.0/src/gumloop/sync/archive.py +131 -0
- gumloop-0.4.0/src/gumloop/sync/bundle.py +71 -0
- gumloop-0.4.0/src/gumloop/sync/bundle_validation.py +121 -0
- gumloop-0.4.0/src/gumloop/sync/errors.py +20 -0
- gumloop-0.4.0/src/gumloop/sync/hashing.py +65 -0
- gumloop-0.4.0/src/gumloop/sync/installed.py +141 -0
- gumloop-0.4.0/src/gumloop/sync/local_state.py +161 -0
- gumloop-0.4.0/src/gumloop/sync/lock.py +50 -0
- gumloop-0.4.0/src/gumloop/sync/markers.py +159 -0
- gumloop-0.4.0/src/gumloop/sync/reconcile.py +219 -0
- gumloop-0.4.0/src/gumloop/sync/reporting.py +136 -0
- gumloop-0.4.0/src/gumloop/sync/results.py +67 -0
- gumloop-0.4.0/src/gumloop/sync/run.py +556 -0
- gumloop-0.4.0/src/gumloop/sync/scheduler.py +294 -0
- gumloop-0.4.0/src/gumloop/sync/target_files.py +421 -0
- gumloop-0.4.0/src/gumloop/sync/targets.py +63 -0
- gumloop-0.4.0/src/gumloop/sync/wire.py +15 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/types.py +67 -0
- gumloop-0.4.0/tests/cli/__init__.py +1 -0
- gumloop-0.4.0/tests/cli/conftest.py +116 -0
- gumloop-0.4.0/tests/cli/sync_scenario_helpers.py +342 -0
- gumloop-0.4.0/tests/cli/sync_test_fakes.py +85 -0
- gumloop-0.4.0/tests/cli/test_home_isolation.py +49 -0
- gumloop-0.4.0/tests/cli/test_sync_bundle.py +359 -0
- gumloop-0.4.0/tests/cli/test_sync_command.py +744 -0
- gumloop-0.4.0/tests/cli/test_sync_command_safety.py +371 -0
- gumloop-0.4.0/tests/cli/test_sync_contract_fixtures.py +187 -0
- gumloop-0.4.0/tests/cli/test_sync_enrollment.py +671 -0
- gumloop-0.4.0/tests/cli/test_sync_fixture_corpus.py +32 -0
- gumloop-0.4.0/tests/cli/test_sync_hashing.py +65 -0
- gumloop-0.4.0/tests/cli/test_sync_installed.py +250 -0
- gumloop-0.4.0/tests/cli/test_sync_reconciliation_safety.py +206 -0
- gumloop-0.4.0/tests/cli/test_sync_scheduler_macos.py +350 -0
- gumloop-0.4.0/tests/cli/test_sync_targets.py +217 -0
- gumloop-0.4.0/tests/fixtures/skill_sync/v1/README.md +19 -0
- gumloop-0.4.0/tests/fixtures/skill_sync/v1/archives/generated-bundle-transfer-limit.json +10 -0
- gumloop-0.4.0/tests/fixtures/skill_sync/v1/archives/invalid-absolute-path.zip +0 -0
- gumloop-0.4.0/tests/fixtures/skill_sync/v1/archives/invalid-bytes-per-file-limit.zip +0 -0
- gumloop-0.4.0/tests/fixtures/skill_sync/v1/archives/invalid-device.zip +0 -0
- gumloop-0.4.0/tests/fixtures/skill_sync/v1/archives/invalid-duplicate-entry.zip +0 -0
- gumloop-0.4.0/tests/fixtures/skill_sync/v1/archives/invalid-fifo.zip +0 -0
- gumloop-0.4.0/tests/fixtures/skill_sync/v1/archives/invalid-files-per-skill-limit.zip +0 -0
- gumloop-0.4.0/tests/fixtures/skill_sync/v1/archives/invalid-nul-name.zip +0 -0
- gumloop-0.4.0/tests/fixtures/skill_sync/v1/archives/invalid-parent-traversal.zip +0 -0
- gumloop-0.4.0/tests/fixtures/skill_sync/v1/archives/invalid-reserved-marker.zip +0 -0
- gumloop-0.4.0/tests/fixtures/skill_sync/v1/archives/invalid-socket.zip +0 -0
- gumloop-0.4.0/tests/fixtures/skill_sync/v1/archives/invalid-symlink.zip +0 -0
- gumloop-0.4.0/tests/fixtures/skill_sync/v1/archives/invalid-total-uncompressed-limit.zip +0 -0
- gumloop-0.4.0/tests/fixtures/skill_sync/v1/archives/invalid-windows-drive-prefix.zip +0 -0
- gumloop-0.4.0/tests/fixtures/skill_sync/v1/archives/valid-single-skill-stored.zip +0 -0
- gumloop-0.4.0/tests/fixtures/skill_sync/v1/archives/valid-single-skill.zip +0 -0
- gumloop-0.4.0/tests/fixtures/skill_sync/v1/bundles/newer-than-plan.zip +0 -0
- gumloop-0.4.0/tests/fixtures/skill_sync/v1/bundles/normal.zip +0 -0
- gumloop-0.4.0/tests/fixtures/skill_sync/v1/catalog.json +179 -0
- gumloop-0.4.0/tests/fixtures/skill_sync/v1/corpus.sha256 +1 -0
- gumloop-0.4.0/tests/fixtures/skill_sync/v1/hashes/content-v1.json +331 -0
- gumloop-0.4.0/tests/fixtures/skill_sync/v1/hashes/manifest-v1.json +130 -0
- gumloop-0.4.0/tests/fixtures/skill_sync/v1/manifests/invalid-duplicate-identity.json +32 -0
- gumloop-0.4.0/tests/fixtures/skill_sync/v1/manifests/invalid-duplicate-install-name.json +32 -0
- gumloop-0.4.0/tests/fixtures/skill_sync/v1/manifests/invalid-manifest-hash.json +26 -0
- gumloop-0.4.0/tests/fixtures/skill_sync/v1/manifests/newer-than-plan.json +26 -0
- gumloop-0.4.0/tests/fixtures/skill_sync/v1/manifests/normal-bundle.json +26 -0
- gumloop-0.4.0/tests/fixtures/skill_sync/v1/responses/below-pro.json +9 -0
- gumloop-0.4.0/tests/fixtures/skill_sync/v1/responses/cli-upgrade-required.json +12 -0
- gumloop-0.4.0/tests/fixtures/skill_sync/v1/responses/lost-membership.json +9 -0
- gumloop-0.4.0/tests/fixtures/skill_sync/v1/responses/normal-empty-plan.json +19 -0
- gumloop-0.4.0/tests/fixtures/skill_sync/v1/responses/normal-plan.json +19 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/tests/sdk/test_client.py +69 -0
- gumloop-0.4.0/tests/sdk/test_sync.py +309 -0
- gumloop-0.4.0/tests/skill_sync_fixtures.py +35 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/uv.lock +1 -1
- gumloop-0.3.4/tests/cli/__init__.py +0 -0
- gumloop-0.3.4/tests/cli/conftest.py +0 -55
- {gumloop-0.3.4 → gumloop-0.4.0}/.env.example +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/.gitignore +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/.pre-commit-config.yaml +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/.python-version +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/LICENSE +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/README.md +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/scripts/check +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/scripts/fmt +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/scripts/sdk_examples.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/cli/__init__.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/cli/commands/__init__.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/cli/commands/_args_input.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/cli/commands/_downloads.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/cli/commands/agents.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/cli/commands/artifacts.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/cli/commands/auth.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/cli/commands/brain.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/cli/commands/chat.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/cli/commands/mcp.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/cli/commands/sessions.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/cli/commands/skills.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/cli/console.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/cli/context.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/cli/credentials.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/cli/errors.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/cli/oauth.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/errors.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/oauth.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/py.typed +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/resources/agents.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/resources/artifacts.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/resources/brain.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/resources/chat.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/resources/mcp.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/resources/sessions.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/resources/skills.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/resources/teams.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/spec/__init__.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/spec/_compat.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/src/gumloop/spec/_extensions.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/tests/__init__.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/tests/cli/test_agents.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/tests/cli/test_args_input.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/tests/cli/test_artifacts.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/tests/cli/test_brain.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/tests/cli/test_chat.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/tests/cli/test_context.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/tests/cli/test_credentials.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/tests/cli/test_login.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/tests/cli/test_main.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/tests/cli/test_mcp.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/tests/cli/test_sessions.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/tests/cli/test_skills.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/tests/conftest.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/tests/integration/conftest.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/tests/integration/test_live.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/tests/sdk/__init__.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/tests/sdk/conftest.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/tests/sdk/helpers.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/tests/sdk/test_agents.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/tests/sdk/test_artifacts.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/tests/sdk/test_brain.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/tests/sdk/test_chat.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/tests/sdk/test_mcp.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/tests/sdk/test_oauth.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/tests/sdk/test_sessions.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/tests/sdk/test_skills.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/tests/sdk/test_teams.py +0 -0
- {gumloop-0.3.4 → gumloop-0.4.0}/tests/test_client.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "gumloop"
|
|
7
|
-
version = "0.
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
27
|
-
|
|
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(
|
|
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 =
|
|
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 = {**
|
|
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 = {**
|
|
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(
|
|
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 =
|
|
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 =
|
|
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 = {**
|
|
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 = {**
|
|
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 =
|
|
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,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"
|