@openhands/extensions 0.8.0 → 0.10.0
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.
- package/.github/workflows/deprecation-check.yml +25 -0
- package/.release-please-manifest.json +1 -1
- package/README.md +3 -2
- package/automations/catalog/jira-issue-to-pr.json +14 -0
- package/automations/index.js +2 -0
- package/integrations/README.md +25 -0
- package/integrations/catalog/atlassian.json +91 -3
- package/integrations/catalog/datadog.json +33 -2
- package/integrations/catalog/github.json +20 -0
- package/integrations/catalog/microsoft-outlook.json +13 -0
- package/integrations/catalog/microsoft-teams.json +13 -0
- package/integrations/catalog/notion.json +13 -0
- package/integrations/catalog/onedrive.json +13 -0
- package/integrations/catalog/sharepoint.json +13 -0
- package/integrations/catalog/slack.json +14 -0
- package/integrations/catalog/superhuman-mail.json +36 -0
- package/integrations/catalog/vanta.json +55 -0
- package/integrations/catalog-index.js +73 -69
- package/integrations/catalog.schema.json +70 -3
- package/integrations/index.d.ts +49 -3
- package/marketplaces/openhands-extensions.json +10 -0
- package/package.json +1 -1
- package/plugins/cobol-modernization/skills/build-setup/SKILL.md +1 -0
- package/plugins/cobol-modernization/skills/cobol-modernization/SKILL.md +9 -0
- package/plugins/migration-scoring/skills/migration-scoring/SKILL.md +11 -0
- package/plugins/onboarding/skills/agent-readiness-report/SKILL.md +2 -0
- package/plugins/openhands/SKILL.md +7 -0
- package/plugins/release-notes/SKILL.md +9 -0
- package/pyproject.toml +6 -1
- package/python/openhands_extensions/__init__.py +38 -0
- package/python/openhands_extensions/_version.py +2 -2
- package/python/openhands_extensions/deprecation.py +159 -0
- package/python/openhands_extensions/integration_models.py +233 -0
- package/python/openhands_extensions/integrations.py +59 -4
- package/scripts/build-skills-catalog.mjs +1 -1
- package/scripts/check_deprecations.py +488 -0
- package/skills/add-skill/SKILL.md +2 -0
- package/skills/azure-devops/SKILL.md +12 -1
- package/skills/bitbucket-cloud/SKILL.md +11 -0
- package/skills/code-review/SKILL.md +1 -0
- package/skills/datadog/SKILL.md +3 -1
- package/skills/datadog/references/windows.md +45 -0
- package/skills/discord/SKILL.md +2 -0
- package/skills/docker/SKILL.md +5 -1
- package/skills/github/SKILL.md +3 -1
- package/skills/github/references/windows.md +48 -0
- package/skills/github-pr-review/SKILL.md +1 -0
- package/skills/github-pr-review/references/windows.md +47 -0
- package/skills/github-pr-reviewer/SKILL.md +1 -0
- package/skills/github-pr-reviewer/references/windows.md +61 -0
- package/skills/github-repo-monitor/SKILL.md +1 -0
- package/skills/github-repo-monitor/references/windows.md +61 -0
- package/skills/gitlab/SKILL.md +3 -1
- package/skills/incident-retrospective/SKILL.md +2 -0
- package/skills/index.js +36 -30
- package/skills/iterate/SKILL.md +1 -0
- package/skills/iterate/references/windows.md +41 -0
- package/skills/jira-issue-to-pr/.plugin/plugin.json +19 -0
- package/skills/jira-issue-to-pr/README.md +56 -0
- package/skills/jira-issue-to-pr/SKILL.md +216 -0
- package/skills/jira-issue-to-pr/references/setup.md +170 -0
- package/skills/jira-issue-to-pr/scripts/main.py +341 -0
- package/skills/jupyter/SKILL.md +7 -1
- package/skills/kubernetes/SKILL.md +2 -1
- package/skills/kubernetes/references/windows.md +38 -0
- package/skills/linear/SKILL.md +2 -0
- package/skills/linear/references/windows.md +29 -0
- package/skills/linear-triage/SKILL.md +2 -0
- package/skills/notion/SKILL.md +2 -0
- package/skills/notion/references/windows.md +43 -0
- package/skills/openhands-api/SKILL.md +1 -0
- package/skills/openhands-api/references/windows.md +44 -0
- package/skills/openhands-automation/SKILL.md +1 -0
- package/skills/openhands-automation/references/custom-automation.md +20 -0
- package/skills/openhands-automation/references/windows.md +57 -0
- package/skills/pdflatex/SKILL.md +3 -1
- package/skills/research-brief/SKILL.md +2 -0
- package/skills/skill-creator/SKILL.md +1 -0
- package/skills/skill-creator/references/windows.md +30 -0
- package/skills/slack-channel-monitor/SKILL.md +1 -0
- package/skills/slack-channel-monitor/references/windows.md +64 -0
- package/skills/slack-standup-digest/SKILL.md +2 -0
- package/skills/spark-version-upgrade/SKILL.md +2 -1
- package/skills/ssh/SKILL.md +2 -1
- package/skills/ssh/references/windows.md +59 -0
- package/skills/swift-linux/SKILL.md +2 -1
- package/skills/vercel/SKILL.md +5 -0
- package/tests/test_catalog_schema.py +58 -0
- package/tests/test_catalogs.py +6 -1
- package/tests/test_deprecations.py +136 -0
- package/tests/test_integration_catalog_in_sync.py +118 -7
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Versioned deprecation helpers for OpenHands extensions.
|
|
2
|
+
|
|
3
|
+
This module intentionally mirrors ``openhands.sdk.utils.deprecation`` so public
|
|
4
|
+
extension APIs use the same decorator, runtime warning, and model-input
|
|
5
|
+
compatibility behavior as the SDK.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import warnings
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from datetime import date
|
|
13
|
+
from functools import cache
|
|
14
|
+
from importlib.metadata import PackageNotFoundError, version as get_version
|
|
15
|
+
from typing import Any, TypeVar, cast
|
|
16
|
+
|
|
17
|
+
from deprecation import (
|
|
18
|
+
DeprecatedWarning,
|
|
19
|
+
UnsupportedWarning,
|
|
20
|
+
deprecated as _deprecated,
|
|
21
|
+
)
|
|
22
|
+
from packaging import version as pkg_version
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_FuncT = TypeVar("_FuncT", bound=Callable[..., Any])
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@cache
|
|
29
|
+
def _current_version() -> str:
|
|
30
|
+
try:
|
|
31
|
+
return get_version("openhands-extensions")
|
|
32
|
+
except PackageNotFoundError:
|
|
33
|
+
return "0.0.0"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def deprecated(
|
|
37
|
+
*,
|
|
38
|
+
deprecated_in: str,
|
|
39
|
+
removed_in: str | date | None,
|
|
40
|
+
current_version: str | None = None,
|
|
41
|
+
details: str = "",
|
|
42
|
+
) -> Callable[[_FuncT], _FuncT]:
|
|
43
|
+
"""Return a decorator that deprecates a callable with explicit metadata."""
|
|
44
|
+
base_decorator = _deprecated(
|
|
45
|
+
deprecated_in=deprecated_in,
|
|
46
|
+
removed_in=removed_in,
|
|
47
|
+
current_version=current_version or _current_version(),
|
|
48
|
+
details=details,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def decorator(func: _FuncT) -> _FuncT:
|
|
52
|
+
return cast(_FuncT, base_decorator(func))
|
|
53
|
+
|
|
54
|
+
return decorator
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _should_warn(
|
|
58
|
+
*,
|
|
59
|
+
deprecated_in: str | None,
|
|
60
|
+
removed_in: str | date | None,
|
|
61
|
+
current_version: str | None,
|
|
62
|
+
) -> tuple[bool, bool]:
|
|
63
|
+
is_deprecated = False
|
|
64
|
+
is_unsupported = False
|
|
65
|
+
|
|
66
|
+
if isinstance(removed_in, date):
|
|
67
|
+
if date.today() >= removed_in:
|
|
68
|
+
is_unsupported = True
|
|
69
|
+
else:
|
|
70
|
+
is_deprecated = True
|
|
71
|
+
elif current_version:
|
|
72
|
+
current = pkg_version.parse(current_version)
|
|
73
|
+
if removed_in and current >= pkg_version.parse(str(removed_in)):
|
|
74
|
+
is_unsupported = True
|
|
75
|
+
elif deprecated_in and current >= pkg_version.parse(deprecated_in):
|
|
76
|
+
is_deprecated = True
|
|
77
|
+
else:
|
|
78
|
+
is_deprecated = True
|
|
79
|
+
|
|
80
|
+
return is_deprecated, is_unsupported
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def warn_deprecated(
|
|
84
|
+
feature: str,
|
|
85
|
+
*,
|
|
86
|
+
deprecated_in: str,
|
|
87
|
+
removed_in: str | date | None,
|
|
88
|
+
current_version: str | None = None,
|
|
89
|
+
details: str = "",
|
|
90
|
+
stacklevel: int = 2,
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Emit a deprecation warning for dynamic access to a legacy feature."""
|
|
93
|
+
current_version = current_version or _current_version()
|
|
94
|
+
is_deprecated, is_unsupported = _should_warn(
|
|
95
|
+
deprecated_in=deprecated_in,
|
|
96
|
+
removed_in=removed_in,
|
|
97
|
+
current_version=current_version,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
if not (is_deprecated or is_unsupported):
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
warning_cls = UnsupportedWarning if is_unsupported else DeprecatedWarning
|
|
104
|
+
warning = warning_cls(feature, deprecated_in, removed_in, details)
|
|
105
|
+
warnings.warn(warning, stacklevel=stacklevel)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def warn_cleanup(
|
|
109
|
+
workaround: str,
|
|
110
|
+
*,
|
|
111
|
+
cleanup_by: str | date,
|
|
112
|
+
current_version: str | None = None,
|
|
113
|
+
details: str = "",
|
|
114
|
+
stacklevel: int = 2,
|
|
115
|
+
) -> None:
|
|
116
|
+
"""Emit a warning when a temporary workaround has reached its deadline."""
|
|
117
|
+
current_version = current_version or _current_version()
|
|
118
|
+
|
|
119
|
+
should_cleanup = False
|
|
120
|
+
if isinstance(cleanup_by, date):
|
|
121
|
+
should_cleanup = date.today() >= cleanup_by
|
|
122
|
+
else:
|
|
123
|
+
try:
|
|
124
|
+
current = pkg_version.parse(current_version)
|
|
125
|
+
target = pkg_version.parse(str(cleanup_by))
|
|
126
|
+
should_cleanup = current >= target
|
|
127
|
+
except pkg_version.InvalidVersion:
|
|
128
|
+
pass
|
|
129
|
+
|
|
130
|
+
if should_cleanup:
|
|
131
|
+
message = (
|
|
132
|
+
f"Cleanup required: {workaround}. "
|
|
133
|
+
f"This workaround was scheduled for removal by {cleanup_by}."
|
|
134
|
+
)
|
|
135
|
+
if details:
|
|
136
|
+
message += f" {details}"
|
|
137
|
+
warnings.warn(message, UserWarning, stacklevel=stacklevel)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def handle_deprecated_model_fields(
|
|
141
|
+
data: Any,
|
|
142
|
+
deprecated_fields: tuple[str, ...],
|
|
143
|
+
) -> Any:
|
|
144
|
+
"""Remove permanently supported legacy fields from Pydantic model input."""
|
|
145
|
+
if not isinstance(data, dict):
|
|
146
|
+
return data
|
|
147
|
+
|
|
148
|
+
for field in deprecated_fields:
|
|
149
|
+
data.pop(field, None)
|
|
150
|
+
|
|
151
|
+
return data
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
__all__ = [
|
|
155
|
+
"deprecated",
|
|
156
|
+
"warn_deprecated",
|
|
157
|
+
"warn_cleanup",
|
|
158
|
+
"handle_deprecated_model_fields",
|
|
159
|
+
]
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Typed models for the OpenHands integration catalog.
|
|
2
|
+
|
|
3
|
+
The hand-authored JSON files under ``integrations/catalog`` remain the source
|
|
4
|
+
of truth. These models provide a Python representation of the public catalog
|
|
5
|
+
contract defined by ``integrations/catalog.schema.json`` and
|
|
6
|
+
``integrations/index.d.ts``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Annotated, Any, Literal
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
NonEmptyString = Annotated[str, Field(min_length=1)]
|
|
17
|
+
HttpsUrl = Annotated[str, Field(pattern=r"^https://", min_length=8)]
|
|
18
|
+
IdentityPath = Annotated[
|
|
19
|
+
str,
|
|
20
|
+
Field(pattern=r"^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*$", min_length=1),
|
|
21
|
+
]
|
|
22
|
+
IntegrationAuthStrategy = Literal["none", "api_key", "bearer", "basic", "oauth2"]
|
|
23
|
+
IntegrationProvider = Literal["mcp", "http"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CatalogModel(BaseModel):
|
|
27
|
+
"""Base model for strict, portable catalog data."""
|
|
28
|
+
|
|
29
|
+
model_config = ConfigDict(extra="forbid")
|
|
30
|
+
|
|
31
|
+
@model_validator(mode="before")
|
|
32
|
+
@classmethod
|
|
33
|
+
def reject_explicit_nulls(cls, values: Any) -> Any:
|
|
34
|
+
"""Match the JSON Schema: optional fields may be omitted, never null."""
|
|
35
|
+
if not isinstance(values, dict):
|
|
36
|
+
return values
|
|
37
|
+
|
|
38
|
+
null_fields = [field for field, value in values.items() if value is None]
|
|
39
|
+
if null_fields:
|
|
40
|
+
raise ValueError(
|
|
41
|
+
"Catalog fields must be omitted instead of set to null: "
|
|
42
|
+
f"{', '.join(null_fields)}"
|
|
43
|
+
)
|
|
44
|
+
return values
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class MarketplaceField(CatalogModel):
|
|
48
|
+
key: NonEmptyString
|
|
49
|
+
label: NonEmptyString
|
|
50
|
+
type: Literal["text", "password"]
|
|
51
|
+
placeholder: str | None = None
|
|
52
|
+
helperText: str | None = None
|
|
53
|
+
helperLink: HttpsUrl | None = None
|
|
54
|
+
required: bool
|
|
55
|
+
|
|
56
|
+
@model_validator(mode="after")
|
|
57
|
+
def password_fields_have_help(self) -> MarketplaceField:
|
|
58
|
+
if self.type == "password" and not self.helperText:
|
|
59
|
+
raise ValueError("Password marketplace fields require helperText.")
|
|
60
|
+
return self
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class StreamableHttpTransport(CatalogModel):
|
|
64
|
+
kind: Literal["shttp"]
|
|
65
|
+
url: HttpsUrl
|
|
66
|
+
apiKeyOptional: bool | None = None
|
|
67
|
+
headerFields: list[MarketplaceField] | None = None
|
|
68
|
+
urlEditable: bool | None = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class SseTransport(CatalogModel):
|
|
72
|
+
kind: Literal["sse"]
|
|
73
|
+
url: HttpsUrl
|
|
74
|
+
apiKeyOptional: bool | None = None
|
|
75
|
+
headerFields: list[MarketplaceField] | None = None
|
|
76
|
+
urlEditable: bool | None = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class StdioTransport(CatalogModel):
|
|
80
|
+
kind: Literal["stdio"]
|
|
81
|
+
serverName: NonEmptyString
|
|
82
|
+
command: NonEmptyString
|
|
83
|
+
args: list[str]
|
|
84
|
+
envFields: list[MarketplaceField] | None = None
|
|
85
|
+
argFields: list[MarketplaceField] | None = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
IntegrationTransport = Annotated[
|
|
89
|
+
StreamableHttpTransport | SseTransport | StdioTransport,
|
|
90
|
+
Field(discriminator="kind"),
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class IntegrationOAuthConfig(CatalogModel):
|
|
95
|
+
authorizationUrl: HttpsUrl | None = None
|
|
96
|
+
tokenUrl: HttpsUrl | None = None
|
|
97
|
+
scopes: list[NonEmptyString] | None = None
|
|
98
|
+
optionalScopes: list[NonEmptyString] | None = None
|
|
99
|
+
toolScopes: list[NonEmptyString] | None = None
|
|
100
|
+
scopeSeparator: Literal["space", "comma"] | None = None
|
|
101
|
+
pkce: bool | None = None
|
|
102
|
+
clientAuthentication: Literal["basic", "body", "none"] | None = None
|
|
103
|
+
registrationUrl: HttpsUrl | None = None
|
|
104
|
+
additionalAuthorizationParams: dict[str, str] | None = None
|
|
105
|
+
additionalTokenParams: dict[str, str] | None = None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class IntegrationAuthConfig(CatalogModel):
|
|
109
|
+
strategy: IntegrationAuthStrategy
|
|
110
|
+
authModes: list[IntegrationAuthStrategy] | None = Field(default=None, min_length=1)
|
|
111
|
+
credentialLabel: NonEmptyString | None = None
|
|
112
|
+
credentialPlaceholder: NonEmptyString | None = None
|
|
113
|
+
credentialHelp: NonEmptyString | None = None
|
|
114
|
+
credentialSecretName: NonEmptyString | None = None
|
|
115
|
+
saveCredentialAsSecretByDefault: bool | None = None
|
|
116
|
+
apiKeyHeaderName: NonEmptyString | None = None
|
|
117
|
+
apiKeyOptional: bool | None = None
|
|
118
|
+
oauth: IntegrationOAuthConfig | None = None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class IntegrationHttpConfig(CatalogModel):
|
|
122
|
+
apiBaseUrl: HttpsUrl
|
|
123
|
+
openApiUrl: HttpsUrl
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class IntegrationIdentityMapping(CatalogModel):
|
|
127
|
+
source: Literal["oauth_token_response", "access_token_claims", "identity_api"]
|
|
128
|
+
endpoint: HttpsUrl | None = None
|
|
129
|
+
externalPrincipalIdPath: IdentityPath
|
|
130
|
+
externalTenantIdPath: IdentityPath | None = None
|
|
131
|
+
externalResourceIdPath: IdentityPath | None = None
|
|
132
|
+
resourceNamePath: IdentityPath | None = None
|
|
133
|
+
resourceUrlPath: IdentityPath | None = None
|
|
134
|
+
|
|
135
|
+
@model_validator(mode="after")
|
|
136
|
+
def identity_api_requires_endpoint(self) -> IntegrationIdentityMapping:
|
|
137
|
+
if self.source == "identity_api" and not self.endpoint:
|
|
138
|
+
raise ValueError("identity_api mappings require an endpoint.")
|
|
139
|
+
if self.source != "identity_api" and self.endpoint:
|
|
140
|
+
raise ValueError("Only identity_api mappings may define an endpoint.")
|
|
141
|
+
return self
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class IntegrationResourceDiscovery(CatalogModel):
|
|
145
|
+
endpoint: HttpsUrl
|
|
146
|
+
itemsPath: IdentityPath | None = None
|
|
147
|
+
externalResourceIdPath: IdentityPath
|
|
148
|
+
resourceNamePath: IdentityPath | None = None
|
|
149
|
+
resourceUrlPath: IdentityPath | None = None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class IntegrationConnectionModel(CatalogModel):
|
|
153
|
+
principalType: Literal["user", "bot", "service_account", "application"]
|
|
154
|
+
credentialScope: Literal["account", "tenant", "organization", "resource"]
|
|
155
|
+
resourceType: NonEmptyString
|
|
156
|
+
resourceCardinality: Literal["one", "many"]
|
|
157
|
+
selectionMode: Literal["automatic", "post_auth", "runtime"]
|
|
158
|
+
identityMapping: IntegrationIdentityMapping | None = None
|
|
159
|
+
resourceDiscovery: IntegrationResourceDiscovery | None = None
|
|
160
|
+
|
|
161
|
+
@model_validator(mode="after")
|
|
162
|
+
def post_auth_requires_resource_discovery(self) -> IntegrationConnectionModel:
|
|
163
|
+
if self.selectionMode == "post_auth" and not self.resourceDiscovery:
|
|
164
|
+
raise ValueError("post_auth connections require resourceDiscovery.")
|
|
165
|
+
return self
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class McpConnectionOption(CatalogModel):
|
|
169
|
+
id: NonEmptyString
|
|
170
|
+
provider: Literal["mcp"]
|
|
171
|
+
transport: IntegrationTransport
|
|
172
|
+
auth: IntegrationAuthConfig
|
|
173
|
+
connectionModel: IntegrationConnectionModel | None = None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class HttpConnectionOption(CatalogModel):
|
|
177
|
+
id: NonEmptyString
|
|
178
|
+
provider: Literal["http"]
|
|
179
|
+
http: IntegrationHttpConfig
|
|
180
|
+
auth: IntegrationAuthConfig
|
|
181
|
+
connectionModel: IntegrationConnectionModel | None = None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
IntegrationConnectionOption = Annotated[
|
|
185
|
+
McpConnectionOption | HttpConnectionOption,
|
|
186
|
+
Field(discriminator="provider"),
|
|
187
|
+
]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class IntegrationCatalogEntry(CatalogModel):
|
|
191
|
+
id: NonEmptyString
|
|
192
|
+
name: NonEmptyString
|
|
193
|
+
description: NonEmptyString
|
|
194
|
+
categories: list[NonEmptyString] | None = Field(default=None, min_length=1)
|
|
195
|
+
appUrl: HttpsUrl | None = None
|
|
196
|
+
docsUrl: HttpsUrl
|
|
197
|
+
notes: NonEmptyString | None = None
|
|
198
|
+
iconBg: NonEmptyString | None = None
|
|
199
|
+
iconColor: NonEmptyString | None = None
|
|
200
|
+
logoUrl: HttpsUrl | None = None
|
|
201
|
+
keywords: list[NonEmptyString] | None = None
|
|
202
|
+
popularityRank: int | None = Field(default=None, ge=0)
|
|
203
|
+
installHint: NonEmptyString | None = None
|
|
204
|
+
connectionOptions: list[IntegrationConnectionOption] = Field(min_length=1)
|
|
205
|
+
|
|
206
|
+
@field_validator("categories", "keywords")
|
|
207
|
+
@classmethod
|
|
208
|
+
def list_values_are_unique(
|
|
209
|
+
cls, values: list[NonEmptyString] | None
|
|
210
|
+
) -> list[NonEmptyString] | None:
|
|
211
|
+
if values is not None and len(values) != len(set(values)):
|
|
212
|
+
raise ValueError("Catalog list values must be unique.")
|
|
213
|
+
return values
|
|
214
|
+
|
|
215
|
+
@model_validator(mode="after")
|
|
216
|
+
def validate_auth_requirements(self) -> IntegrationCatalogEntry:
|
|
217
|
+
for option in self.connectionOptions:
|
|
218
|
+
if option.provider == "http" and option.auth.strategy == "api_key":
|
|
219
|
+
if not option.auth.apiKeyHeaderName:
|
|
220
|
+
raise ValueError(
|
|
221
|
+
"HTTP API-key connections require apiKeyHeaderName."
|
|
222
|
+
)
|
|
223
|
+
if option.provider == "http" and option.auth.strategy == "oauth2":
|
|
224
|
+
oauth = option.auth.oauth
|
|
225
|
+
if not oauth or not oauth.authorizationUrl or not oauth.tokenUrl:
|
|
226
|
+
raise ValueError(
|
|
227
|
+
"HTTP OAuth connections require authorizationUrl and tokenUrl."
|
|
228
|
+
)
|
|
229
|
+
return self
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class IntegrationCatalogSnapshot(CatalogModel):
|
|
233
|
+
integrations: list[IntegrationCatalogEntry]
|
|
@@ -14,10 +14,16 @@ from importlib import resources
|
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
from typing import Any, Iterable
|
|
16
16
|
|
|
17
|
+
from .deprecation import deprecated
|
|
18
|
+
from .integration_models import IntegrationCatalogEntry
|
|
19
|
+
|
|
20
|
+
|
|
17
21
|
__all__ = [
|
|
18
22
|
"INTEGRATION_CATALOG_SNAPSHOT",
|
|
19
23
|
"get_integration_catalog_entry",
|
|
24
|
+
"get_integration_catalog_entry_model",
|
|
20
25
|
"list_integration_catalog",
|
|
26
|
+
"list_integration_catalog_models",
|
|
21
27
|
]
|
|
22
28
|
|
|
23
29
|
|
|
@@ -45,7 +51,14 @@ def _read_json(path: Any) -> dict[str, Any]:
|
|
|
45
51
|
def _integrations() -> tuple[dict[str, Any], ...]:
|
|
46
52
|
entries = [_read_json(path) for path in _catalog_files()]
|
|
47
53
|
entries.sort(
|
|
48
|
-
key=lambda entry: (
|
|
54
|
+
key=lambda entry: (
|
|
55
|
+
-(
|
|
56
|
+
entry.get("popularityRank")
|
|
57
|
+
if entry.get("popularityRank") is not None
|
|
58
|
+
else -1
|
|
59
|
+
),
|
|
60
|
+
entry["id"],
|
|
61
|
+
),
|
|
49
62
|
)
|
|
50
63
|
return tuple(entries)
|
|
51
64
|
|
|
@@ -56,7 +69,9 @@ def _integration_by_id() -> dict[str, dict[str, Any]]:
|
|
|
56
69
|
|
|
57
70
|
|
|
58
71
|
def _entry_supports_mcp(entry: dict[str, Any]) -> bool:
|
|
59
|
-
return any(
|
|
72
|
+
return any(
|
|
73
|
+
option.get("provider") == "mcp" for option in entry.get("connectionOptions", [])
|
|
74
|
+
)
|
|
60
75
|
|
|
61
76
|
|
|
62
77
|
def _entry_supports_oauth(entry: dict[str, Any]) -> bool:
|
|
@@ -66,11 +81,20 @@ def _entry_supports_oauth(entry: dict[str, Any]) -> bool:
|
|
|
66
81
|
)
|
|
67
82
|
|
|
68
83
|
|
|
84
|
+
@deprecated(
|
|
85
|
+
deprecated_in="0.10.0",
|
|
86
|
+
removed_in="0.12.0",
|
|
87
|
+
details="Use list_integration_catalog_models() for validated Pydantic models.",
|
|
88
|
+
)
|
|
69
89
|
def list_integration_catalog(
|
|
70
90
|
mcp: bool | None = None,
|
|
71
91
|
oauth: bool | None = None,
|
|
72
92
|
) -> list[dict[str, Any]]:
|
|
73
|
-
"""Return
|
|
93
|
+
"""Return raw catalog dictionaries, optionally filtered by connector type.
|
|
94
|
+
|
|
95
|
+
Deprecated since v0.10.0 and scheduled for removal in v0.12.0.
|
|
96
|
+
Use :func:`list_integration_catalog_models` instead.
|
|
97
|
+
"""
|
|
74
98
|
result = []
|
|
75
99
|
for entry in _integrations():
|
|
76
100
|
if mcp is not None and _entry_supports_mcp(entry) != mcp:
|
|
@@ -81,12 +105,43 @@ def list_integration_catalog(
|
|
|
81
105
|
return result
|
|
82
106
|
|
|
83
107
|
|
|
108
|
+
def list_integration_catalog_models(
|
|
109
|
+
mcp: bool | None = None,
|
|
110
|
+
oauth: bool | None = None,
|
|
111
|
+
) -> list[IntegrationCatalogEntry]:
|
|
112
|
+
"""Return typed, validated integration catalog entries.
|
|
113
|
+
|
|
114
|
+
``list_integration_catalog`` remains available for callers that need the
|
|
115
|
+
original JSON-compatible dictionaries. New Python consumers should prefer
|
|
116
|
+
this function for a stable, validated contract.
|
|
117
|
+
"""
|
|
118
|
+
return [
|
|
119
|
+
IntegrationCatalogEntry.model_validate(entry)
|
|
120
|
+
for entry in list_integration_catalog(mcp=mcp, oauth=oauth)
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@deprecated(
|
|
125
|
+
deprecated_in="0.10.0",
|
|
126
|
+
removed_in="0.12.0",
|
|
127
|
+
details="Use get_integration_catalog_entry_model() for a validated Pydantic model.",
|
|
128
|
+
)
|
|
84
129
|
def get_integration_catalog_entry(id: str) -> dict[str, Any] | None:
|
|
85
|
-
"""Return one
|
|
130
|
+
"""Return one raw catalog dictionary by id, or ``None``.
|
|
131
|
+
|
|
132
|
+
Deprecated since v0.10.0 and scheduled for removal in v0.12.0.
|
|
133
|
+
Use :func:`get_integration_catalog_entry_model` instead.
|
|
134
|
+
"""
|
|
86
135
|
entry = _integration_by_id().get(id)
|
|
87
136
|
return copy.deepcopy(entry) if entry is not None else None
|
|
88
137
|
|
|
89
138
|
|
|
139
|
+
def get_integration_catalog_entry_model(id: str) -> IntegrationCatalogEntry | None:
|
|
140
|
+
"""Return one typed catalog entry, or ``None`` when it does not exist."""
|
|
141
|
+
entry = get_integration_catalog_entry(id)
|
|
142
|
+
return IntegrationCatalogEntry.model_validate(entry) if entry is not None else None
|
|
143
|
+
|
|
144
|
+
|
|
90
145
|
INTEGRATION_CATALOG_SNAPSHOT: dict[str, Any] = {
|
|
91
146
|
"integrations": copy.deepcopy(list(_integrations()))
|
|
92
147
|
}
|
|
@@ -61,7 +61,7 @@ export function buildCatalog(skillsDir) {
|
|
|
61
61
|
const skillMd = join(dirPath, "SKILL.md");
|
|
62
62
|
if (!existsSync(skillMd)) continue;
|
|
63
63
|
|
|
64
|
-
const raw = readFileSync(skillMd, "utf-8");
|
|
64
|
+
const raw = readFileSync(skillMd, "utf-8").replace(/\r\n?/g, "\n");
|
|
65
65
|
const parts = raw.split("---");
|
|
66
66
|
if (parts.length < 3) {
|
|
67
67
|
console.warn(`Warning: ${skillMd} missing frontmatter sections, skipping`);
|