synax-sdk 0.2.0__py3-none-any.whl

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 (51) hide show
  1. synax_sdk/__init__.py +42 -0
  2. synax_sdk/build.py +164 -0
  3. synax_sdk/errors.py +140 -0
  4. synax_sdk/manifest.py +305 -0
  5. synax_sdk/packaging.py +202 -0
  6. synax_sdk/publishing.py +372 -0
  7. synax_sdk/py.typed +0 -0
  8. synax_sdk/runtime/__init__.py +24 -0
  9. synax_sdk/runtime/_backends/__init__.py +2 -0
  10. synax_sdk/runtime/_backends/base.py +142 -0
  11. synax_sdk/runtime/_backends/mock.py +245 -0
  12. synax_sdk/runtime/_capabilities.py +104 -0
  13. synax_sdk/runtime/context.py +86 -0
  14. synax_sdk/runtime/logger.py +62 -0
  15. synax_sdk/runtime/metrics.py +90 -0
  16. synax_sdk/runtime/network.py +255 -0
  17. synax_sdk/runtime/secrets.py +26 -0
  18. synax_sdk/schema/__init__.py +34 -0
  19. synax_sdk/schema/manifest_v1_0.json +207 -0
  20. synax_sdk/signing.py +343 -0
  21. synax_sdk/skill.py +385 -0
  22. synax_sdk/testing/__init__.py +41 -0
  23. synax_sdk/testing/_mock_platform.py +323 -0
  24. synax_sdk/testing/declarative.py +372 -0
  25. synax_sdk/testing/pytest_plugin.py +63 -0
  26. synax_sdk/testing/runners.py +42 -0
  27. synax_sdk/tool.py +401 -0
  28. synax_sdk/validation.py +179 -0
  29. synax_sdk/version.py +3 -0
  30. synax_sdk-0.2.0.dist-info/METADATA +197 -0
  31. synax_sdk-0.2.0.dist-info/RECORD +51 -0
  32. synax_sdk-0.2.0.dist-info/WHEEL +4 -0
  33. synax_sdk-0.2.0.dist-info/entry_points.txt +5 -0
  34. synax_sdk-0.2.0.dist-info/licenses/LICENSE +201 -0
  35. synax_skill_cli/__init__.py +1 -0
  36. synax_skill_cli/__main__.py +6 -0
  37. synax_skill_cli/commands/__init__.py +1 -0
  38. synax_skill_cli/commands/keys.py +197 -0
  39. synax_skill_cli/commands/skill.py +534 -0
  40. synax_skill_cli/main.py +75 -0
  41. synax_skill_cli/templates/default/.gitignore.tmpl +22 -0
  42. synax_skill_cli/templates/default/.synax-skillignore.tmpl +24 -0
  43. synax_skill_cli/templates/default/README.md.tmpl +40 -0
  44. synax_skill_cli/templates/default/pyproject.toml.tmpl +27 -0
  45. synax_skill_cli/templates/default/skill.py.tmpl +29 -0
  46. synax_skill_cli/templates/default/test_inputs/greet/case_basic.yaml.tmpl +13 -0
  47. synax_skill_cli/templates/default/tests/test_skill.py.tmpl +24 -0
  48. synax_skill_cli/utils/__init__.py +1 -0
  49. synax_skill_cli/utils/config.py +131 -0
  50. synax_skill_cli/utils/project.py +146 -0
  51. synax_skill_cli/utils/templating.py +104 -0
