This is an automated email from the ASF dual-hosted git repository.

wenjin272 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/flink-agents.git

commit 2fa1a3d62ac961daa8afbc084b94a57e6a3f2a10
Author: WenjinXie <[email protected]>
AuthorDate: Wed May 20 17:32:24 2026 +0800

    [api][runtime][python] Extend agent skill loading repository.
    
    Co-Authored-By: Claude Opus 4.7 <[email protected]>
---
 docs/yaml-schema.json                              |  46 +++-
 python/flink_agents/api/skills.py                  | 109 +++++++-
 python/flink_agents/api/tests/test_skills.py       |  99 ++++++++
 python/flink_agents/api/yaml/loader.py             |  21 +-
 python/flink_agents/api/yaml/specs.py              |  43 +++-
 python/flink_agents/api/yaml/tests/test_loader.py  |  45 +++-
 python/flink_agents/api/yaml/tests/test_specs.py   |  21 +-
 python/flink_agents/plan/agent_plan.py             |   6 +-
 python/flink_agents/runtime/resource_cache.py      |   9 +-
 python/flink_agents/runtime/resource_context.py    |  13 +
 python/flink_agents/runtime/skill/agent_skill.py   |  24 ++
 .../runtime/skill/repository/_materialize.py       | 166 +++++++++++++
 .../skill/repository/filesystem_repository.py      | 204 +++------------
 .../repository/materialized_skill_repository.py    |  72 ++++++
 .../runtime/skill/repository/package_repository.py |  94 +++++++
 .../skill/repository/skill_directory_reader.py     | 139 +++++++++++
 .../runtime/skill/repository/url_repository.py     |  66 +++++
 python/flink_agents/runtime/skill/skill_manager.py | 130 ++++++++--
 .../flink_agents/runtime/skill/skill_repository.py |  49 ++--
 .../runtime/skill/skill_source_registry.py         | 131 ++++++++++
 ...repository.py => test_filesystem_repository.py} |  50 +++-
 .../runtime/skill/tests/test_manager.py            | 273 ++++++++++++++++++++-
 .../runtime/skill/tests/test_materialize.py        | 152 ++++++++++++
 .../runtime/skill/tests/test_package_repository.py |  84 +++++++
 .../skill/tests/test_skill_directory_reader.py     | 106 ++++++++
 .../runtime/skill/tests/test_url_repository.py     |  99 ++++++++
 26 files changed, 1990 insertions(+), 261 deletions(-)

diff --git a/docs/yaml-schema.json b/docs/yaml-schema.json
index 905fa4b6..ac62bb17 100644
--- a/docs/yaml-schema.json
+++ b/docs/yaml-schema.json
@@ -216,6 +216,26 @@
       "title": "MessageRole",
       "type": "string"
     },
+    "PackageSkillSpec": {
+      "additionalProperties": false,
+      "description": "A single ``package`` skill source entry: a Python 
package name plus a\nresource path relative to that package's root.",
+      "properties": {
+        "package": {
+          "title": "Package",
+          "type": "string"
+        },
+        "resource": {
+          "title": "Resource",
+          "type": "string"
+        }
+      },
+      "required": [
+        "package",
+        "resource"
+      ],
+      "title": "PackageSkillSpec",
+      "type": "object"
+    },
     "PromptMessage": {
       "additionalProperties": false,
       "description": "One message in a multi-turn prompt template.",
@@ -279,23 +299,43 @@
     },
     "SkillsSpec": {
       "additionalProperties": false,
-      "description": "Declarative Skills resource pointing at one or more 
skill source\ndirectories on the local filesystem.",
+      "description": "Declarative Skills resource: one or more skill sources 
grouped by scheme.\n\nEach list below maps to a skill source scheme:\n\n- 
``paths`` \u2014 ``local`` scheme: directories or ``.zip`` files\n- ``urls`` 
\u2014 ``url`` scheme: ``http(s)`` URLs pointing to a ``.zip``\n- ``classpath`` 
\u2014 ``classpath`` scheme (Java-only at runtime): resource\n  paths on the 
Java classpath\n- ``package`` \u2014 ``package`` scheme (Python-only at 
runtime): resources\n  inside insta [...]
       "properties": {
+        "classpath": {
+          "items": {
+            "type": "string"
+          },
+          "title": "Classpath",
+          "type": "array"
+        },
         "name": {
           "title": "Name",
           "type": "string"
         },
+        "package": {
+          "items": {
+            "$ref": "#/$defs/PackageSkillSpec"
+          },
+          "title": "Package",
+          "type": "array"
+        },
         "paths": {
           "items": {
             "type": "string"
           },
           "title": "Paths",
           "type": "array"
+        },
+        "urls": {
+          "items": {
+            "type": "string"
+          },
+          "title": "Urls",
+          "type": "array"
         }
       },
       "required": [
-        "name",
-        "paths"
+        "name"
       ],
       "title": "SkillsSpec",
       "type": "object"
diff --git a/python/flink_agents/api/skills.py 
b/python/flink_agents/api/skills.py
index 0306d983..f1acfec6 100644
--- a/python/flink_agents/api/skills.py
+++ b/python/flink_agents/api/skills.py
@@ -17,7 +17,14 @@
 
#################################################################################
 """Skills configuration resource for agent skills discovery.
 
-Use :meth:`Skills.from_local_dir` to construct a :class:`Skills` resource.
+Each :class:`Skills` resource carries a single ordered list of
+:class:`SkillSourceSpec` entries. Each entry has a ``scheme`` (e.g.
+``"local"``, ``"url"``, ``"package"``) and a scheme-specific ``params`` map.
+Use one of the factory methods to construct a :class:`Skills` resource:
+
+* :meth:`Skills.from_local_dir` for local directories or local ``.zip`` files
+* :meth:`Skills.from_url` for http(s) URLs pointing to a ``.zip``
+* :meth:`Skills.from_package` for resources inside installed packages
 
 Example::
 
@@ -26,39 +33,115 @@ Example::
     def my_skills() -> Skills:
         return Skills.from_local_dir("./skills")
 
+
+    @skills
+    @staticmethod
+    def remote_skills() -> Skills:
+        return Skills.from_url("https://example.com/skills.zip";)
+
+
+    @skills
+    @staticmethod
+    def packaged_skills() -> Skills:
+        return Skills.from_package(("my_skills_pkg", "skills"))
+
+The ``"classpath"`` scheme is Java-only; a plan written by Java with
+``scheme=classpath`` deserializes successfully on Python but
+:class:`SkillManager` will fail fast at load time with the registered-scheme
+list.
+
 Declare more than one ``@skills`` function on the same agent to combine
-sources; the runtime merges them.
+sources; the runtime merges them and de-duplicates identical
+:class:`SkillSourceSpec` entries.
 """
 
 from __future__ import annotations
 
-from typing import List
+from typing import Dict, List, Tuple
 
-from pydantic import Field
+from pydantic import BaseModel, ConfigDict, Field, field_validator
 from typing_extensions import override
 
 from flink_agents.api.resource import ResourceType, SerializableResource
 
 
+class SkillSourceSpec(BaseModel):
+    """One entry in :attr:`Skills.sources`.
+
+    ``scheme`` identifies the source type; ``params`` carries the
+    scheme-specific configuration. The ``scheme`` is normalized to lowercase.
+    Unknown schemes deserialize successfully — the registry is the fail point
+    at load time.
+    """
+
+    scheme: str
+    params: Dict[str, str] = Field(default_factory=dict)
+
+    model_config = ConfigDict(frozen=True)
+
+    @field_validator("scheme")
+    @classmethod
+    def _lower(cls, v: str) -> str:
+        return v.lower()
+
+    def __hash__(self) -> int:
+        return hash((self.scheme, tuple(sorted(self.params.items()))))
+
+
 class Skills(SerializableResource):
     """A resource describing where to load agent skills from.
 
-    Use :meth:`from_local_dir` to construct — direct field construction is
-    reserved for internal serialization and not part of the public API.
+    Use one of the ``from_*`` factory methods to construct — direct field
+    construction is reserved for internal serialization and not part of the
+    public API.
     """
 
-    # Internal location list populated through the factory methods; kept
-    # as a public pydantic field so it can be serialized/deserialized.
-    paths: List[str] = Field(default_factory=list)
+    sources: List[SkillSourceSpec] = Field(default_factory=list)
 
     @classmethod
     def from_local_dir(cls, *paths: str) -> Skills:
-        """Create a Skills resource from one or more local filesystem 
directories.
+        """Create a Skills resource from one or more local paths.
 
-        Each path points to a directory whose immediate subdirectories each
-        contain a ``SKILL.md`` file.
+        Each path may be a directory or a ``.zip`` file. For a directory, its
+        immediate subdirectories must each contain a ``SKILL.md`` file. For
+        a zip, its top-level entries are the skill subdirectories.
+        """
+        return cls(
+            sources=[
+                SkillSourceSpec(scheme="local", params={"path": p}) for p in 
paths
+            ]
+        )
+
+    @classmethod
+    def from_url(cls, *urls: str) -> Skills:
+        """Create a Skills resource from one or more http(s) URLs.
+
+        Each URL must point to a ``.zip`` whose top level is the baseDir
+        (i.e. skill subdirectories sit at the top of the zip).
+        """
+        return cls(
+            sources=[SkillSourceSpec(scheme="url", params={"url": u}) for u in 
urls]
+        )
+
+    @classmethod
+    def from_package(cls, *pairs: Tuple[str, str]) -> Skills:
+        """Create a Skills resource from resources inside installed packages.
+
+        Args:
+            *pairs: One or more ``(package, resource)`` tuples. ``package`` is
+                a dotted Python package name (e.g. ``"my_skills_pkg"``);
+                ``resource`` is a path inside the package, relative to the
+                package root. The resource may refer to a directory or a
+                ``.zip`` file.
         """
-        return cls(paths=list(paths))
+        return cls(
+            sources=[
+                SkillSourceSpec(
+                    scheme="package", params={"package": pkg, "resource": res}
+                )
+                for pkg, res in pairs
+            ]
+        )
 
     @classmethod
     @override
diff --git a/python/flink_agents/api/tests/test_skills.py 
b/python/flink_agents/api/tests/test_skills.py
new file mode 100644
index 00000000..7b8829c1
--- /dev/null
+++ b/python/flink_agents/api/tests/test_skills.py
@@ -0,0 +1,99 @@
+################################################################################
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+# limitations under the License.
+#################################################################################
+"""Tests for the Skills resource API."""
+
+from flink_agents.api.skills import Skills, SkillSourceSpec
+
+
+class TestSkillsFactories:
+    def test_from_local_dir_emits_local_scheme(self) -> None:
+        s = Skills.from_local_dir("/a", "/b.zip")
+        assert s.sources == [
+            SkillSourceSpec(scheme="local", params={"path": "/a"}),
+            SkillSourceSpec(scheme="local", params={"path": "/b.zip"}),
+        ]
+
+    def test_from_url_emits_url_scheme(self) -> None:
+        s = Skills.from_url("https://example.com/x.zip";)
+        assert s.sources == [
+            SkillSourceSpec(
+                scheme="url", params={"url": "https://example.com/x.zip"}
+            )
+        ]
+
+    def test_from_package_single_pair(self) -> None:
+        s = Skills.from_package(("my_pkg", "skills"))
+        assert s.sources == [
+            SkillSourceSpec(
+                scheme="package", params={"package": "my_pkg", "resource": 
"skills"}
+            )
+        ]
+
+    def test_from_package_varargs(self) -> None:
+        s = Skills.from_package(("pkg_a", "skills"), ("pkg_b", "other"))
+        assert s.sources == [
+            SkillSourceSpec(
+                scheme="package", params={"package": "pkg_a", "resource": 
"skills"}
+            ),
+            SkillSourceSpec(
+                scheme="package", params={"package": "pkg_b", "resource": 
"other"}
+            ),
+        ]
+
+    def test_serialize_roundtrip(self) -> None:
+        s = Skills(
+            sources=[
+                SkillSourceSpec(scheme="local", params={"path": "/a"}),
+                SkillSourceSpec(
+                    scheme="url", params={"url": "https://e.com/x.zip"}
+                ),
+                SkillSourceSpec(
+                    scheme="package",
+                    params={"package": "p", "resource": "skills"},
+                ),
+            ]
+        )
+        dumped = s.model_dump()
+        restored = Skills.model_validate(dumped)
+        assert restored.sources == s.sources
+
+
+class TestSkillSourceSpec:
+    def test_scheme_is_lowercased(self) -> None:
+        spec = SkillSourceSpec(scheme="LOCAL", params={"path": "/x"})
+        assert spec.scheme == "local"
+
+    def test_equality_ignores_scheme_case(self) -> None:
+        a = SkillSourceSpec(scheme="LOCAL", params={"path": "/x"})
+        b = SkillSourceSpec(scheme="local", params={"path": "/x"})
+        assert a == b
+
+    def test_hashable(self) -> None:
+        a = SkillSourceSpec(scheme="local", params={"path": "/x"})
+        b = SkillSourceSpec(scheme="LOCAL", params={"path": "/x"})
+        assert hash(a) == hash(b)
+        # Spec is usable as a set / dict key, supporting de-duplication during 
merge.
+        assert len({a, b}) == 1
+
+    def test_unknown_scheme_deserializes_successfully(self) -> None:
+        # The registry — not the model — is the fail point.
+        spec = SkillSourceSpec.model_validate(
+            {"scheme": "future-scheme", "params": {"k": "v"}}
+        )
+        assert spec.scheme == "future-scheme"
+        assert spec.params == {"k": "v"}
diff --git a/python/flink_agents/api/yaml/loader.py 
b/python/flink_agents/api/yaml/loader.py
index 59922053..4ec97a95 100644
--- a/python/flink_agents/api/yaml/loader.py
+++ b/python/flink_agents/api/yaml/loader.py
@@ -32,7 +32,7 @@ from flink_agents.api.chat_message import ChatMessage, 
MessageRole
 from flink_agents.api.function import Function, JavaFunction, PythonFunction
 from flink_agents.api.prompts.prompt import Prompt
 from flink_agents.api.resource import ResourceDescriptor, ResourceType
-from flink_agents.api.skills import Skills
+from flink_agents.api.skills import Skills, SkillSourceSpec
 from flink_agents.api.tools.function_tool import FunctionTool
 from flink_agents.api.yaml.aliases import (
     JAVA_WRAPPER_CLAZZ,
@@ -199,7 +199,24 @@ def _build_prompt(spec: PromptSpec) -> Prompt:
 
 
 def _build_skills(spec: SkillsSpec) -> Skills:
-    return Skills(paths=list(spec.paths))
+    sources: List[SkillSourceSpec] = [
+        SkillSourceSpec(scheme="local", params={"path": p}) for p in spec.paths
+    ]
+    sources.extend(
+        SkillSourceSpec(scheme="url", params={"url": u}) for u in spec.urls
+    )
+    sources.extend(
+        SkillSourceSpec(scheme="classpath", params={"resource": r})
+        for r in spec.classpath
+    )
+    sources.extend(
+        SkillSourceSpec(
+            scheme="package",
+            params={"package": entry.package, "resource": entry.resource},
+        )
+        for entry in spec.package
+    )
+    return Skills(sources=sources)
 
 
 def _build_agent(agent_spec: AgentSpec) -> Agent:
diff --git a/python/flink_agents/api/yaml/specs.py 
b/python/flink_agents/api/yaml/specs.py
index f806bf29..220eccf3 100644
--- a/python/flink_agents/api/yaml/specs.py
+++ b/python/flink_agents/api/yaml/specs.py
@@ -123,15 +123,52 @@ class ToolSpec(BaseModel):
     parameter_types: List[str] | None = None
 
 
+class PackageSkillSpec(BaseModel):
+    """A single ``package`` skill source entry: a Python package name plus a
+    resource path relative to that package's root.
+    """
+
+    model_config = ConfigDict(extra="forbid")
+
+    package: str
+    resource: str
+
+
 class SkillsSpec(BaseModel):
-    """Declarative Skills resource pointing at one or more skill source
-    directories on the local filesystem.
+    """Declarative Skills resource: one or more skill sources grouped by 
scheme.
+
+    Each list below maps to a skill source scheme:
+
+    - ``paths`` — ``local`` scheme: directories or ``.zip`` files
+    - ``urls`` — ``url`` scheme: ``http(s)`` URLs pointing to a ``.zip``
+    - ``classpath`` — ``classpath`` scheme (Java-only at runtime): resource
+      paths on the Java classpath
+    - ``package`` — ``package`` scheme (Python-only at runtime): resources
+      inside installed Python packages, given as ``{package, resource}`` pairs
+
+    At least one of the four must be non-empty. ``classpath`` is exposed on
+    Python for YAML schema parity with Java — it deserializes successfully
+    but ``SkillManager`` on Python will fail at load time because Python does
+    not register a ``classpath`` handler.
     """
 
     model_config = ConfigDict(extra="forbid")
 
     name: str
-    paths: List[str]
+    paths: List[str] = Field(default_factory=list)
+    urls: List[str] = Field(default_factory=list)
+    classpath: List[str] = Field(default_factory=list)
+    package: List[PackageSkillSpec] = Field(default_factory=list)
+
+    @model_validator(mode="after")
+    def _require_one_source(self) -> "SkillsSpec":
+        if not (self.paths or self.urls or self.classpath or self.package):
+            msg = (
+                f"skills '{self.name}': at least one of "
+                "paths/urls/classpath/package must be non-empty."
+            )
+            raise ValueError(msg)
+        return self
 
 
 class ActionSpec(BaseModel):
diff --git a/python/flink_agents/api/yaml/tests/test_loader.py 
b/python/flink_agents/api/yaml/tests/test_loader.py
index 7569c9bd..a605cc38 100644
--- a/python/flink_agents/api/yaml/tests/test_loader.py
+++ b/python/flink_agents/api/yaml/tests/test_loader.py
@@ -27,9 +27,15 @@ from flink_agents.api.execution_environment import 
AgentsExecutionEnvironment
 from flink_agents.api.function import JavaFunction, PythonFunction
 from flink_agents.api.prompts.prompt import LocalPrompt
 from flink_agents.api.resource import ResourceDescriptor, ResourceName, 
ResourceType
-from flink_agents.api.skills import Skills
+from flink_agents.api.skills import Skills, SkillSourceSpec
 from flink_agents.api.tools.function_tool import FunctionTool
-from flink_agents.api.yaml.loader import build_agents, load_yaml, 
resolve_function
+from flink_agents.api.yaml.loader import (
+    _build_skills,
+    build_agents,
+    load_yaml,
+    resolve_function,
+)
+from flink_agents.api.yaml.specs import SkillsSpec
 from flink_agents.api.yaml.tests.fixtures import loader_targets
 
 _FIXTURES = Path(__file__).parent / "fixtures"
@@ -336,11 +342,37 @@ def test_build_agents_loads_skills_per_agent_and_shared() 
-> None:
 
     own = agent.resources[ResourceType.SKILLS]["agent_skills"]
     assert isinstance(own, Skills)
-    assert own.paths == ["./agent_skill_dir"]
+    assert own.sources == [
+        SkillSourceSpec(scheme="local", params={"path": "./agent_skill_dir"})
+    ]
 
     shared = shared_resources[ResourceType.SKILLS]["shared_skills"]
     assert isinstance(shared, Skills)
-    assert shared.paths == ["./shared_skill_dir", "./more"]
+    assert shared.sources == [
+        SkillSourceSpec(scheme="local", params={"path": "./shared_skill_dir"}),
+        SkillSourceSpec(scheme="local", params={"path": "./more"}),
+    ]
+
+
+def test_build_skills_merges_all_schemes() -> None:
+    spec = SkillsSpec.model_validate(
+        {
+            "name": "s",
+            "paths": ["./a"],
+            "urls": ["https://x/s.zip";],
+            "classpath": ["com/example/s"],
+            "package": [{"package": "my_pkg", "resource": "skills/"}],
+        }
+    )
+    skills = _build_skills(spec)
+    assert skills.sources == [
+        SkillSourceSpec(scheme="local", params={"path": "./a"}),
+        SkillSourceSpec(scheme="url", params={"url": "https://x/s.zip"}),
+        SkillSourceSpec(scheme="classpath", params={"resource": 
"com/example/s"}),
+        SkillSourceSpec(
+            scheme="package", params={"package": "my_pkg", "resource": 
"skills/"}
+        ),
+    ]
 
 
 def test_load_yaml_registers_shared_skills_on_env() -> None:
@@ -348,7 +380,10 @@ def test_load_yaml_registers_shared_skills_on_env() -> 
None:
     load_yaml(env, _FIXTURES / "with_skills.yaml")
     shared = env.resources[ResourceType.SKILLS]["shared_skills"]
     assert isinstance(shared, Skills)
-    assert shared.paths == ["./shared_skill_dir", "./more"]
+    assert shared.sources == [
+        SkillSourceSpec(scheme="local", params={"path": "./shared_skill_dir"}),
+        SkillSourceSpec(scheme="local", params={"path": "./more"}),
+    ]
 
 
 def test_build_agents_supports_type_java(tmp_path: Path) -> None:
diff --git a/python/flink_agents/api/yaml/tests/test_specs.py 
b/python/flink_agents/api/yaml/tests/test_specs.py
index a7c89dbd..e7028588 100644
--- a/python/flink_agents/api/yaml/tests/test_specs.py
+++ b/python/flink_agents/api/yaml/tests/test_specs.py
@@ -250,7 +250,7 @@ def test_yaml_document_with_shared_resources_and_actions() 
-> None:
     assert doc.actions[0].name == "shared"
 
 
-def test_skills_spec_requires_paths() -> None:
+def test_skills_spec_requires_at_least_one_source() -> None:
     with pytest.raises(ValidationError):
         SkillsSpec.model_validate({"name": "s"})
 
@@ -258,6 +258,25 @@ def test_skills_spec_requires_paths() -> None:
 def test_skills_spec_with_paths() -> None:
     spec = SkillsSpec.model_validate({"name": "s", "paths": ["./a", "./b"]})
     assert spec.paths == ["./a", "./b"]
+    assert spec.urls == []
+    assert spec.package == []
+
+
+def test_skills_spec_with_urls_classpath_package() -> None:
+    spec = SkillsSpec.model_validate(
+        {
+            "name": "s",
+            "urls": ["https://x/s.zip";],
+            "classpath": ["com/example/s"],
+            "package": [{"package": "my_pkg", "resource": "skills/"}],
+        }
+    )
+    assert spec.paths == []
+    assert spec.urls == ["https://x/s.zip";]
+    assert spec.classpath == ["com/example/s"]
+    assert len(spec.package) == 1
+    assert spec.package[0].package == "my_pkg"
+    assert spec.package[0].resource == "skills/"
 
 
 def test_skills_spec_forbids_extras() -> None:
diff --git a/python/flink_agents/plan/agent_plan.py 
b/python/flink_agents/plan/agent_plan.py
index f38c0fc7..01e946e4 100644
--- a/python/flink_agents/plan/agent_plan.py
+++ b/python/flink_agents/plan/agent_plan.py
@@ -499,11 +499,11 @@ def _add_skills(
     #  skill names and which skill manager they belong to when declaring a chat
     #  model setup. MCP prompts and tools face the same situation, we can 
refactor
     #  them as a whole.
-    paths: List[str] = []
+    sources = []
     for skills_obj in skills_objects.values():
-        paths.extend(skills_obj.paths)
+        sources.extend(skills_obj.sources)
 
-    merged = Skills.from_local_dir(*dict.fromkeys(paths))
+    merged = Skills(sources=list(dict.fromkeys(sources)))
 
     resource_providers.append(
         PythonSerializableResourceProvider.from_resource(
diff --git a/python/flink_agents/runtime/resource_cache.py 
b/python/flink_agents/runtime/resource_cache.py
index 382a58a9..3f98a8b7 100644
--- a/python/flink_agents/runtime/resource_cache.py
+++ b/python/flink_agents/runtime/resource_cache.py
@@ -94,8 +94,15 @@ class ResourceCache:
         return resource
 
     def close(self) -> None:
-        """Clean up all cached resources."""
+        """Clean up all cached resources and close the injected 
ResourceContext.
+
+        Cascades to ``ResourceContextImpl.close()`` which in turn closes the
+        cached ``SkillManager`` (releasing materialized skill temp dirs). This
+        is what releases skill resources on operator close, including Flink
+        failover when the JVM stays up.
+        """
         for typed in self._cache.values():
             for resource in typed.values():
                 resource.close()
         self._cache.clear()
+        self._resource_context.close()
diff --git a/python/flink_agents/runtime/resource_context.py 
b/python/flink_agents/runtime/resource_context.py
index 1cfb24b9..11fb5d9f 100644
--- a/python/flink_agents/runtime/resource_context.py
+++ b/python/flink_agents/runtime/resource_context.py
@@ -75,6 +75,19 @@ class ResourceContextImpl(ResourceContext):
             self._skill_manager = self._create_skill_manager()
         return self._skill_manager
 
+    def close(self) -> None:
+        """Close the lazily-cached SkillManager, releasing materialized temp
+        directories owned by its repositories. Called via
+        ``ResourceCache.close()`` on operator close (including Flink failover
+        when the JVM stays up). Idempotent.
+        """
+        if self._skill_manager is not None:
+            try:
+                self._skill_manager.close()
+            finally:
+                self._skill_manager = None
+                self._skill_manager_initialized = False
+
     def _create_skill_manager(self) -> SkillManager | None:
         try:
             skills_config = cast(
diff --git a/python/flink_agents/runtime/skill/agent_skill.py 
b/python/flink_agents/runtime/skill/agent_skill.py
index df44d48e..d94b09c5 100644
--- a/python/flink_agents/runtime/skill/agent_skill.py
+++ b/python/flink_agents/runtime/skill/agent_skill.py
@@ -20,6 +20,23 @@ from typing import Callable, Dict, List
 from pydantic import BaseModel, Field, PrivateAttr
 
 
+class SkillOrigin(BaseModel):
+    """Identifies the source from which an :class:`AgentSkill` was loaded.
+
+    Attached to each skill at registration time and used for logging (e.g.
+    duplicate-name WARN) and debugging. ``scheme`` mirrors the
+    ``SkillSourceSpec`` scheme (``"local"`` / ``"url"`` / ``"package"`` /
+    ``"classpath"``); ``location`` is a human-readable identifier such as a
+    filesystem path, URL, ``<package>/<resource>``, or classpath resource name.
+    """
+
+    scheme: str
+    location: str
+
+    def __str__(self) -> str:
+        return f"{self.scheme}:{self.location}"
+
+
 class AgentSkill(BaseModel):
     """Represents an agent skill that can be loaded and used by agents.
 
@@ -53,6 +70,7 @@ class AgentSkill(BaseModel):
     compatibility: str | None = Field(default=None, max_length=500)
     metadata: Dict[str, str] | None = Field(default=None)
     resources: Dict[str, str] | None = None
+    origin: SkillOrigin | None = Field(default=None)
 
     _resource_loader: Callable[[], Dict[str, str]] | None = 
PrivateAttr(default=None)
     _activated: bool = PrivateAttr(default=False)
@@ -61,6 +79,12 @@ class AgentSkill(BaseModel):
         """Set a lazy resource loader for this skill."""
         self._resource_loader = loader
 
+    def set_origin(self, origin: SkillOrigin) -> None:
+        """Set the source this skill was loaded from. Called by 
``SkillManager`` at
+        registration time; used for duplicate-name WARN and debugging.
+        """
+        self.origin = origin
+
     def _activate(self) -> None:
         """Load resources lazily on first access."""
         if not self._activated and self._resource_loader is not None:
diff --git a/python/flink_agents/runtime/skill/repository/_materialize.py 
b/python/flink_agents/runtime/skill/repository/_materialize.py
new file mode 100644
index 00000000..4119f617
--- /dev/null
+++ b/python/flink_agents/runtime/skill/repository/_materialize.py
@@ -0,0 +1,166 @@
+################################################################################
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+# limitations under the License.
+#################################################################################
+"""Internal helpers for materializing skills from non-filesystem sources."""
+
+from __future__ import annotations
+
+import atexit
+import os
+import shutil
+import tempfile
+import zipfile
+from pathlib import Path
+from typing import TYPE_CHECKING
+from urllib.request import Request, urlopen
+
+if TYPE_CHECKING:
+    from typing_extensions import Self
+
+_TEMP_DIR_PREFIX = "flink-agents-skills-"
+
+
+class Materialized:
+    """Owns one temp directory plus a fallback atexit cleanup handler.
+
+    :meth:`close` unregisters the handler and removes the dir eagerly;
+    it is idempotent. Mirrors Java's ``SkillMaterializer.Materialized``.
+    """
+
+    def __init__(self, dir_: Path, *, borrowed: bool = False) -> None:
+        """Wrap ``dir_`` as an owned (or borrowed) handle.
+
+        Args:
+            dir_: The directory to wrap.
+            borrowed: If True, the caller owns the dir; ``close()`` is a
+                no-op and no atexit handler is registered.
+        """
+        self.dir = dir_
+        self._closed = False
+        self._borrowed = borrowed
+        if not borrowed:
+            atexit.register(self._cleanup)
+
+    @classmethod
+    def borrowed(cls, existing_dir: Path) -> Materialized:
+        """Wrap an existing directory the caller does not own."""
+        return cls(existing_dir, borrowed=True)
+
+    def _cleanup(self) -> None:
+        shutil.rmtree(self.dir, ignore_errors=True)
+
+    def close(self) -> None:
+        """Release the temp dir eagerly. Idempotent."""
+        if self._closed:
+            return
+        self._closed = True
+        if self._borrowed:
+            return
+        # atexit.unregister matches by identity; passing the bound method 
works because
+        # the same bound-method instance was registered in __init__.
+        atexit.unregister(self._cleanup)
+        self._cleanup()
+
+    def __enter__(self) -> Self:
+        return self
+
+    def __exit__(self, *exc: object) -> None:
+        self.close()
+
+
+def copy_dir_to_temp(src_dir: Path) -> Materialized:
+    """Copy ``src_dir`` into a fresh owned tempdir and return a Materialized.
+
+    Used when the caller has transient access to a directory (e.g. inside an
+    ``importlib.resources.as_file`` context) and wants a lasting owned copy
+    whose lifetime is independent of the original.
+
+    Args:
+        src_dir: Source directory to copy.
+
+    Returns:
+        A :class:`Materialized` handle owning the copied directory.
+    """
+    extract_dir = Path(tempfile.mkdtemp(prefix=_TEMP_DIR_PREFIX)).resolve()
+    materialized = Materialized(extract_dir)
+    try:
+        shutil.copytree(src_dir, extract_dir, dirs_exist_ok=True)
+    except Exception:
+        materialized.close()
+        raise
+    return materialized
+
+
+def extract_zip_safely(zip_path: Path) -> Materialized:
+    """Extract a zip into a fresh temp dir, returning a :class:`Materialized`.
+
+    Each entry is validated against zip-slip. ``close()`` the returned handle
+    to free the dir eagerly; an atexit cleanup is the fallback.
+
+    Args:
+        zip_path: Path to the zip file to extract.
+
+    Returns:
+        A :class:`Materialized` handle owning the extraction directory.
+
+    Raises:
+        ValueError: if any zip entry resolves outside the extraction directory.
+    """
+    extract_dir = Path(tempfile.mkdtemp(prefix=_TEMP_DIR_PREFIX)).resolve()
+    # Construct the handle before validation so the (empty) tempdir is always 
reclaimed,
+    # even if validation raises.
+    materialized = Materialized(extract_dir)
+    with zipfile.ZipFile(zip_path) as zf:
+        for member in zf.infolist():
+            target = (extract_dir / member.filename).resolve()
+            if not target.is_relative_to(extract_dir):
+                msg = f"Unsafe zip entry: {member.filename}"
+                raise ValueError(msg)
+        zf.extractall(extract_dir)
+    return materialized
+
+
+def download_to_tempfile(url: str, timeout: int = 90) -> Path:
+    """Download ``url`` to a temp file and return its path.
+
+    Uses ``urllib.request`` from the standard library. ``timeout`` is the
+    socket-level timeout passed to ``urlopen`` and applies to both the
+    connection and the read phases.
+
+    Args:
+        url: The URL to download.
+        timeout: Socket timeout in seconds.
+
+    Returns:
+        Path to the downloaded temp file (caller is responsible for deletion).
+
+    Raises:
+        urllib.error.HTTPError / URLError on HTTP or transport failures.
+    """
+    req = Request(url, method="GET")
+    # The .zip suffix is load-bearing: FileSystemSkillRepository uses
+    # path.suffix == ".zip" to detect zip input. Do not change it.
+    fd, tmp_path_str = tempfile.mkstemp(prefix=_TEMP_DIR_PREFIX, suffix=".zip")
+    os.close(fd)
+    tmp_path = Path(tmp_path_str)
+    try:
+        with urlopen(req, timeout=timeout) as resp, tmp_path.open("wb") as out:
+            shutil.copyfileobj(resp, out)
+    except Exception:
+        tmp_path.unlink(missing_ok=True)
+        raise
+    return tmp_path
diff --git 
a/python/flink_agents/runtime/skill/repository/filesystem_repository.py 
b/python/flink_agents/runtime/skill/repository/filesystem_repository.py
index 6e6eaa04..8fc7d67e 100644
--- a/python/flink_agents/runtime/skill/repository/filesystem_repository.py
+++ b/python/flink_agents/runtime/skill/repository/filesystem_repository.py
@@ -15,196 +15,60 @@
 #  See the License for the specific language governing permissions and
 # limitations under the License.
 
################################################################################
-import logging
-import os
-from pathlib import Path
-from typing import Dict, List
-
-from typing_extensions import override
+"""Filesystem-backed :class:`SkillRepository`.
 
-from flink_agents.runtime.skill.agent_skill import AgentSkill
-from flink_agents.runtime.skill.skill_parser import SkillParser
-from flink_agents.runtime.skill.skill_repository import (
-    SkillRepository,
-)
+Accepts either a directory whose immediate subdirectories each contain a
+``SKILL.md``, or a ``.zip`` file that expands into such a layout.
+"""
 
-logger = logging.getLogger(__name__)
+from __future__ import annotations
 
+from pathlib import Path
 
-class FileSystemSkillRepository(SkillRepository):
-    """File system based implementation of SkillRepository.
+from flink_agents.runtime.skill.repository._materialize import (
+    Materialized,
+    extract_zip_safely,
+)
+from flink_agents.runtime.skill.repository.materialized_skill_repository 
import (
+    MaterializedSkillRepository,
+)
 
-    This repository stores skills in a local file system directory structure
-    where each skill is stored in its own subdirectory containing a SKILL.md
-    file and optional resource files.
 
-    Directory structure:
+class FileSystemSkillRepository(MaterializedSkillRepository):
+    """Filesystem-backed :class:`SkillRepository`.
 
-    baseDir/
-    ├── skill-name-1/
-    │   ├── SKILL.md          # Required: Entry file with YAML frontmatter
-    │   ├── references/       # Optional: Reference documentation
-    │   ├── examples/         # Optional: Example files
-    │   └── scripts/          # Optional: Script files
-    └── skill-name-2/
-        └── SKILL.md
+    Accepts a directory whose immediate subdirectories each contain a
+    ``SKILL.md``, or a ``.zip`` that extracts into such a layout.
     """
 
-    SKILL_MD_FILE = "SKILL.md"
-
-    def __init__(
-        self,
-        base_dir: Path | str,
-    ) -> None:
-        """Create a FileSystemSkillRepository.
-
-        Args:
-            base_dir: The base directory containing skill subdirectories.
-            skip_dirs: Optional set of directory names to skip.
-            skip_patterns: Optional set of file patterns to skip.
+    def __init__(self, base_dir: Path | str) -> None:
+        """Open a directory or ``.zip`` of skills.
 
         Raises:
-            ValueError: If base_dir is None, doesn't exist, or is not a 
directory.
+            ValueError: If ``base_dir`` is None, doesn't exist, or is neither
+                a directory nor a ``.zip`` file.
         """
         if base_dir is None:
             msg = "Base directory cannot be None"
             raise ValueError(msg)
 
-        # Convert to Path and normalize
-        self._base_dir = Path(base_dir).resolve()
+        path = Path(base_dir).resolve()
 
-        # Validate directory exists
-        if not self._base_dir.exists():
-            msg = f"Base directory does not exist: {self._base_dir}"
+        if not path.exists():
+            msg = f"Path does not exist: {path}"
             raise ValueError(msg)
 
-        # Validate it's a directory
-        if not self._base_dir.is_dir():
-            msg = f"Base directory is not a directory: {self._base_dir}"
+        if path.is_dir():
+            materialization = Materialized.borrowed(path)
+        elif path.is_file() and path.suffix.lower() == ".zip":
+            materialization = extract_zip_safely(path)
+        else:
+            msg = f"Path must be a directory or a .zip file: {path}"
             raise ValueError(msg)
 
+        super().__init__(materialization)
+
     @property
     def base_dir(self) -> Path:
-        """Get the base directory.
-
-        Returns:
-            The base directory path.
-        """
-        return self._base_dir
-
-    @override
-    def get_skill(self, name: str) -> AgentSkill | None:
-        """Get a skill by name.
-
-        Args:
-            name: The skill name.
-
-        Returns:
-            The skill, or None if not found.
-        """
-        skill_dir = self._base_dir / name
-        skill_md_path = skill_dir / self.SKILL_MD_FILE
-
-        if not skill_md_path.exists():
-            return None
-
-        return self._load_skill(skill_dir)
-
-    @override
-    def get_resources(self, name: str) -> Dict[str, str]:
-        skill_dir = self._base_dir / name
-        return self._load_resources(skill_dir)
-
-    @override
-    def get_skills(self) -> List[AgentSkill]:
-        """Get all skills in this repository.
-
-        Returns:
-            List of all skills.
-        """
-        skills = []
-        for skill_name in self._get_all_skill_names():
-            skill = self.get_skill(skill_name)
-            if skill is not None:
-                skills.append(skill)
-        return skills
-
-    def _get_all_skill_names(self) -> List[str]:
-        """Get all skill names in this repository.
-
-        Returns:
-            List of skill names.
-        """
-        return sorted(
-            [
-                entry.name
-                for entry in self._base_dir.iterdir()
-                if entry.is_dir() and (entry / self.SKILL_MD_FILE).exists()
-            ]
-        )
-
-    def _load_skill(self, skill_dir: Path) -> AgentSkill | None:
-        """Load a skill from a directory.
-
-        Args:
-            skill_dir: Path to the skill directory.
-
-        Returns:
-            The loaded skill, or None if loading failed.
-        """
-        skill_md_path = skill_dir / self.SKILL_MD_FILE
-
-        if not skill_md_path.exists():
-            return None
-
-        try:
-            skill_md_content = skill_md_path.read_text()
-
-            skill = SkillParser.parse_skill(skill_md_content)
-
-            if skill.name != skill_dir.name:
-                logger.warning(
-                    f"The skill name {skill.name} is different from the base 
directory {skill_dir.name}."
-                )
-
-        except Exception as e:
-            err_msg = f"Failed to load skill from {skill_dir}"
-            raise ValueError(err_msg) from e
-        else:
-            return skill
-
-    def _load_resources(self, skill_dir: Path) -> dict[str, str]:
-        """Load all resources from a skill directory.
-
-        Args:
-            skill_dir: Path to the skill directory.
-
-        Returns:
-            Map of relative path to content.
-        """
-        resources = {}
-
-        for root, _dirs, files in os.walk(skill_dir):
-            root_path = Path(root)
-
-            for file_name in files:
-                # Skip SKILL.md (handled separately)
-                if file_name == self.SKILL_MD_FILE:
-                    continue
-
-                file_path = root_path / file_name
-                relative_path = str(file_path.relative_to(skill_dir))
-
-                try:
-                    # Try to read as text
-                    content = file_path.read_text()
-                    resources[relative_path] = content
-                except UnicodeDecodeError:
-                    content = file_path.read_bytes()
-                    resources[relative_path] = f"base64: {content}"
-                except Exception:
-                    logging.warning(
-                        f"Failed to read resource file {file_path}", 
exc_info=True
-                    )
-
-        return resources
+        """Absolute base directory."""
+        return self._materialization.dir
diff --git 
a/python/flink_agents/runtime/skill/repository/materialized_skill_repository.py 
b/python/flink_agents/runtime/skill/repository/materialized_skill_repository.py
new file mode 100644
index 00000000..634f4559
--- /dev/null
+++ 
b/python/flink_agents/runtime/skill/repository/materialized_skill_repository.py
@@ -0,0 +1,72 @@
+################################################################################
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+# limitations under the License.
+################################################################################
+"""Base for SkillRepository implementations whose skills live on disk under a
+single :class:`Materialized` directory. Subclasses contribute only the
+source-specific I/O that produces that handle in their constructor; reading
+and close lifecycle are handled here.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Dict, List
+
+from typing_extensions import override
+
+from flink_agents.runtime.skill.repository.skill_directory_reader import (
+    SkillDirectoryReader,
+)
+from flink_agents.runtime.skill.skill_repository import SkillRepository
+
+if TYPE_CHECKING:
+    from pathlib import Path
+
+    from flink_agents.runtime.skill.agent_skill import AgentSkill
+    from flink_agents.runtime.skill.repository._materialize import Materialized
+
+
+class MaterializedSkillRepository(SkillRepository):
+    """SkillRepository whose backing data is a single :class:`Materialized`
+    directory — borrowed (already on disk) or owned (extracted from a zip /
+    download). :meth:`close` is unconditional because ``Materialized.close``
+    collapses both cases.
+    """
+
+    def __init__(self, materialization: Materialized) -> None:
+        """Initialize a :class:`MaterializedSkillRepository`."""
+        self._materialization = materialization
+        self._reader = SkillDirectoryReader(materialization.dir)
+
+    @override
+    def get_skill(self, name: str) -> AgentSkill | None:
+        return self._reader.get_skill(name)
+
+    @override
+    def get_skills(self) -> List[AgentSkill]:
+        return self._reader.get_skills()
+
+    @override
+    def get_resources(self, name: str) -> Dict[str, str]:
+        return self._reader.get_resources(name)
+
+    @override
+    def get_skill_dir(self, name: str) -> Path:
+        return self._reader.get_skill_dir(name)
+
+    @override
+    def close(self) -> None:
+        self._materialization.close()
diff --git a/python/flink_agents/runtime/skill/repository/package_repository.py 
b/python/flink_agents/runtime/skill/repository/package_repository.py
new file mode 100644
index 00000000..7547b607
--- /dev/null
+++ b/python/flink_agents/runtime/skill/repository/package_repository.py
@@ -0,0 +1,94 @@
+################################################################################
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+# limitations under the License.
+################################################################################
+"""Package-resource based :class:`SkillRepository`.
+
+Resolves a resource (directory or zip) inside an installed Python package via
+:mod:`importlib.resources` and materializes it once at construction time into
+a process-owned temp directory.
+"""
+
+from __future__ import annotations
+
+from importlib.resources import as_file, files
+from pathlib import Path
+
+from flink_agents.runtime.skill.repository._materialize import (
+    copy_dir_to_temp,
+    extract_zip_safely,
+)
+from flink_agents.runtime.skill.repository.materialized_skill_repository 
import (
+    MaterializedSkillRepository,
+)
+
+
+class PackageSkillRepository(MaterializedSkillRepository):
+    """Skill repository backed by a resource inside an installed Python 
package.
+
+    The resource (directory or ``.zip``) is copied / extracted into a
+    process-owned temp directory at construction time. The
+    ``importlib.resources.as_file`` context is released immediately after
+    materialization, so close lifecycle reduces to releasing that single owned
+    temp directory.
+    """
+
+    def __init__(self, package: str, resource: str) -> None:
+        """Open ``resource`` inside ``package``.
+
+        Args:
+            package: Dotted package name.
+            resource: Path inside the package; directory or ``.zip``.
+
+        Raises:
+            ValueError: If the resource is missing, or is neither a directory
+                nor a ``.zip`` file.
+        """
+        traversable = files(package).joinpath(resource)
+        if not traversable.is_dir() and not traversable.is_file():
+            msg = f"Resource {resource!r} not found in package {package!r}"
+            raise ValueError(msg)
+
+        self._package = package
+        self._resource = resource
+
+        # Materialize inside the as_file context so the path is valid, then
+        # let the context release; the resulting Materialized owns its own
+        # temp dir and outlives the context.
+        with as_file(traversable) as path:
+            path = Path(path)
+            if path.is_dir():
+                materialization = copy_dir_to_temp(path)
+            elif path.is_file() and path.suffix.lower() == ".zip":
+                materialization = extract_zip_safely(path)
+            else:
+                msg = (
+                    f"Package resource must be a directory or a .zip: "
+                    f"{package}/{resource}"
+                )
+                raise ValueError(msg)
+
+        super().__init__(materialization)
+
+    @property
+    def package(self) -> str:
+        """Source package this repo was loaded from."""
+        return self._package
+
+    @property
+    def resource(self) -> str:
+        """Resource path inside :attr:`package`."""
+        return self._resource
diff --git 
a/python/flink_agents/runtime/skill/repository/skill_directory_reader.py 
b/python/flink_agents/runtime/skill/repository/skill_directory_reader.py
new file mode 100644
index 00000000..983df5f1
--- /dev/null
+++ b/python/flink_agents/runtime/skill/repository/skill_directory_reader.py
@@ -0,0 +1,139 @@
+################################################################################
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+# limitations under the License.
+################################################################################
+"""Read-only accessor for an on-disk directory of skills.
+
+Composed by :class:`SkillRepository` implementations to handle the
+"parse SKILL.md under base_dir" half of their work, leaving each repo
+free to manage its own materialization and ``close()`` story.
+"""
+
+from __future__ import annotations
+
+import base64
+import logging
+import os
+from pathlib import Path
+from typing import TYPE_CHECKING, Dict, List
+
+from flink_agents.runtime.skill.skill_parser import SkillParser
+
+if TYPE_CHECKING:
+    from flink_agents.runtime.skill.agent_skill import AgentSkill
+
+logger = logging.getLogger(__name__)
+
+
+class SkillDirectoryReader:
+    """Reads skills from an already-materialized directory. No lifecycle."""
+
+    SKILL_MD_FILE = "SKILL.md"
+
+    def __init__(self, base_dir: Path) -> None:
+        """Wrap ``base_dir`` (must be an existing directory)."""
+        if base_dir is None:
+            msg = "Base directory cannot be None"
+            raise ValueError(msg)
+        resolved = Path(base_dir).resolve()
+        if not resolved.exists():
+            msg = f"Path does not exist: {resolved}"
+            raise ValueError(msg)
+        if not resolved.is_dir():
+            msg = f"Path must be a directory: {resolved}"
+            raise ValueError(msg)
+        self._base_dir = resolved
+
+    @property
+    def base_dir(self) -> Path:
+        """Absolute base directory."""
+        return self._base_dir
+
+    def get_skill_dir(self, name: str) -> Path:
+        """``base_dir / name``; existence not checked."""
+        return self._base_dir / name
+
+    def get_skill(self, name: str) -> AgentSkill | None:
+        """Parse ``base_dir/name/SKILL.md``; ``None`` if absent."""
+        skill_dir = self._base_dir / name
+        skill_md_path = skill_dir / self.SKILL_MD_FILE
+        if not skill_md_path.exists():
+            return None
+        return self._load_skill(skill_dir)
+
+    def get_skills(self) -> List[AgentSkill]:
+        """All skills under ``base_dir``, sorted by name."""
+        skills: List[AgentSkill] = []
+        for skill_name in self._list_skill_names():
+            skill = self.get_skill(skill_name)
+            if skill is not None:
+                skills.append(skill)
+        return skills
+
+    def get_resources(self, name: str) -> Dict[str, str]:
+        """All non-``SKILL.md`` files under the named skill, keyed by relative 
path."""
+        skill_dir = self._base_dir / name
+        if not skill_dir.is_dir():
+            return {}
+        return self._load_resources(skill_dir)
+
+    def _list_skill_names(self) -> List[str]:
+        return sorted(
+            [
+                entry.name
+                for entry in self._base_dir.iterdir()
+                if entry.is_dir() and (entry / self.SKILL_MD_FILE).exists()
+            ]
+        )
+
+    def _load_skill(self, skill_dir: Path) -> AgentSkill | None:
+        skill_md_path = skill_dir / self.SKILL_MD_FILE
+        if not skill_md_path.exists():
+            return None
+        try:
+            content = skill_md_path.read_text()
+            skill = SkillParser.parse_skill(content)
+            if skill.name != skill_dir.name:
+                logger.warning(
+                    "The skill name %s is different from the base directory 
%s.",
+                    skill.name,
+                    skill_dir.name,
+                )
+        except Exception as e:
+            err_msg = f"Failed to load skill from {skill_dir}"
+            raise ValueError(err_msg) from e
+        else:
+            return skill
+
+    def _load_resources(self, skill_dir: Path) -> Dict[str, str]:
+        resources: Dict[str, str] = {}
+        for root, _dirs, files in os.walk(skill_dir):
+            root_path = Path(root)
+            for file_name in files:
+                if file_name == self.SKILL_MD_FILE:
+                    continue
+                file_path = root_path / file_name
+                relative_path = str(file_path.relative_to(skill_dir))
+                try:
+                    resources[relative_path] = file_path.read_text()
+                except UnicodeDecodeError:
+                    encoded = 
base64.b64encode(file_path.read_bytes()).decode("ascii")
+                    resources[relative_path] = f"base64: {encoded}"
+                except Exception:
+                    logger.warning(
+                        "Failed to read resource file %s", file_path, 
exc_info=True
+                    )
+        return resources
diff --git a/python/flink_agents/runtime/skill/repository/url_repository.py 
b/python/flink_agents/runtime/skill/repository/url_repository.py
new file mode 100644
index 00000000..b4e4f888
--- /dev/null
+++ b/python/flink_agents/runtime/skill/repository/url_repository.py
@@ -0,0 +1,66 @@
+################################################################################
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+# limitations under the License.
+################################################################################
+"""URL-based :class:`SkillRepository`.
+
+Downloads a zip from an http(s) URL into a temp file, extracts it into a
+process-local temp directory, and reads skills from there.
+"""
+
+from __future__ import annotations
+
+from flink_agents.runtime.skill.repository._materialize import (
+    download_to_tempfile,
+    extract_zip_safely,
+)
+from flink_agents.runtime.skill.repository.materialized_skill_repository 
import (
+    MaterializedSkillRepository,
+)
+
+_REQUEST_TIMEOUT_SEC = 90
+
+
+class URLSkillRepository(MaterializedSkillRepository):
+    """Skill repository backed by an http(s) URL pointing to a zip.
+
+    The zip is downloaded then extracted into a process-local temp directory
+    (released eagerly via :meth:`close` or at process exit).
+    """
+
+    def __init__(self, url: str) -> None:
+        """Download and extract the zip at ``url``.
+
+        Raises:
+            ValueError: If the URL is not http(s).
+            urllib.error.HTTPError / URLError: On transport/HTTP failures.
+        """
+        if not url.startswith(("http://";, "https://";)):
+            msg = f"Only http(s) URLs are supported: {url}"
+            raise ValueError(msg)
+
+        self._url = url
+        tmp_zip = download_to_tempfile(url, timeout=_REQUEST_TIMEOUT_SEC)
+        try:
+            materialization = extract_zip_safely(tmp_zip)
+        finally:
+            tmp_zip.unlink(missing_ok=True)
+        super().__init__(materialization)
+
+    @property
+    def url(self) -> str:
+        """Source URL this repo was loaded from."""
+        return self._url
diff --git a/python/flink_agents/runtime/skill/skill_manager.py 
b/python/flink_agents/runtime/skill/skill_manager.py
index deb9ce82..c5ccf592 100644
--- a/python/flink_agents/runtime/skill/skill_manager.py
+++ b/python/flink_agents/runtime/skill/skill_manager.py
@@ -15,19 +15,34 @@
 #  See the License for the specific language governing permissions and
 # limitations under the License.
 
#################################################################################
+import contextlib
+import logging
 from pathlib import Path
 from typing import TYPE_CHECKING, Dict, List
 
-from flink_agents.api.skills import Skills
-from flink_agents.runtime.skill.agent_skill import AgentSkill
-from flink_agents.runtime.skill.repository.filesystem_repository import (
-    FileSystemSkillRepository,
-)
+from flink_agents.api.skills import Skills, SkillSourceSpec
+from flink_agents.runtime.skill import skill_source_registry
+from flink_agents.runtime.skill.agent_skill import AgentSkill, SkillOrigin
 from flink_agents.runtime.skill.skill_prompt_provider import 
SkillPromptProvider
 
 if TYPE_CHECKING:
     from flink_agents.runtime.skill.skill_repository import SkillRepository
 
+logger = logging.getLogger(__name__)
+
+
+def _origin_of(spec: SkillSourceSpec) -> SkillOrigin:
+    """Build a SkillOrigin from a spec for diagnostics.
+
+    Delegates location description to the handler so a new scheme is one
+    ``register()`` call, not a parallel switch here.
+    """
+    handler = skill_source_registry.get(spec.scheme)
+    return SkillOrigin(
+        scheme=spec.scheme,
+        location=handler.describe_location(spec.params),
+    )
+
 
 class SkillManager:
     """Internal runtime component for loading, parsing, and managing skills.
@@ -45,8 +60,12 @@ class SkillManager:
         """Initialize the SkillManager from a Skills configuration."""
         self._skills: Dict[str, AgentSkill] = {}
         self._repos: Dict[str, SkillRepository] = {}
+        # Every opened repo in load order, kept separately from `_repos` 
because that
+        # map is keyed by skill name — duplicate names overwrite the earlier 
repo's
+        # reference, so close() iterates this list (id-deduped) instead.
+        self._opened_repos: List[SkillRepository] = []
         self._config = skills_config
-        self._load_skills_from_paths()
+        self._load_skills()
 
     @property
     def size(self) -> int:
@@ -93,23 +112,30 @@ class SkillManager:
         """Return absolute directory paths for the given skill names.
 
         If no names are provided, returns directories for all filesystem-backed
-        skills. Unknown names and skills not backed by a filesystem repo are
-        silently skipped.
+        skills. Skills not backed by a filesystem repo are silently skipped.
+
+        Raises:
+            ValueError: If any provided name is not a registered skill.
         """
         selected = names if names else tuple(self._repos.keys())
         dirs: List[str] = []
         for skill_name in selected:
             repo = self._repos.get(skill_name)
-            if isinstance(repo, FileSystemSkillRepository):
-                dirs.append(str(repo.base_dir / skill_name))
+            if repo is None:
+                msg = (
+                    f"Skill {skill_name} not found, "
+                    f"available skill names are: {list(self._repos.keys())}"
+                )
+                raise ValueError(msg)
+            dir_path = repo.get_skill_dir(skill_name)
+            if dir_path is not None:
+                dirs.append(str(dir_path))
         return dirs
 
     def get_skill_dir(self, skill_name: str) -> Path | None:
         """Return absolute directory path for a single skill, if 
filesystem-backed."""
         repo = self._repos.get(skill_name)
-        if isinstance(repo, FileSystemSkillRepository):
-            return repo.base_dir / skill_name
-        return None
+        return None if repo is None else repo.get_skill_dir(skill_name)
 
     def resolve_resource_path(self, skill_name: str, resource_path: str) -> 
Path | None:
         """Resolve a skill resource's relative path to an absolute filesystem 
path.
@@ -117,18 +143,68 @@ class SkillManager:
         Returns None if the skill's repository doesn't support path resolution.
         """
         repo = self._repos.get(skill_name)
-        if isinstance(repo, FileSystemSkillRepository):
-            resolved = repo.base_dir / skill_name / resource_path
-            if resolved.exists() and resolved.is_file():
-                return resolved
-        return None
-
-    def _load_skills_from_paths(self) -> None:
-        for path in self._config.paths:
-            repo = FileSystemSkillRepository(path)
-            for skill in repo.get_skills():
-                skill.set_resource_loader(
-                    lambda name=skill.name, r=repo: r.get_resources(name)
+        if repo is None:
+            return None
+        dir_path = repo.get_skill_dir(skill_name)
+        if dir_path is None:
+            return None
+        resolved = dir_path / resource_path
+        return resolved if resolved.is_file() else None
+
+    def _load_skills(self) -> None:
+        for spec in self._config.sources:
+            try:
+                handler = skill_source_registry.get(spec.scheme)
+                repo = handler.open(spec.params)
+                self._opened_repos.append(repo)
+            except (OSError, ValueError) as e:
+                # Release repos opened by earlier iterations — the caller never
+                # receives a SkillManager reference to clean them up via 
close()
+                # itself, so without this their temp dirs / atexit handlers 
leak
+                # until interpreter exit.
+                self.close()
+                msg = (
+                    f"Failed to load skills from {spec.scheme}:{spec.params}"
                 )
-                self._skills[skill.name] = skill
-                self._repos[skill.name] = repo
+                raise RuntimeError(msg) from e
+            self._register_repo(repo, _origin_of(spec))
+
+    def _register_repo(self, repo: "SkillRepository", origin: SkillOrigin) -> 
None:
+        for skill in repo.get_skills():
+            skill.set_resource_loader(
+                lambda name=skill.name, r=repo: r.get_resources(name)
+            )
+            skill.set_origin(origin)
+            previous = self._skills.get(skill.name)
+            if previous is not None:
+                logger.warning(
+                    "Skill '%s' from %s overrides earlier registration from 
%s",
+                    skill.name,
+                    origin,
+                    previous.origin if previous.origin is not None else 
"<unknown>",
+                )
+            self._skills[skill.name] = skill
+            self._repos[skill.name] = repo
+
+    def close(self) -> None:
+        """Close every opened :class:`SkillRepository`, releasing any temp 
directory
+        materialized for URL / classpath-zip / package sources. Idempotent.
+
+        Iterates ``_opened_repos`` rather than ``_repos.values()``: duplicate 
skill
+        names overwrite the earlier repo's reference in ``_repos``, but the 
displaced
+        repo is still owned and must be closed. Dedup by identity in case the 
same
+        repo contributes multiple skills.
+        """
+        seen: set[int] = set()
+        for repo in self._opened_repos:
+            if id(repo) in seen:
+                continue
+            seen.add(id(repo))
+            with contextlib.suppress(Exception):
+                repo.close()
+
+    def __enter__(self) -> "SkillManager":
+        return self
+
+    def __exit__(self, *exc: object) -> None:
+        self.close()
diff --git a/python/flink_agents/runtime/skill/skill_repository.py 
b/python/flink_agents/runtime/skill/skill_repository.py
index 1d2cd407..5cca1f8b 100644
--- a/python/flink_agents/runtime/skill/skill_repository.py
+++ b/python/flink_agents/runtime/skill/skill_repository.py
@@ -17,6 +17,7 @@
 
#################################################################################
 from abc import ABC, abstractmethod
 from dataclasses import dataclass
+from pathlib import Path
 from typing import Dict, List
 
 from flink_agents.runtime.skill.agent_skill import AgentSkill
@@ -42,43 +43,31 @@ class SkillRepositoryInfo:
 
 
 class SkillRepository(ABC):
-    """Abstract interface for skill repositories.
+    """Source of skills, loaded from filesystem / classpath / URL / package.
 
-    A SkillRepository is responsible for loading and optionally storing skills
-    from a specific source (filesystem, classpath, URL, etc.).
-
-    Each skill is stored in its own subdirectory containing a SKILL.md file
-    and optional resource files:
-
-    baseDir/
-    ├── skill-name-1/
-    │   ├── SKILL.md          # Required: Entry file with YAML frontmatter
-    │   ├── references/       # Optional: Reference documentation
-    │   ├── examples/         # Optional: Example files
-    │   └── scripts/          # Optional: Script files
-    └── skill-name-2/
-        └── SKILL.md
+    Each skill lives under ``base_dir/<name>/`` with a required ``SKILL.md``
+    and optional resource files (``references/``, ``scripts/``, ...).
     """
 
     @abstractmethod
-    def get_skill(self, name: str) -> str:
-        """Get a skill by name.
-
-        Args:
-            name: The skill name.
-
-        Returns:
-            The skill, or None if not found.
-        """
+    def get_skill(self, name: str) -> AgentSkill | None:
+        """Return the named skill, or ``None`` if absent."""
 
     @abstractmethod
     def get_skills(self) -> List[AgentSkill]:
-        """Get all skills in this repository.
-
-        Returns:
-            List of all skills.
-        """
+        """Return all skills in this repository."""
 
     @abstractmethod
     def get_resources(self, name: str) -> Dict[str, str]:
-        """Get resources for the specified skill."""
+        """Return resources for the named skill, keyed by relative path."""
+
+    def get_skill_dir(self, name: str) -> Path | None:
+        """Absolute on-disk directory for the named skill.
+
+        Filesystem-backed implementations return ``base_dir / name``
+        without checking existence. Non-filesystem-backed return ``None``.
+        """
+        return None
+
+    def close(self) -> None:  # noqa: B027
+        """Release any owned temp directory. Default no-op; idempotent."""
diff --git a/python/flink_agents/runtime/skill/skill_source_registry.py 
b/python/flink_agents/runtime/skill/skill_source_registry.py
new file mode 100644
index 00000000..c193126a
--- /dev/null
+++ b/python/flink_agents/runtime/skill/skill_source_registry.py
@@ -0,0 +1,131 @@
+################################################################################
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+# limitations under the License.
+#################################################################################
+"""Scheme-keyed registry mapping a skill source scheme (e.g. ``"local"``,
+``"url"``, ``"package"``) to a handler that opens a :class:`SkillRepository`.
+Built-ins are registered at import time; external code may add custom schemes
+via :func:`register`.
+
+Adding a new source = one :func:`register` call plus one ``SkillRepository``
+implementation. ``SkillManager`` need not change.
+
+The Java side has an analogous ``SkillSourceRegistry`` registering ``local``
+/ ``url`` / ``classpath``. Cross-language unsupported schemes (Java's
+``classpath`` on Python, Python's ``package`` on Java) hit the registry's
+fail-loud path at load time.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING, Callable, Dict, Mapping
+
+from flink_agents.runtime.skill.repository.filesystem_repository import (
+    FileSystemSkillRepository,
+)
+from flink_agents.runtime.skill.repository.package_repository import (
+    PackageSkillRepository,
+)
+from flink_agents.runtime.skill.repository.url_repository import 
URLSkillRepository
+
+if TYPE_CHECKING:
+    from flink_agents.runtime.skill.skill_repository import SkillRepository
+
+
+@dataclass(frozen=True)
+class SkillSourceHandler:
+    """Pair of (open, describe_location) bound to a scheme.
+
+    ``describe_location`` returns the human-readable source location for
+    :class:`SkillOrigin`. The default falls back to the raw params dict;
+    built-ins override it to point at the relevant param (e.g. ``path`` for
+    local). Keeping description on the handler removes the parallel scheme
+    ladder ``SkillManager`` would otherwise need.
+    """
+
+    open: Callable[[Mapping[str, str]], SkillRepository]
+    describe_location: Callable[[Mapping[str, str]], str] = field(
+        default=lambda params: str(dict(params)),
+    )
+
+
+_HANDLERS: Dict[str, SkillSourceHandler] = {}
+
+
+def register(
+    scheme: str,
+    open_fn: Callable[[Mapping[str, str]], SkillRepository],
+    describe_location: Callable[[Mapping[str, str]], str] | None = None,
+) -> None:
+    """Register a handler under ``scheme``. Scheme is normalized to lowercase.
+
+    ``describe_location`` defaults to the raw params dict; pass a tighter
+    function (e.g. ``lambda p: p.get("path", "")``) to produce a clean origin.
+    """
+    handler = (
+        SkillSourceHandler(open=open_fn)
+        if describe_location is None
+        else SkillSourceHandler(open=open_fn, 
describe_location=describe_location)
+    )
+    _HANDLERS[scheme.lower()] = handler
+
+
+def get(scheme: str) -> SkillSourceHandler:
+    """Return the handler for ``scheme``, or raise if unknown.
+
+    The error message lists the currently registered schemes to aid debugging
+    cross-language plan mismatches.
+    """
+    handler = _HANDLERS.get(scheme.lower())
+    if handler is None:
+        msg = (
+            f"Unknown skill source scheme: {scheme}. "
+            f"Registered schemes: {sorted(_HANDLERS.keys())}"
+        )
+        raise ValueError(msg)
+    return handler
+
+
+def _require(params: Mapping[str, str], scheme: str, key: str) -> str:
+    value = params.get(key)
+    if value is None:
+        msg = (
+            f"Missing required param '{key}' for skill source scheme "
+            f"'{scheme}'. Got: {dict(params)}"
+        )
+        raise ValueError(msg)
+    return value
+
+
+register(
+    "local",
+    lambda params: FileSystemSkillRepository(_require(params, "local", 
"path")),
+    lambda params: params.get("path", ""),
+)
+register(
+    "url",
+    lambda params: URLSkillRepository(_require(params, "url", "url")),
+    lambda params: params.get("url", ""),
+)
+register(
+    "package",
+    lambda params: PackageSkillRepository(
+        _require(params, "package", "package"),
+        _require(params, "package", "resource"),
+    ),
+    lambda params: f"{params.get('package', '')}/{params.get('resource', '')}",
+)
diff --git a/python/flink_agents/runtime/skill/tests/test_skill_repository.py 
b/python/flink_agents/runtime/skill/tests/test_filesystem_repository.py
similarity index 64%
rename from python/flink_agents/runtime/skill/tests/test_skill_repository.py
rename to python/flink_agents/runtime/skill/tests/test_filesystem_repository.py
index 8a0f1bd2..51d55b5a 100644
--- a/python/flink_agents/runtime/skill/tests/test_skill_repository.py
+++ b/python/flink_agents/runtime/skill/tests/test_filesystem_repository.py
@@ -17,6 +17,7 @@
 
################################################################################
 """Unit tests for Skill Repository components."""
 
+import zipfile
 from pathlib import Path
 
 import pytest
@@ -47,9 +48,9 @@ class TestFileSystemSkillRepository:
             FileSystemSkillRepository("/nonexistent/path")
 
     def test_get_all_skill_names(self, skills_dir: Path) -> None:
-        """Test getting all skill names."""
+        """Test getting all skills (verifies skill discovery)."""
         repo = FileSystemSkillRepository(skills_dir)
-        names = repo._get_all_skill_names()
+        names = [s.name for s in repo.get_skills()]
         assert len(names) == 2
         assert "github" in names
         assert "nano-banana-pro" in names
@@ -94,3 +95,48 @@ class TestFileSystemSkillRepository:
         names = {s.name for s in skills}
         assert "github" in names
         assert "nano-banana-pro" in names
+
+
+def _zip_dir(src: Path, dst_zip: Path) -> None:
+    """Zip ``src`` so that ``src``'s immediate children are at the zip top 
level."""
+    with zipfile.ZipFile(dst_zip, "w", zipfile.ZIP_DEFLATED) as zf:
+        for path in src.rglob("*"):
+            if path.is_file():
+                zf.write(path, arcname=path.relative_to(src))
+
+
+class TestFileSystemSkillRepositoryZip:
+    @pytest.fixture
+    def skills_zip(self, tmp_path: Path) -> Path:
+        # Reuse the existing on-disk skills directory; package it into a zip
+        # whose top level is the baseDir (skill subdirs at the top).
+        src = Path(__file__).parent / "resources" / "skills"
+        zip_path = tmp_path / "skills.zip"
+        _zip_dir(src, zip_path)
+        return zip_path
+
+    def test_create_repository_from_zip(self, skills_zip: Path) -> None:
+        repo = FileSystemSkillRepository(skills_zip)
+
+        # base_dir resolves to a temp directory containing the extracted layout
+        assert repo.base_dir.is_dir()
+        names = {p.name for p in repo.base_dir.iterdir()}
+        assert "github" in names
+        assert "nano-banana-pro" in names
+
+    def test_get_skills_from_zip(self, skills_zip: Path) -> None:
+        repo = FileSystemSkillRepository(skills_zip)
+        skills = repo.get_skills()
+        names = {s.name for s in skills}
+        assert names == {"github", "nano-banana-pro"}
+
+    def test_get_resources_from_zip(self, skills_zip: Path) -> None:
+        repo = FileSystemSkillRepository(skills_zip)
+        resources = repo.get_resources("nano-banana-pro")
+        assert "_meta.json" in resources
+
+    def test_invalid_path_kind(self, tmp_path: Path) -> None:
+        non_zip_file = tmp_path / "data.txt"
+        non_zip_file.write_text("not a zip")
+        with pytest.raises(ValueError, match=r"must be a directory or a \.zip 
file"):
+            FileSystemSkillRepository(non_zip_file)
diff --git a/python/flink_agents/runtime/skill/tests/test_manager.py 
b/python/flink_agents/runtime/skill/tests/test_manager.py
index 3e4cb253..4944f846 100644
--- a/python/flink_agents/runtime/skill/tests/test_manager.py
+++ b/python/flink_agents/runtime/skill/tests/test_manager.py
@@ -17,11 +17,20 @@
 
################################################################################
 """Unit tests for SkillManager and skill tools."""
 
+import importlib
+import shutil
+import sys
+import threading
+import zipfile
+from http.server import BaseHTTPRequestHandler, HTTPServer
 from pathlib import Path
 
 import pytest
 
-from flink_agents.api.skills import Skills
+from flink_agents.api.skills import Skills, SkillSourceSpec
+from flink_agents.runtime.skill.repository.package_repository import (
+    PackageSkillRepository,
+)
 from flink_agents.runtime.skill.skill_manager import SkillManager
 
 base_dir = Path(__file__).parent
@@ -75,6 +84,35 @@ class TestSkillManager:
             == "Generate/edit images with Nano Banana Pro (Gemini 3 Pro 
Image). Use for image create/modify requests incl. edits. Supports 
text-to-image + image-to-image; 1K/2K/4K; use --input-image."
         )
 
+    def test_origin_is_attached_after_load(self, skills_dir: Path) -> None:
+        manager = SkillManager(Skills.from_local_dir(str(skills_dir)))
+        origin = manager.get_skill("github").origin
+        assert origin is not None
+        assert origin.scheme == "local"
+        assert origin.location == str(skills_dir)
+
+    def test_duplicate_skill_name_last_write_wins_with_new_origin(
+        self, tmp_path: Path
+    ) -> None:
+        # Two distinct local source dirs each contain a single skill named 
"dup".
+        dir_a = tmp_path / "a"
+        dir_b = tmp_path / "b"
+        for d, tag in [(dir_a, "from-a"), (dir_b, "from-b")]:
+            skill_dir = d / "dup"
+            skill_dir.mkdir(parents=True)
+            (skill_dir / "SKILL.md").write_text(
+                f"---\nname: dup\ndescription: dummy ({tag})\n---\nbody {tag}"
+            )
+        config = Skills(
+            sources=[
+                SkillSourceSpec(scheme="local", params={"path": str(dir_a)}),
+                SkillSourceSpec(scheme="local", params={"path": str(dir_b)}),
+            ]
+        )
+        manager = SkillManager(config)
+        # Second source wins on collision; check via origin.
+        assert manager.get_skill("dup").origin.location == str(dir_b)
+
     def test_load_skill_resource(self, skills_dir: Path) -> None:
         """Test loading a skill resource."""
         manager = SkillManager(Skills.from_local_dir(str(skills_dir)))
@@ -86,3 +124,236 @@ class TestSkillManager:
 
         nonexistent = skill.get_resource("nonexistent")
         assert nonexistent is None
+
+    def test_get_skill_dirs_raises_for_unknown_name(self, skills_dir: Path) -> 
None:
+        manager = SkillManager(Skills.from_local_dir(str(skills_dir)))
+        with pytest.raises(ValueError) as exc_info:
+            manager.get_skill_dirs("does-not-exist")
+        assert "does-not-exist" in str(exc_info.value)
+        assert "github" in str(exc_info.value)
+
+
+def _zip_dir(src: Path, dst_zip: Path) -> None:
+    with zipfile.ZipFile(dst_zip, "w", zipfile.ZIP_DEFLATED) as zf:
+        for path in src.rglob("*"):
+            if path.is_file():
+                zf.write(path, arcname=path.relative_to(src))
+
+
+class _ZipHandler(BaseHTTPRequestHandler):
+    zip_bytes: bytes = b""
+
+    def do_GET(self) -> None:
+        self.send_response(200)
+        self.send_header("Content-Type", "application/zip")
+        self.send_header("Content-Length", str(len(type(self).zip_bytes)))
+        self.end_headers()
+        self.wfile.write(type(self).zip_bytes)
+
+    def log_message(self, *_args: object) -> None:
+        pass
+
+
[email protected]
+def mixed_sources(tmp_path: Path):
+    """Yields (dir_path, zip_url, pkg_name, pkg_resource).
+
+    Spins up a local http.server, builds a tmp package on sys.path with
+    a skills/ subdirectory, and zips the existing skills tree for the
+    URL fixture. Cleans up all three on teardown.
+    """
+    src = base_dir / "resources" / "skills"
+
+    dir_path = str(src)
+
+    zip_path = tmp_path / "skills.zip"
+    _zip_dir(src, zip_path)
+    _ZipHandler.zip_bytes = zip_path.read_bytes()
+    server = HTTPServer(("127.0.0.1", 0), _ZipHandler)
+    port = server.server_address[1]
+    thread = threading.Thread(target=server.serve_forever, daemon=True)
+    thread.start()
+
+    pkg_root = tmp_path / "pkg_root"
+    pkg_root.mkdir()
+    pkg_name = "_flink_agents_test_mixed_pkg"
+    pkg_dir = pkg_root / pkg_name
+    pkg_dir.mkdir()
+    (pkg_dir / "__init__.py").write_text("")
+    shutil.copytree(src, pkg_dir / "skills")
+    sys.path.insert(0, str(pkg_root))
+    importlib.invalidate_caches()
+
+    try:
+        yield (
+            dir_path,
+            f"http://127.0.0.1:{port}/skills.zip";,
+            pkg_name,
+            "skills",
+        )
+    finally:
+        server.shutdown()
+        server.server_close()
+        _ZipHandler.zip_bytes = b""
+        sys.path.remove(str(pkg_root))
+        sys.modules.pop(pkg_name, None)
+
+
+class TestSkillManagerMixedSources:
+    def test_url_only_loads_skills(self, mixed_sources) -> None:
+        _dir, url, _pkg, _resource = mixed_sources
+        config = Skills.from_url(url)
+        manager = SkillManager(config)
+        assert set(manager.get_all_skill_names()) == {"github", 
"nano-banana-pro"}
+
+    def test_package_only_loads_skills(self, mixed_sources) -> None:
+        _dir, _url, pkg, resource = mixed_sources
+        config = Skills.from_package((pkg, resource))
+        manager = SkillManager(config)
+        assert set(manager.get_all_skill_names()) == {"github", 
"nano-banana-pro"}
+
+    def test_loads_from_all_sources(self, mixed_sources) -> None:
+        dir_path, url, pkg, resource = mixed_sources
+        # All three sources expose the same skills. Dispatch runs in 
source-list
+        # order (local -> url -> package); registration is last-wins, so the
+        # final repo for each skill must be a PackageSkillRepository if all
+        # three sources actually executed.
+        config = Skills(
+            sources=[
+                SkillSourceSpec(scheme="local", params={"path": dir_path}),
+                SkillSourceSpec(scheme="url", params={"url": url}),
+                SkillSourceSpec(
+                    scheme="package",
+                    params={"package": pkg, "resource": resource},
+                ),
+            ]
+        )
+        manager = SkillManager(config)
+        assert set(manager.get_all_skill_names()) == {"github", 
"nano-banana-pro"}
+        assert all(
+            isinstance(r, PackageSkillRepository) for r in 
manager._repos.values()
+        )
+
+    def test_close_releases_url_repo_temp_dir(self, mixed_sources) -> None:
+        _dir, url, _pkg, _resource = mixed_sources
+        config = Skills.from_url(url)
+        with SkillManager(config) as manager:
+            skill_dir = manager.get_skill_dir("github")
+            assert skill_dir is not None
+            extract_root = skill_dir.parent
+            assert skill_dir.exists()
+            assert extract_root.exists()
+        # Context manager exit triggered close.
+        assert not extract_root.exists(), (
+            "SkillManager.close() must release the URL repo's temp dir"
+        )
+
+    def test_unknown_scheme_fails_loud(self) -> None:
+        config = Skills(
+            sources=[SkillSourceSpec(scheme="future-scheme", params={"k": 
"v"})]
+        )
+        with pytest.raises(RuntimeError) as exc_info:
+            SkillManager(config)
+        assert "future-scheme" in str(exc_info.value)
+        # The registered-scheme list comes from the chained ValueError.
+        assert "local" in str(exc_info.value.__cause__)
+        assert "package" in str(exc_info.value.__cause__)
+
+    def test_close_releases_repo_displaced_by_duplicate_skill_name(self) -> 
None:
+        from typing import Dict, List
+
+        from flink_agents.runtime.skill import skill_source_registry
+        from flink_agents.runtime.skill.agent_skill import AgentSkill
+        from flink_agents.runtime.skill.skill_repository import SkillRepository
+
+        closed: List[int] = []
+
+        class FakeRepo(SkillRepository):
+            def __init__(self, tag: int) -> None:
+                self._tag = tag
+
+            def get_skill(self, name: str) -> AgentSkill | None:
+                return self.get_skills()[0] if name == "dup" else None
+
+            def get_skills(self) -> List[AgentSkill]:
+                return [AgentSkill(name="dup", description="dummy", 
content="body")]
+
+            def get_resources(self, name: str) -> Dict[str, str]:
+                return {}
+
+            def close(self) -> None:
+                closed.append(self._tag)
+
+        counter = {"n": 0}
+
+        def opener(params) -> SkillRepository:
+            counter["n"] += 1
+            return FakeRepo(counter["n"])
+
+        skill_source_registry.register("test-dup-close", opener)
+
+        config = Skills(
+            sources=[
+                SkillSourceSpec(scheme="test-dup-close", params={}),
+                SkillSourceSpec(scheme="test-dup-close", params={}),
+            ]
+        )
+        manager = SkillManager(config)
+        manager.close()
+
+        assert sorted(closed) == [1, 2], (
+            "duplicate-name registration must not orphan the displaced repo"
+        )
+
+    def test_partial_load_failure_closes_already_opened_repos(self) -> None:
+        from typing import Dict, List
+
+        from flink_agents.runtime.skill import skill_source_registry
+        from flink_agents.runtime.skill.agent_skill import AgentSkill
+        from flink_agents.runtime.skill.skill_repository import SkillRepository
+
+        closed: List[str] = []
+
+        class FakeRepo(SkillRepository):
+            def __init__(self, skill_name: str) -> None:
+                self._skill_name = skill_name
+
+            def get_skill(self, name: str) -> AgentSkill | None:
+                return self.get_skills()[0] if name == self._skill_name else 
None
+
+            def get_skills(self) -> List[AgentSkill]:
+                return [
+                    AgentSkill(
+                        name=self._skill_name, description="dummy", 
content="body"
+                    )
+                ]
+
+            def get_resources(self, name: str) -> Dict[str, str]:
+                return {}
+
+            def close(self) -> None:
+                closed.append(self._skill_name)
+
+        counter = {"n": 0}
+
+        def opener(params) -> SkillRepository:
+            counter["n"] += 1
+            if counter["n"] == 2:
+                msg = "boom"
+                raise OSError(msg)
+            return FakeRepo(f"skill-{counter['n']}")
+
+        skill_source_registry.register("test-partial-load", opener)
+
+        config = Skills(
+            sources=[
+                SkillSourceSpec(scheme="test-partial-load", params={}),
+                SkillSourceSpec(scheme="test-partial-load", params={}),
+            ]
+        )
+
+        with pytest.raises(RuntimeError):
+            SkillManager(config)
+        assert closed == ["skill-1"], (
+            "the repo opened before the partial-load failure must be closed"
+        )
diff --git a/python/flink_agents/runtime/skill/tests/test_materialize.py 
b/python/flink_agents/runtime/skill/tests/test_materialize.py
new file mode 100644
index 00000000..2285e7a2
--- /dev/null
+++ b/python/flink_agents/runtime/skill/tests/test_materialize.py
@@ -0,0 +1,152 @@
+################################################################################
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+# limitations under the License.
+#################################################################################
+"""Unit tests for the _materialize utility module."""
+
+import threading
+import zipfile
+from http.server import BaseHTTPRequestHandler, HTTPServer
+from pathlib import Path
+from urllib.error import HTTPError
+
+import pytest
+
+from flink_agents.runtime.skill.repository._materialize import (
+    Materialized,
+    download_to_tempfile,
+    extract_zip_safely,
+)
+
+
+def _make_zip(zip_path: Path, entries: dict[str, str]) -> None:
+    with zipfile.ZipFile(zip_path, "w") as zf:
+        for name, content in entries.items():
+            zf.writestr(name, content)
+
+
+class TestExtractZipSafely:
+    def test_extracts_top_level_entries(self, tmp_path: Path) -> None:
+        zip_path = tmp_path / "skills.zip"
+        _make_zip(
+            zip_path,
+            {
+                "skill-a/SKILL.md": "---\nname: skill-a\n---\nbody",
+                "skill-b/SKILL.md": "---\nname: skill-b\n---\nbody",
+            },
+        )
+
+        with extract_zip_safely(zip_path) as m:
+            extract_dir = m.dir
+            assert extract_dir.is_dir()
+            assert (extract_dir / "skill-a" / 
"SKILL.md").read_text().startswith("---")
+            assert (extract_dir / "skill-b" / "SKILL.md").is_file()
+
+    def test_rejects_zip_slip_relative(self, tmp_path: Path) -> None:
+        zip_path = tmp_path / "evil.zip"
+        _make_zip(zip_path, {"../evil.txt": "pwn"})
+
+        with pytest.raises(ValueError, match="Unsafe zip entry"):
+            extract_zip_safely(zip_path)
+
+    def test_rejects_zip_slip_absolute(self, tmp_path: Path) -> None:
+        # Defense-in-depth: CPython's extractall already strips leading 
slashes,
+        # but we reject absolute entries explicitly so we don't depend on that.
+        zip_path = tmp_path / "evil.zip"
+        _make_zip(zip_path, {"/etc/evil.txt": "pwn"})
+
+        with pytest.raises(ValueError, match="Unsafe zip entry"):
+            extract_zip_safely(zip_path)
+
+
+class TestMaterialized:
+    def test_close_removes_dir(self, tmp_path: Path) -> None:
+        zip_path = tmp_path / "skills.zip"
+        _make_zip(zip_path, {"skill-a/SKILL.md": "---\nname: 
skill-a\n---\nbody"})
+        m = extract_zip_safely(zip_path)
+        extracted = m.dir
+        assert extracted.exists()
+
+        m.close()
+        assert not extracted.exists(), "close() must remove the temp dir"
+
+        # Idempotent.
+        m.close()
+
+    def test_borrowed_does_not_remove_dir(self, tmp_path: Path) -> None:
+        target = tmp_path / "borrowed"
+        target.mkdir()
+        m = Materialized.borrowed(target)
+        m.close()
+        assert target.exists(), "borrowed dirs must not be deleted on close"
+
+
+class _StaticHandler(BaseHTTPRequestHandler):
+    payload: bytes = b""
+    status: int = 200
+
+    def do_GET(self) -> None:
+        self.send_response(type(self).status)
+        self.send_header("Content-Length", str(len(type(self).payload)))
+        self.end_headers()
+        self.wfile.write(type(self).payload)
+
+    def log_message(self, *_args: object) -> None:
+        pass
+
+
[email protected]
+def static_server() -> "tuple[str, type[_StaticHandler]]":
+    _StaticHandler.payload = b""
+    _StaticHandler.status = 200
+    server = HTTPServer(("127.0.0.1", 0), _StaticHandler)
+    port = server.server_address[1]
+    thread = threading.Thread(target=server.serve_forever, daemon=True)
+    thread.start()
+    try:
+        yield f"http://127.0.0.1:{port}";, _StaticHandler
+    finally:
+        server.shutdown()
+        server.server_close()
+        _StaticHandler.payload = b""
+        _StaticHandler.status = 200
+
+
+class TestDownloadToTempfile:
+    def test_downloads_bytes(
+        self, static_server: "tuple[str, type[_StaticHandler]]"
+    ) -> None:
+        base_url, handler = static_server
+        handler.payload = b"hello-zip-bytes"
+        handler.status = 200
+
+        path = download_to_tempfile(f"{base_url}/anything", timeout=10)
+
+        try:
+            assert path.is_file()
+            assert path.read_bytes() == b"hello-zip-bytes"
+        finally:
+            path.unlink(missing_ok=True)
+
+    def test_raises_on_http_error(
+        self, static_server: "tuple[str, type[_StaticHandler]]"
+    ) -> None:
+        base_url, handler = static_server
+        handler.payload = b""
+        handler.status = 404
+
+        with pytest.raises(HTTPError):
+            download_to_tempfile(f"{base_url}/missing", timeout=10)
diff --git a/python/flink_agents/runtime/skill/tests/test_package_repository.py 
b/python/flink_agents/runtime/skill/tests/test_package_repository.py
new file mode 100644
index 00000000..5b1490f5
--- /dev/null
+++ b/python/flink_agents/runtime/skill/tests/test_package_repository.py
@@ -0,0 +1,84 @@
+################################################################################
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+# limitations under the License.
+#################################################################################
+"""Unit tests for PackageSkillRepository."""
+
+import importlib
+import shutil
+import sys
+import zipfile
+from pathlib import Path
+
+import pytest
+
+from flink_agents.runtime.skill.repository.package_repository import (
+    PackageSkillRepository,
+)
+
+
+def _zip_dir(src: Path, dst_zip: Path) -> None:
+    with zipfile.ZipFile(dst_zip, "w", zipfile.ZIP_DEFLATED) as zf:
+        for path in src.rglob("*"):
+            if path.is_file():
+                zf.write(path, arcname=path.relative_to(src))
+
+
[email protected]
+def installed_pkg(tmp_path: Path) -> str:
+    """Create a tmp Python package on sys.path with skills as both a directory
+    and a zip resource. Yields the package name.
+    """
+    pkg_root = tmp_path / "pkg_root"
+    pkg_root.mkdir()
+    pkg_name = "_flink_agents_test_skills_pkg"
+    pkg_dir = pkg_root / pkg_name
+    pkg_dir.mkdir()
+    (pkg_dir / "__init__.py").write_text("")
+
+    # Copy the existing skills/ directory into the package as a directory 
resource.
+    src_skills = Path(__file__).parent / "resources" / "skills"
+    shutil.copytree(src_skills, pkg_dir / "skills")
+
+    # Build a zip resource alongside the directory.
+    _zip_dir(src_skills, pkg_dir / "skills.zip")
+
+    sys.path.insert(0, str(pkg_root))
+    importlib.invalidate_caches()
+    try:
+        yield pkg_name
+    finally:
+        sys.path.remove(str(pkg_root))
+        # Remove cached module so it doesn't leak across tests.
+        sys.modules.pop(pkg_name, None)
+
+
+class TestPackageSkillRepository:
+    def test_load_directory_resource(self, installed_pkg: str) -> None:
+        repo = PackageSkillRepository(installed_pkg, "skills")
+
+        names = {s.name for s in repo.get_skills()}
+        assert names == {"github", "nano-banana-pro"}
+
+    def test_load_zip_resource(self, installed_pkg: str) -> None:
+        repo = PackageSkillRepository(installed_pkg, "skills.zip")
+
+        names = {s.name for s in repo.get_skills()}
+        assert names == {"github", "nano-banana-pro"}
+
+    def test_missing_resource(self, installed_pkg: str) -> None:
+        with pytest.raises(ValueError, match="not found in package"):
+            PackageSkillRepository(installed_pkg, "no_such_resource")
diff --git 
a/python/flink_agents/runtime/skill/tests/test_skill_directory_reader.py 
b/python/flink_agents/runtime/skill/tests/test_skill_directory_reader.py
new file mode 100644
index 00000000..6143919e
--- /dev/null
+++ b/python/flink_agents/runtime/skill/tests/test_skill_directory_reader.py
@@ -0,0 +1,106 @@
+################################################################################
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+# limitations under the License.
+################################################################################
+"""Unit tests for SkillDirectoryReader."""
+
+import base64
+from pathlib import Path
+
+import pytest
+
+from flink_agents.runtime.skill.repository.skill_directory_reader import (
+    SkillDirectoryReader,
+)
+
+
+def _write_skill(base_dir: Path, name: str, body: str) -> None:
+    skill_dir = base_dir / name
+    skill_dir.mkdir(parents=True)
+    (skill_dir / "SKILL.md").write_text(
+        f"---\nname: {name}\ndescription: dummy skill\n---\n{body}"
+    )
+
+
+class TestSkillDirectoryReader:
+    def test_rejects_none(self) -> None:
+        with pytest.raises(ValueError):
+            SkillDirectoryReader(None)  # type: ignore[arg-type]
+
+    def test_rejects_nonexistent_path(self, tmp_path: Path) -> None:
+        with pytest.raises(ValueError):
+            SkillDirectoryReader(tmp_path / "missing")
+
+    def test_rejects_non_directory(self, tmp_path: Path) -> None:
+        file_path = tmp_path / "not-a-dir.txt"
+        file_path.write_text("x")
+        with pytest.raises(ValueError):
+            SkillDirectoryReader(file_path)
+
+    def test_get_skill_dir_returns_base_slash_name(self, tmp_path: Path) -> 
None:
+        reader = SkillDirectoryReader(tmp_path)
+        # Returns even for missing names — caller verifies existence.
+        assert reader.get_skill_dir("anything") == (tmp_path.resolve() / 
"anything")
+
+    def test_get_skill_returns_none_for_missing(self, tmp_path: Path) -> None:
+        reader = SkillDirectoryReader(tmp_path)
+        assert reader.get_skill("missing") is None
+
+    def test_get_skills_lists_every_skill_subdir(self, tmp_path: Path) -> None:
+        _write_skill(tmp_path, "alpha", "body alpha")
+        _write_skill(tmp_path, "beta", "body beta")
+        # A non-skill subdirectory must be ignored.
+        (tmp_path / "not-a-skill").mkdir()
+
+        reader = SkillDirectoryReader(tmp_path)
+        names = [s.name for s in reader.get_skills()]
+        assert names == ["alpha", "beta"]
+
+    def test_get_resources_exposes_non_skill_md_files(self, tmp_path: Path) -> 
None:
+        _write_skill(tmp_path, "gamma", "body gamma")
+        skill_dir = tmp_path / "gamma"
+        (skill_dir / "notes.txt").write_text("hello")
+        (skill_dir / "scripts").mkdir()
+        (skill_dir / "scripts" / "run.sh").write_text("echo hi")
+
+        reader = SkillDirectoryReader(tmp_path)
+        resources = reader.get_resources("gamma")
+        assert resources == {
+            "notes.txt": "hello",
+            "scripts/run.sh": "echo hi",
+        }
+
+    def test_get_resources_empty_for_missing_skill(self, tmp_path: Path) -> 
None:
+        reader = SkillDirectoryReader(tmp_path)
+        assert reader.get_resources("missing") == {}
+
+    def test_binary_resource_round_trips_through_base64(self, tmp_path: Path) 
-> None:
+        # PNG signature + a few non-UTF-8 bytes — must come back identical 
after
+        # b64-decoding the value with the "base64: " prefix stripped, otherwise
+        # the encoder is silently emitting something other than base64
+        # (e.g. the f-string-of-bytes-repr bug that survived the first review).
+        _write_skill(tmp_path, "binary-skill", "body")
+        original = bytes(
+            [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0xF8, 0x88, 0x80, 
0x80]
+        )
+        (tmp_path / "binary-skill" / "blob.bin").write_bytes(original)
+
+        reader = SkillDirectoryReader(tmp_path)
+        encoded = reader.get_resources("binary-skill")["blob.bin"]
+
+        assert encoded.startswith("base64: ")
+        decoded = base64.b64decode(encoded.removeprefix("base64: "))
+        assert decoded == original
diff --git a/python/flink_agents/runtime/skill/tests/test_url_repository.py 
b/python/flink_agents/runtime/skill/tests/test_url_repository.py
new file mode 100644
index 00000000..6eaf1eae
--- /dev/null
+++ b/python/flink_agents/runtime/skill/tests/test_url_repository.py
@@ -0,0 +1,99 @@
+################################################################################
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+# limitations under the License.
+#################################################################################
+"""Unit tests for URLSkillRepository."""
+
+import threading
+import zipfile
+from http.server import BaseHTTPRequestHandler, HTTPServer
+from pathlib import Path
+from urllib.error import HTTPError
+
+import pytest
+
+from flink_agents.runtime.skill.repository.url_repository import 
URLSkillRepository
+
+
+def _zip_dir(src: Path, dst_zip: Path) -> None:
+    with zipfile.ZipFile(dst_zip, "w", zipfile.ZIP_DEFLATED) as zf:
+        for path in src.rglob("*"):
+            if path.is_file():
+                zf.write(path, arcname=path.relative_to(src))
+
+
[email protected]
+def skills_zip_path(tmp_path: Path) -> Path:
+    src = Path(__file__).parent / "resources" / "skills"
+    zip_path = tmp_path / "skills.zip"
+    _zip_dir(src, zip_path)
+    return zip_path
+
+
+class _ZipHandler(BaseHTTPRequestHandler):
+    zip_bytes: bytes = b""
+    status: int = 200
+
+    def do_GET(self) -> None:
+        self.send_response(type(self).status)
+        self.send_header("Content-Type", "application/zip")
+        self.send_header("Content-Length", str(len(type(self).zip_bytes)))
+        self.end_headers()
+        self.wfile.write(type(self).zip_bytes)
+
+    def log_message(self, *_args: object) -> None:
+        pass
+
+
[email protected]
+def zip_server(skills_zip_path: Path) -> "tuple[str, type[_ZipHandler]]":
+    _ZipHandler.zip_bytes = skills_zip_path.read_bytes()
+    _ZipHandler.status = 200
+    server = HTTPServer(("127.0.0.1", 0), _ZipHandler)
+    port = server.server_address[1]
+    thread = threading.Thread(target=server.serve_forever, daemon=True)
+    thread.start()
+    try:
+        yield f"http://127.0.0.1:{port}/skills.zip";, _ZipHandler
+    finally:
+        server.shutdown()
+        server.server_close()
+        _ZipHandler.zip_bytes = b""
+        _ZipHandler.status = 200
+
+
+class TestURLSkillRepository:
+    def test_load_from_url(self, zip_server: "tuple[str, type[_ZipHandler]]") 
-> None:
+        url, _handler = zip_server
+        repo = URLSkillRepository(url)
+
+        skills = repo.get_skills()
+        names = {s.name for s in skills}
+        assert names == {"github", "nano-banana-pro"}
+
+    def test_non_http_url_rejected(self) -> None:
+        with pytest.raises(ValueError, match="Only http"):
+            URLSkillRepository("file:///tmp/skills.zip")
+
+        with pytest.raises(ValueError, match="Only http"):
+            URLSkillRepository("ftp://example.com/skills.zip";)
+
+    def test_404_error(self, zip_server: "tuple[str, type[_ZipHandler]]") -> 
None:
+        url, handler = zip_server
+        handler.status = 404
+
+        with pytest.raises(HTTPError):
+            URLSkillRepository(url)

Reply via email to