synax_sdk/__init__.py ADDED
@@ -0,0 +1,42 @@
1
+ """Synax SDK — Python library for building skills on the Synax agentic platform.
2
+
3
+ Public API surface. Anything exported here is part of the stable (within a
4
+ minor version) API; anything not exported is internal and may change.
5
+
6
+ See ``docs/build_plan.md`` for what's wired up at each milestone.
7
+ """
8
+
9
+ from synax_sdk.errors import (
10
+ CapabilityError,
11
+ ManifestError,
12
+ SkillDefinitionError,
13
+ SkillRuntimeError,
14
+ SkillValidationError,
15
+ SynaxSDKError,
16
+ )
17
+ from synax_sdk.manifest import Manifest
18
+ from synax_sdk.runtime import Context, Invoker, ctx, logger, metrics, network, secrets
19
+ from synax_sdk.skill import Secret, Skill, SkillDependency, SkillMetadata
20
+ from synax_sdk.version import __version__
21
+
22
+ __all__ = [
23
+ "CapabilityError",
24
+ "Context",
25
+ "Invoker",
26
+ "Manifest",
27
+ "ManifestError",
28
+ "Secret",
29
+ "Skill",
30
+ "SkillDefinitionError",
31
+ "SkillDependency",
32
+ "SkillMetadata",
33
+ "SkillRuntimeError",
34
+ "SkillValidationError",
35
+ "SynaxSDKError",
36
+ "__version__",
37
+ "ctx",
38
+ "logger",
39
+ "metrics",
40
+ "network",
41
+ "secrets",
42
+ ]
synax_sdk/build.py ADDED
@@ -0,0 +1,164 @@
1
+ """End-to-end build pipeline: derive manifest → validate → package → sign → write.
2
+
3
+ This module orchestrates every other M4 module. The CLI's
4
+ ``synax skill build`` command loads the :class:`Skill` and the project's
5
+ ``entry_point`` (via :mod:`synax_skill_cli.utils.project`) and passes
6
+ them in; ``synax_sdk.build`` itself stays independent of the CLI package
7
+ to avoid a circular dependency.
8
+
9
+ The pipeline:
10
+
11
+ 1. Caller has already loaded a :class:`Skill` and read the project's
12
+ ``entry_point`` from ``pyproject.toml [tool.synax]``.
13
+ 2. Derive an in-memory :class:`Manifest` (M2 logic).
14
+ 3. Build the deterministic tar.gz artifact (M4 packaging); pin its
15
+ ``sha256`` onto ``implementation.hash`` and set ``entry_point``.
16
+ 4. Validate the now-populated manifest. Schema-layer and consistency
17
+ errors are blocking; warnings are surfaced but don't fail the build.
18
+ 5. Sign the canonical form with the chosen key (defaults to the store's
19
+ active key). Append the :class:`Signature` to the manifest.
20
+ 6. Write the signed bundle to ``<project_root>/build/`` by default.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import datetime
26
+ from dataclasses import dataclass
27
+ from pathlib import Path
28
+
29
+ from synax_sdk.errors import BuildError
30
+ from synax_sdk.manifest import Manifest, Signature
31
+ from synax_sdk.packaging import ArtifactResult, build_implementation_artifact
32
+ from synax_sdk.signing import KeyStore
33
+ from synax_sdk.skill import Skill
34
+ from synax_sdk.validation import ValidationError, validate_manifest
35
+
36
+ DEFAULT_OUTPUT_SUBDIR = "build"
37
+ BUNDLE_SUBDIR = "signed_bundle"
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class BuildResult:
42
+ """Everything a successful build produced."""
43
+
44
+ manifest: Manifest
45
+ artifact: ArtifactResult
46
+ signature: Signature
47
+ output_dir: Path
48
+ bundle_dir: Path
49
+ warnings: list[ValidationError]
50
+
51
+
52
+ def build(
53
+ project_root: Path,
54
+ skill: Skill,
55
+ *,
56
+ entry_point: str,
57
+ key_fingerprint: str | None = None,
58
+ output_dir: Path | None = None,
59
+ key_store: KeyStore | None = None,
60
+ ) -> BuildResult:
61
+ """Run the full build pipeline and return the :class:`BuildResult`.
62
+
63
+ Args:
64
+ project_root: the directory whose contents will be packaged.
65
+ skill: the loaded skill object (caller has already imported it).
66
+ entry_point: ``module:variable`` import path (becomes
67
+ ``implementation.entry_point`` in the manifest).
68
+ key_fingerprint: signing key. Defaults to the key store's active
69
+ default (``synax keys set-default``).
70
+ output_dir: where to write build outputs. Defaults to
71
+ ``<project_root>/build``.
72
+ key_store: override the key store location (test seam). Defaults
73
+ to ``~/.synax/keys``.
74
+
75
+ Raises:
76
+ BuildError: if validation fails with blocking errors, or no
77
+ signing key is available.
78
+ """
79
+ project_root = project_root.resolve()
80
+ output_dir = (output_dir or project_root / DEFAULT_OUTPUT_SUBDIR).resolve()
81
+ key_store = key_store or KeyStore.default()
82
+
83
+ manifest = Manifest.from_skill(skill)
84
+ manifest.implementation.entry_point = entry_point
85
+
86
+ artifact = build_implementation_artifact(
87
+ project_root,
88
+ extra_ignore=_build_output_relative_pattern(project_root, output_dir),
89
+ )
90
+ manifest.implementation.hash = artifact.sha256
91
+
92
+ errors = validate_manifest(manifest)
93
+ blocking = [e for e in errors if e.kind != "warning"]
94
+ warnings = [e for e in errors if e.kind == "warning"]
95
+ if blocking:
96
+ raise BuildError(
97
+ f"Manifest failed validation ({len(blocking)} error(s)). "
98
+ "Fix the issues below and re-run `synax skill build`.",
99
+ errors=list(blocking),
100
+ )
101
+
102
+ fingerprint = key_fingerprint or key_store.default_fingerprint
103
+ if fingerprint is None:
104
+ raise BuildError(
105
+ "No signing key available. Run `synax keys generate` to create one, "
106
+ "or pass --key <fingerprint>."
107
+ )
108
+ keypair = key_store.load(fingerprint)
109
+ canonical = manifest.to_canonical_json()
110
+ signature_b64 = keypair.signer().sign(canonical)
111
+ signature = Signature(
112
+ key_fingerprint=fingerprint,
113
+ signer_type="author",
114
+ signature=signature_b64,
115
+ signed_at=datetime.datetime.now(datetime.UTC).isoformat(),
116
+ )
117
+ manifest.signatures.append(signature)
118
+
119
+ bundle_dir = _write_outputs(output_dir, manifest, artifact, canonical)
120
+
121
+ return BuildResult(
122
+ manifest=manifest,
123
+ artifact=artifact,
124
+ signature=signature,
125
+ output_dir=output_dir,
126
+ bundle_dir=bundle_dir,
127
+ warnings=warnings,
128
+ )
129
+
130
+
131
+ def _build_output_relative_pattern(project_root: Path, output_dir: Path) -> list[str] | None:
132
+ """Return a ``.synax-skillignore``-compatible pattern excluding ``output_dir``.
133
+
134
+ Returns ``None`` when ``output_dir`` is outside the project tree (no
135
+ need to ignore — packaging wouldn't pick it up anyway).
136
+ """
137
+ try:
138
+ rel = output_dir.relative_to(project_root)
139
+ except ValueError:
140
+ return None
141
+ rel_str = rel.as_posix().strip("/")
142
+ return [f"{rel_str}/"] if rel_str else None
143
+
144
+
145
+ def _write_outputs(
146
+ output_dir: Path,
147
+ manifest: Manifest,
148
+ artifact: ArtifactResult,
149
+ canonical: bytes,
150
+ ) -> Path:
151
+ output_dir.mkdir(parents=True, exist_ok=True)
152
+ bundle_dir = output_dir / BUNDLE_SUBDIR
153
+ bundle_dir.mkdir(parents=True, exist_ok=True)
154
+
155
+ # Bundle: what gets uploaded to the platform.
156
+ (bundle_dir / "manifest.yaml").write_text(manifest.to_yaml(), encoding="utf-8")
157
+ (bundle_dir / "implementation.tar.gz").write_bytes(artifact.tar_gz_bytes)
158
+
159
+ # Diagnostic files at the build root.
160
+ (output_dir / "manifest.yaml").write_text(manifest.to_yaml(), encoding="utf-8")
161
+ (output_dir / "manifest.json").write_text(manifest.to_json(), encoding="utf-8")
162
+ (output_dir / "manifest.canonical").write_bytes(canonical)
163
+
164
+ return bundle_dir
synax_sdk/errors.py ADDED
@@ -0,0 +1,140 @@
1
+ """Exception hierarchy for the Synax SDK.
2
+
3
+ All SDK-raised exceptions inherit from :class:`SynaxSDKError`, so users can
4
+ catch every SDK error with one ``except`` clause if desired. More specific
5
+ subclasses are preferred when the error category is known.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+
11
+ class SynaxSDKError(Exception):
12
+ """Base class for every exception raised by the Synax SDK."""
13
+
14
+
15
+ class SkillDefinitionError(SynaxSDKError):
16
+ """A :class:`~synax_sdk.skill.Skill` or tool is defined improperly.
17
+
18
+ Raised at construction time (in ``Skill.__init__`` or in the
19
+ ``@skill.tool`` decorator) when the author's code violates the SDK's
20
+ structural rules — for example an invalid skill name, a tool with an
21
+ untyped parameter, or a secret that lacks a matching capability.
22
+
23
+ These errors are fatal at *build* time. They never propagate to a
24
+ running skill, because a skill that fails to define cannot be invoked.
25
+ """
26
+
27
+
28
+ class SkillValidationError(SynaxSDKError):
29
+ """A skill or manifest failed semantic validation.
30
+
31
+ Distinct from :class:`SkillDefinitionError`: definition errors are about
32
+ *shape* (caught while the Python is being loaded), validation errors
33
+ are about *consistency* across the whole skill — e.g. a manifest whose
34
+ JSON Schema doesn't match the schema spec.
35
+ """
36
+
37
+
38
+ class ManifestError(SynaxSDKError):
39
+ """A manifest could not be constructed, serialized, or parsed."""
40
+
41
+
42
+ class SkillRuntimeError(SynaxSDKError):
43
+ """A skill attempted to use the runtime API in a way that failed.
44
+
45
+ Common cases:
46
+
47
+ * No runtime backend is active (skill imported but not running inside
48
+ the platform or a :class:`~synax_sdk.testing.MockPlatform`).
49
+ * The mock backend was active but the requested secret / context
50
+ wasn't configured before the skill was invoked.
51
+
52
+ The message names what was attempted and what to do about it.
53
+ """
54
+
55
+
56
+ class CapabilityError(SynaxSDKError):
57
+ """A skill attempted a gated operation without the required capability.
58
+
59
+ Raised by the runtime API (e.g. :mod:`synax_sdk.runtime.network`) when
60
+ the skill's runtime-effective capability set — supplied by the platform
61
+ via the runtime contract, or programmatically via
62
+ :class:`~synax_sdk.testing.MockPlatform` in tests — does not include
63
+ the capability needed for the call.
64
+
65
+ Attributes:
66
+ requested: The capability string the call required, in its most
67
+ specific form (e.g. ``"network:api.github.com:443"`` for a URL
68
+ with an explicit port).
69
+ available: The capabilities currently allowed for the invocation.
70
+ Capability names are not sensitive and are safe to log.
71
+
72
+ The error message names the missing capability and lists what *is*
73
+ allowed, so the skill author can see at a glance what to add.
74
+ """
75
+
76
+ def __init__(
77
+ self,
78
+ *,
79
+ requested: str,
80
+ available: frozenset[str],
81
+ ) -> None:
82
+ self.requested = requested
83
+ self.available = frozenset(available)
84
+ listed = ", ".join(sorted(self.available)) if self.available else "(none)"
85
+ super().__init__(
86
+ f"Skill did not declare the required capability {requested!r}. "
87
+ f"Currently allowed: {listed}. "
88
+ f"Add it to the Skill's capabilities list to enable this operation."
89
+ )
90
+
91
+
92
+ class BuildError(SynaxSDKError):
93
+ """A build step (load, validate, package, sign, write) failed.
94
+
95
+ Aggregates validation errors when validation is the cause; the CLI
96
+ formats them into a coloured list. Construct via :meth:`with_errors`
97
+ when carrying a structured list.
98
+ """
99
+
100
+ def __init__(self, message: str, *, errors: list[object] | None = None) -> None:
101
+ super().__init__(message)
102
+ self.errors = errors or []
103
+
104
+
105
+ class PublishError(SynaxSDKError):
106
+ """Base class for publishing failures.
107
+
108
+ Subclasses distinguish between authentication problems, server-side
109
+ rejection of the manifest, version conflicts, and network/transient
110
+ failures so the CLI can render the right message and exit code.
111
+ """
112
+
113
+
114
+ class PublishAuthError(PublishError):
115
+ """The platform rejected the auth token (HTTP 401/403)."""
116
+
117
+
118
+ class PublishValidationError(PublishError):
119
+ """The platform rejected the manifest (HTTP 400). Typically because the
120
+ SDK's vendored schema is ahead of or behind the platform's."""
121
+
122
+ def __init__(self, message: str, *, details: object | None = None) -> None:
123
+ super().__init__(message)
124
+ self.details = details
125
+
126
+
127
+ class PublishVersionConflictError(PublishError):
128
+ """Version already published (HTTP 409). The author must bump the version."""
129
+
130
+ def __init__(self, message: str, *, existing_version_id: str | None = None) -> None:
131
+ super().__init__(message)
132
+ self.existing_version_id = existing_version_id
133
+
134
+
135
+ # Backwards-compatible alias for the original name (without the Error suffix).
136
+ PublishVersionConflict = PublishVersionConflictError
137
+
138
+
139
+ class PublishNetworkError(PublishError):
140
+ """Network failure or persistent 5xx after retries."""
synax_sdk/manifest.py ADDED
@@ -0,0 +1,305 @@
1
+ """Skill manifest dataclasses, the ``from_skill`` factory, and serialization.
2
+
3
+ A manifest is the YAML/JSON representation of a skill that the platform
4
+ consumes. This module is the SDK's source of truth for the manifest shape;
5
+ the schema it produces must match ``docs/manifest_spec.md``.
6
+
7
+ At Milestone 2 the manifest is *in-memory only* — no file I/O, no signing,
8
+ no JSON Schema validation. ``Implementation.entry_point`` and
9
+ ``Implementation.hash`` are placeholders that later milestones (4: packaging
10
+ & signing) fill in.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import dataclasses
16
+ import json
17
+ from dataclasses import dataclass, field
18
+ from typing import Any, Literal
19
+
20
+ import yaml
21
+
22
+ from synax_sdk import version as _version_module
23
+ from synax_sdk.errors import ManifestError
24
+ from synax_sdk.skill import Secret, Skill, SkillDependency, SkillMetadata
25
+ from synax_sdk.tool import Tool
26
+
27
+ SCHEMA_VERSION = "1.0"
28
+
29
+ SignerType = Literal["author", "platform_verifier", "audit", "customer_internal"]
30
+ ImplementationType = Literal["python_code", "manifest_only", "typescript_code"]
31
+
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # Sub-dataclasses
35
+ # ---------------------------------------------------------------------------
36
+ @dataclass
37
+ class Implementation:
38
+ """How the skill is implemented.
39
+
40
+ Only ``type`` is required at construction time. ``entry_point`` and
41
+ ``hash`` are filled in by the build pipeline (Milestone 4).
42
+ """
43
+
44
+ type: ImplementationType = "python_code"
45
+ entry_point: str | None = None
46
+ hash: str | None = None
47
+ runtime_requirements: dict[str, str] | None = None
48
+
49
+
50
+ @dataclass
51
+ class ToolManifest:
52
+ """A tool as it appears in the manifest."""
53
+
54
+ name: str
55
+ description: str
56
+ parameters: dict[str, Any]
57
+ returns: dict[str, Any] | None = None
58
+
59
+ @classmethod
60
+ def from_tool(cls, tool: Tool) -> ToolManifest:
61
+ return cls(
62
+ name=tool.name,
63
+ description=tool.description,
64
+ parameters=dict(tool.parameters_schema),
65
+ returns=dict(tool.returns_schema) if tool.returns_schema is not None else None,
66
+ )
67
+
68
+
69
+ @dataclass
70
+ class SecretManifest:
71
+ """A secret as it appears in the manifest."""
72
+
73
+ name: str
74
+ type: str
75
+ description: str
76
+ scope_required: str | None = None
77
+
78
+ @classmethod
79
+ def from_secret(cls, secret: Secret) -> SecretManifest:
80
+ return cls(
81
+ name=secret.name,
82
+ type=secret.type,
83
+ description=secret.description,
84
+ scope_required=secret.scope_required,
85
+ )
86
+
87
+
88
+ @dataclass
89
+ class DependencyManifest:
90
+ skill: str
91
+ version: str
92
+
93
+ @classmethod
94
+ def from_dependency(cls, dep: SkillDependency) -> DependencyManifest:
95
+ return cls(skill=dep.skill, version=dep.version)
96
+
97
+
98
+ @dataclass
99
+ class ManifestMetadata:
100
+ """Manifest-side metadata. Mirrors :class:`SkillMetadata` and adds
101
+ ``sdk_version`` (populated automatically by ``Manifest.from_skill``)."""
102
+
103
+ category: str | None = None
104
+ tags: list[str] = field(default_factory=list)
105
+ homepage: str | None = None
106
+ source: str | None = None
107
+ documentation: str | None = None
108
+ license: str | None = None
109
+ long_description: str | None = None
110
+ icon_url: str | None = None
111
+ sdk_version: str | None = None
112
+
113
+ @classmethod
114
+ def from_skill_metadata(
115
+ cls, meta: SkillMetadata, *, sdk_version: str | None = None
116
+ ) -> ManifestMetadata:
117
+ return cls(
118
+ category=meta.category,
119
+ tags=list(meta.tags),
120
+ homepage=meta.homepage,
121
+ source=meta.source,
122
+ documentation=meta.documentation,
123
+ license=meta.license,
124
+ long_description=meta.long_description,
125
+ icon_url=meta.icon_url,
126
+ sdk_version=sdk_version,
127
+ )
128
+
129
+
130
+ @dataclass
131
+ class Signature:
132
+ key_fingerprint: str
133
+ signer_type: SignerType
134
+ signature: str
135
+ signed_at: str
136
+
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # The top-level Manifest
140
+ # ---------------------------------------------------------------------------
141
+ @dataclass
142
+ class Manifest:
143
+ """The top-level skill manifest.
144
+
145
+ Construct via :meth:`Manifest.from_skill` rather than instantiating
146
+ directly. Serialize via :meth:`to_yaml`, :meth:`to_json`, or
147
+ :meth:`to_canonical_json` (the bytes used as input to Ed25519 signing).
148
+ """
149
+
150
+ schema_version: str
151
+ name: str
152
+ version: str
153
+ display_name: str
154
+ description: str
155
+ implementation: Implementation
156
+ tools: list[ToolManifest]
157
+ namespace: str | None = None
158
+ capabilities: list[str] = field(default_factory=list)
159
+ execution_location: str = "either"
160
+ secrets: list[SecretManifest] = field(default_factory=list)
161
+ dependencies: list[DependencyManifest] = field(default_factory=list)
162
+ metadata: ManifestMetadata = field(default_factory=ManifestMetadata)
163
+ signatures: list[Signature] = field(default_factory=list)
164
+
165
+ # -----------------------------------------------------------------------
166
+ @classmethod
167
+ def from_skill(
168
+ cls,
169
+ skill: Skill,
170
+ *,
171
+ sdk_version: str | None = None,
172
+ ) -> Manifest:
173
+ """Build a Manifest from a :class:`~synax_sdk.skill.Skill`.
174
+
175
+ The resulting manifest has ``implementation.entry_point`` and
176
+ ``implementation.hash`` left as ``None``; the build pipeline
177
+ (Milestone 4) fills them in once it knows the packaged artifact's
178
+ module path and hash.
179
+
180
+ Args:
181
+ skill: source of truth.
182
+ sdk_version: overrides the auto-detected SDK version stamp in
183
+ ``metadata.sdk_version``. Mostly useful for reproducible
184
+ tests.
185
+ """
186
+ return cls(
187
+ schema_version=SCHEMA_VERSION,
188
+ name=skill.name,
189
+ namespace=skill.namespace,
190
+ version=skill.version,
191
+ display_name=skill.display_name,
192
+ description=skill.description,
193
+ implementation=Implementation(type="python_code"),
194
+ tools=[ToolManifest.from_tool(t) for t in skill.tools],
195
+ capabilities=list(skill.capabilities),
196
+ execution_location=skill.execution_location,
197
+ secrets=[SecretManifest.from_secret(s) for s in skill.secrets],
198
+ dependencies=[DependencyManifest.from_dependency(d) for d in skill.dependencies],
199
+ metadata=ManifestMetadata.from_skill_metadata(
200
+ skill.metadata,
201
+ sdk_version=(
202
+ sdk_version if sdk_version is not None else _version_module.__version__
203
+ ),
204
+ ),
205
+ signatures=[],
206
+ )
207
+
208
+ # -----------------------------------------------------------------------
209
+ # Serialization
210
+ # -----------------------------------------------------------------------
211
+ def to_dict(self) -> dict[str, Any]:
212
+ """Return a plain ``dict`` matching ``manifest_spec.md`` field order.
213
+
214
+ Empty optional collections, ``None`` scalars, and defaulted values
215
+ (``execution_location == "either"``) are omitted so the resulting
216
+ document is concise.
217
+ """
218
+ result: dict[str, Any] = {
219
+ "schema_version": self.schema_version,
220
+ "name": self.name,
221
+ }
222
+ if self.namespace:
223
+ result["namespace"] = self.namespace
224
+ result["version"] = self.version
225
+ result["display_name"] = self.display_name
226
+ result["description"] = self.description
227
+ result["implementation"] = _filter_dataclass(self.implementation)
228
+ result["tools"] = [_filter_dataclass(t) for t in self.tools]
229
+ if self.capabilities:
230
+ result["capabilities"] = list(self.capabilities)
231
+ if self.execution_location != "either":
232
+ result["execution_location"] = self.execution_location
233
+ if self.secrets:
234
+ result["secrets"] = [_filter_dataclass(s) for s in self.secrets]
235
+ if self.dependencies:
236
+ result["dependencies"] = [_filter_dataclass(d) for d in self.dependencies]
237
+ meta = _filter_dataclass(self.metadata)
238
+ if meta:
239
+ result["metadata"] = meta
240
+ if self.signatures:
241
+ result["signatures"] = [_filter_dataclass(s) for s in self.signatures]
242
+ return result
243
+
244
+ def to_yaml(self) -> str:
245
+ """Serialize as a YAML document with the spec's field order preserved."""
246
+ try:
247
+ return yaml.safe_dump(
248
+ self.to_dict(),
249
+ sort_keys=False,
250
+ default_flow_style=False,
251
+ allow_unicode=True,
252
+ )
253
+ except yaml.YAMLError as exc: # pragma: no cover - defensive
254
+ raise ManifestError(f"Failed to serialize manifest to YAML: {exc}") from exc
255
+
256
+ def to_json(self, *, indent: int | None = 2) -> str:
257
+ """Serialize as JSON. Default is indented for human review."""
258
+ return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False)
259
+
260
+ def to_canonical_json(self) -> bytes:
261
+ """Return the canonical signing bytes.
262
+
263
+ The canonical form (per ``manifest_spec.md`` §Canonical form for
264
+ signing):
265
+
266
+ 1. JSON object representation
267
+ 2. Drop ``signatures`` (signatures sign everything *except* themselves)
268
+ 3. Sort keys recursively
269
+ 4. No whitespace
270
+ 5. UTF-8 encoded
271
+ """
272
+ payload = self.to_dict()
273
+ payload.pop("signatures", None)
274
+ return json.dumps(
275
+ payload,
276
+ sort_keys=True,
277
+ separators=(",", ":"),
278
+ ensure_ascii=False,
279
+ ).encode("utf-8")
280
+
281
+
282
+ # ---------------------------------------------------------------------------
283
+ # Helpers
284
+ # ---------------------------------------------------------------------------
285
+ def _filter_dataclass(obj: Any) -> Any:
286
+ """Convert a dataclass to dict, omitting None and empty list/dict fields.
287
+
288
+ Plain ``dict`` instances inside the dataclass (such as a JSON Schema
289
+ payload) are returned unchanged — we only filter at the dataclass
290
+ boundary, not inside opaque payloads.
291
+ """
292
+ if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
293
+ result: dict[str, Any] = {}
294
+ for f in dataclasses.fields(obj):
295
+ value = getattr(obj, f.name)
296
+ cleaned = _filter_dataclass(value)
297
+ if cleaned is None:
298
+ continue
299
+ if isinstance(cleaned, list | dict) and len(cleaned) == 0:
300
+ continue
301
+ result[f.name] = cleaned
302
+ return result
303
+ if isinstance(obj, list):
304
+ return [_filter_dataclass(item) for item in obj]
305
+ return obj