From 13e848ddf4cfa8f4cbfb672c1cb8ffb4f304646e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com>
Date: Mon, 8 Jun 2026 16:52:41 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E6=8A=80=E8=83=BD?=
=?UTF-8?q?=E7=AE=A1=E7=90=86=E6=A8=A1=E5=9D=97=E8=AF=95=E9=AA=8C=E6=80=A7?=
=?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=8C=E7=AD=89=E5=BE=85=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
easy-agents-bom/pom.xml | 4 +
easy-agents-skill/pom.xml | 27 ++
.../skill/codec/SkillPackageCodec.java | 20 ++
.../skill/codec/ZipSkillPackageCodec.java | 254 ++++++++++++++++
.../skill/exception/SkillException.java | 26 ++
.../exception/SkillPackageException.java | 26 ++
.../exception/SkillValidationException.java | 26 ++
.../skill/factory/SkillFactory.java | 52 ++++
.../com/easyagents/skill/model/Skill.java | 175 +++++++++++
.../easyagents/skill/model/SkillAsset.java | 145 +++++++++
.../skill/model/SkillDescriptor.java | 111 +++++++
.../easyagents/skill/model/SkillMetadata.java | 79 +++++
.../skill/model/SkillReference.java | 126 ++++++++
.../easyagents/skill/model/SkillScript.java | 126 ++++++++
.../skill/model/SkillScriptLanguage.java | 50 ++++
.../skill/repository/SkillRepository.java | 58 ++++
.../memory/InMemorySkillRepository.java | 176 +++++++++++
.../skill/store/SkillContentStore.java | 41 +++
.../memory/InMemorySkillContentStore.java | 70 +++++
.../skill/util/SkillFrontmatter.java | 89 ++++++
.../easyagents/skill/util/SkillHashes.java | 47 +++
.../com/easyagents/skill/util/SkillPaths.java | 118 ++++++++
.../skill/validation/SkillValidator.java | 16 +
.../defaults/DefaultSkillValidator.java | 156 ++++++++++
.../skill/codec/ZipSkillPackageCodecTest.java | 275 ++++++++++++++++++
.../memory/InMemorySkillRepositoryTest.java | 83 ++++++
.../memory/InMemorySkillContentStoreTest.java | 46 +++
.../defaults/DefaultSkillValidatorTest.java | 193 ++++++++++++
pom.xml | 7 +
29 files changed, 2622 insertions(+)
create mode 100644 easy-agents-skill/pom.xml
create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/codec/SkillPackageCodec.java
create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/codec/ZipSkillPackageCodec.java
create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/exception/SkillException.java
create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/exception/SkillPackageException.java
create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/exception/SkillValidationException.java
create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/factory/SkillFactory.java
create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/model/Skill.java
create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillAsset.java
create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillDescriptor.java
create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillMetadata.java
create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillReference.java
create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillScript.java
create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillScriptLanguage.java
create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/repository/SkillRepository.java
create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/repository/memory/InMemorySkillRepository.java
create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/store/SkillContentStore.java
create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/store/memory/InMemorySkillContentStore.java
create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/util/SkillFrontmatter.java
create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/util/SkillHashes.java
create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/util/SkillPaths.java
create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/validation/SkillValidator.java
create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/validation/defaults/DefaultSkillValidator.java
create mode 100644 easy-agents-skill/src/test/java/com/easyagents/skill/codec/ZipSkillPackageCodecTest.java
create mode 100644 easy-agents-skill/src/test/java/com/easyagents/skill/repository/memory/InMemorySkillRepositoryTest.java
create mode 100644 easy-agents-skill/src/test/java/com/easyagents/skill/store/memory/InMemorySkillContentStoreTest.java
create mode 100644 easy-agents-skill/src/test/java/com/easyagents/skill/validation/defaults/DefaultSkillValidatorTest.java
diff --git a/easy-agents-bom/pom.xml b/easy-agents-bom/pom.xml
index 4911bcc..32234e4 100644
--- a/easy-agents-bom/pom.xml
+++ b/easy-agents-bom/pom.xml
@@ -256,6 +256,10 @@
+
+ com.easyagents
+ easy-agents-skill
+
com.easyagents
easy-agents-agent-runtime
diff --git a/easy-agents-skill/pom.xml b/easy-agents-skill/pom.xml
new file mode 100644
index 0000000..8dbe3b6
--- /dev/null
+++ b/easy-agents-skill/pom.xml
@@ -0,0 +1,27 @@
+
+
+ 4.0.0
+
+ com.easyagents
+ easy-agents
+ ${revision}
+
+
+ easy-agents-skill
+ easy-agents-skill
+
+
+ 17
+ UTF-8
+
+
+
+
+ junit
+ junit
+ test
+
+
+
diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/codec/SkillPackageCodec.java b/easy-agents-skill/src/main/java/com/easyagents/skill/codec/SkillPackageCodec.java
new file mode 100644
index 0000000..7936b0e
--- /dev/null
+++ b/easy-agents-skill/src/main/java/com/easyagents/skill/codec/SkillPackageCodec.java
@@ -0,0 +1,20 @@
+package com.easyagents.skill.codec;
+
+import com.easyagents.skill.model.Skill;
+
+import java.io.InputStream;
+import java.util.List;
+
+/**
+ * Skill 包导入接口。
+ */
+public interface SkillPackageCodec {
+
+ /**
+ * 从 zip 输入流导入 Skill。
+ *
+ * @param inputStream zip 输入流
+ * @return Skill 列表
+ */
+ List importZip(InputStream inputStream);
+}
diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/codec/ZipSkillPackageCodec.java b/easy-agents-skill/src/main/java/com/easyagents/skill/codec/ZipSkillPackageCodec.java
new file mode 100644
index 0000000..16590af
--- /dev/null
+++ b/easy-agents-skill/src/main/java/com/easyagents/skill/codec/ZipSkillPackageCodec.java
@@ -0,0 +1,254 @@
+package com.easyagents.skill.codec;
+
+import com.easyagents.skill.exception.SkillPackageException;
+import com.easyagents.skill.exception.SkillValidationException;
+import com.easyagents.skill.factory.SkillFactory;
+import com.easyagents.skill.model.*;
+import com.easyagents.skill.store.SkillContentStore;
+import com.easyagents.skill.store.memory.InMemorySkillContentStore;
+import com.easyagents.skill.util.SkillHashes;
+import com.easyagents.skill.util.SkillPaths;
+import com.easyagents.skill.validation.SkillValidator;
+import com.easyagents.skill.validation.defaults.DefaultSkillValidator;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URLConnection;
+import java.nio.charset.StandardCharsets;
+import java.util.*;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+
+/**
+ * 基于 zip 的 Skill 包导入实现。
+ */
+public class ZipSkillPackageCodec implements SkillPackageCodec {
+
+ private static final String DEFAULT_MEDIA_TYPE = "application/octet-stream";
+
+ private final SkillContentStore contentStore;
+ private final SkillValidator validator;
+
+ /**
+ * 创建使用内存内容存储的 zip Skill 包导入器。
+ */
+ public ZipSkillPackageCodec() {
+ this(new InMemorySkillContentStore());
+ }
+
+ /**
+ * 创建 zip Skill 包导入器。
+ *
+ * @param contentStore 二进制内容存储
+ */
+ public ZipSkillPackageCodec(SkillContentStore contentStore) {
+ this(contentStore, new DefaultSkillValidator());
+ }
+
+ /**
+ * 创建 zip Skill 包导入器。
+ *
+ * @param contentStore 二进制内容存储
+ * @param validator Skill 聚合校验器
+ */
+ public ZipSkillPackageCodec(SkillContentStore contentStore, SkillValidator validator) {
+ if (contentStore == null) {
+ throw new SkillPackageException("Skill content store is required.");
+ }
+ if (validator == null) {
+ throw new SkillPackageException("Skill validator is required.");
+ }
+ this.contentStore = contentStore;
+ this.validator = validator;
+ }
+
+ /**
+ * 从 zip 流导入 Skill 列表。
+ *
+ * @param inputStream zip 输入流
+ * @return Skill 列表
+ */
+ @Override
+ public List importZip(InputStream inputStream) {
+ if (inputStream == null) {
+ throw new SkillPackageException("Zip input stream is required.");
+ }
+ Map builders = new LinkedHashMap<>();
+ try (ZipInputStream zipInputStream = new ZipInputStream(inputStream)) {
+ ZipEntry entry;
+ while ((entry = zipInputStream.getNextEntry()) != null) {
+ handleEntry(entry, zipInputStream, builders);
+ zipInputStream.closeEntry();
+ }
+ } catch (IOException e) {
+ throw new SkillPackageException("Failed to import skill zip package.", e);
+ }
+ if (builders.isEmpty()) {
+ throw new SkillPackageException("Zip package must contain at least one skill folder.");
+ }
+ List skills = new ArrayList<>();
+ for (SkillBuilder builder : builders.values()) {
+ Skill skill = builder.build();
+ validator.validate(skill);
+ skills.add(skill);
+ }
+ for (SkillBuilder builder : builders.values()) {
+ builder.writeAssets(contentStore);
+ }
+ return skills;
+ }
+
+ private void handleEntry(ZipEntry entry, ZipInputStream zipInputStream, Map builders)
+ throws IOException {
+ String rawPath = entry.getName();
+ if (entry.isDirectory() || SkillPaths.isIgnoredSystemPath(rawPath)) {
+ return;
+ }
+ String normalizedPath = SkillPaths.normalize(rawPath);
+ if (SkillPaths.SKILL_FILE.equals(normalizedPath)) {
+ throw new SkillPackageException("Zip root cannot directly contain SKILL.md.");
+ }
+ int slashIndex = normalizedPath.indexOf('/');
+ if (slashIndex < 0) {
+ throw new SkillPackageException("Zip root can only contain skill folders: " + normalizedPath);
+ }
+
+ String skillId = normalizedPath.substring(0, slashIndex);
+ String skillPath = normalizedPath.substring(slashIndex + 1);
+ if (skillPath.isBlank()) {
+ return;
+ }
+ SkillBuilder builder = builders.computeIfAbsent(skillId, SkillBuilder::new);
+ builder.addFile(skillPath, readEntryBytes(zipInputStream));
+ }
+
+ private static byte[] readEntryBytes(InputStream inputStream) throws IOException {
+ ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+ byte[] buffer = new byte[8192];
+ int readLength;
+ while ((readLength = inputStream.read(buffer)) >= 0) {
+ outputStream.write(buffer, 0, readLength);
+ }
+ return outputStream.toByteArray();
+ }
+
+ private static String detectMediaType(String path) {
+ String mediaType = URLConnection.guessContentTypeFromName(path);
+ return mediaType == null ? DEFAULT_MEDIA_TYPE : mediaType;
+ }
+
+ private static final class SkillBuilder {
+
+ private final String id;
+ private final Set paths = new HashSet<>();
+ private String skillContent;
+ private final List references = new ArrayList<>();
+ private final List scripts = new ArrayList<>();
+ private final List assets = new ArrayList<>();
+ private final List pendingAssets = new ArrayList<>();
+
+ private SkillBuilder(String id) {
+ this.id = id;
+ }
+
+ private void addFile(String path, byte[] bytes) {
+ String normalizedPath = SkillPaths.normalize(path);
+ if (!paths.add(normalizedPath)) {
+ throw new SkillPackageException("Duplicate skill file path: " + id + "/" + normalizedPath);
+ }
+ if (SkillPaths.SKILL_FILE.equals(normalizedPath)) {
+ addSkillFile(bytes);
+ return;
+ }
+
+ String topDir = SkillPaths.firstSegment(normalizedPath);
+ if (SkillPaths.REFERENCES_DIR.equals(topDir)) {
+ addReference(normalizedPath, bytes);
+ } else if (SkillPaths.SCRIPTS_DIR.equals(topDir)) {
+ addScript(normalizedPath, bytes);
+ } else if (SkillPaths.ASSETS_DIR.equals(topDir)) {
+ addAsset(normalizedPath, bytes);
+ } else {
+ throw new SkillPackageException("Unknown skill top-level directory: " + id + "/" + normalizedPath);
+ }
+ }
+
+ private void addSkillFile(byte[] bytes) {
+ skillContent = new String(bytes, StandardCharsets.UTF_8);
+ }
+
+ private void addReference(String path, byte[] bytes) {
+ if (!SkillPaths.hasExtension(path, ".md")) {
+ throw new SkillPackageException("Skill reference must be a markdown file: " + id + "/" + path);
+ }
+ String content = new String(bytes, StandardCharsets.UTF_8);
+ SkillReference reference = new SkillReference();
+ reference.setPath(path);
+ reference.setName(SkillPaths.fileName(path));
+ reference.setContent(content);
+ reference.setContentHash(SkillHashes.sha256Hex(bytes));
+ reference.setSize(bytes.length);
+ references.add(reference);
+ }
+
+ private void addScript(String path, byte[] bytes) {
+ SkillScriptLanguage language = SkillScriptLanguage.fromPath(path);
+ if (language == SkillScriptLanguage.UNKNOWN) {
+ throw new SkillPackageException("Unsupported skill script extension: " + id + "/" + path);
+ }
+ SkillScript script = new SkillScript();
+ script.setPath(path);
+ script.setLanguage(language);
+ script.setContent(new String(bytes, StandardCharsets.UTF_8));
+ script.setContentHash(SkillHashes.sha256Hex(bytes));
+ script.setSize(bytes.length);
+ scripts.add(script);
+ }
+
+ private void addAsset(String path, byte[] bytes) {
+ String contentHash = SkillHashes.sha256Hex(bytes);
+ SkillAsset asset = new SkillAsset();
+ asset.setPath(path);
+ asset.setName(SkillPaths.fileName(path));
+ asset.setMediaType(detectMediaType(path));
+ asset.setContentRef("sha256:" + contentHash);
+ asset.setContentHash(contentHash);
+ asset.setSize(bytes.length);
+ assets.add(asset);
+ pendingAssets.add(new PendingAsset(asset, bytes));
+ }
+
+ private Skill build() {
+ if (skillContent == null) {
+ throw new SkillPackageException("Skill folder must contain SKILL.md: " + id);
+ }
+ try {
+ return SkillFactory.create(id, skillContent, references, scripts, assets);
+ } catch (SkillValidationException e) {
+ throw new SkillPackageException("Invalid SKILL.md frontmatter: " + id, e);
+ }
+ }
+
+ private void writeAssets(SkillContentStore contentStore) {
+ for (PendingAsset pendingAsset : pendingAssets) {
+ String contentRef = contentStore.put(pendingAsset.bytes);
+ if (!pendingAsset.asset.getContentRef().equals(contentRef)) {
+ throw new SkillPackageException("Skill asset content ref does not match store result: "
+ + id + "/" + pendingAsset.asset.getPath());
+ }
+ }
+ }
+ }
+
+ private static final class PendingAsset {
+
+ private final SkillAsset asset;
+ private final byte[] bytes;
+
+ private PendingAsset(SkillAsset asset, byte[] bytes) {
+ this.asset = asset;
+ this.bytes = bytes;
+ }
+ }
+}
diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/exception/SkillException.java b/easy-agents-skill/src/main/java/com/easyagents/skill/exception/SkillException.java
new file mode 100644
index 0000000..b263760
--- /dev/null
+++ b/easy-agents-skill/src/main/java/com/easyagents/skill/exception/SkillException.java
@@ -0,0 +1,26 @@
+package com.easyagents.skill.exception;
+
+/**
+ * Skill 模块基础运行时异常。
+ */
+public class SkillException extends RuntimeException {
+
+ /**
+ * 创建 Skill 异常。
+ *
+ * @param message 异常信息
+ */
+ public SkillException(String message) {
+ super(message);
+ }
+
+ /**
+ * 创建 Skill 异常。
+ *
+ * @param message 异常信息
+ * @param cause 原始异常
+ */
+ public SkillException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/exception/SkillPackageException.java b/easy-agents-skill/src/main/java/com/easyagents/skill/exception/SkillPackageException.java
new file mode 100644
index 0000000..ef388ae
--- /dev/null
+++ b/easy-agents-skill/src/main/java/com/easyagents/skill/exception/SkillPackageException.java
@@ -0,0 +1,26 @@
+package com.easyagents.skill.exception;
+
+/**
+ * Skill 包导入导出异常。
+ */
+public class SkillPackageException extends SkillException {
+
+ /**
+ * 创建 Skill 包异常。
+ *
+ * @param message 异常信息
+ */
+ public SkillPackageException(String message) {
+ super(message);
+ }
+
+ /**
+ * 创建 Skill 包异常。
+ *
+ * @param message 异常信息
+ * @param cause 原始异常
+ */
+ public SkillPackageException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/exception/SkillValidationException.java b/easy-agents-skill/src/main/java/com/easyagents/skill/exception/SkillValidationException.java
new file mode 100644
index 0000000..dbf781c
--- /dev/null
+++ b/easy-agents-skill/src/main/java/com/easyagents/skill/exception/SkillValidationException.java
@@ -0,0 +1,26 @@
+package com.easyagents.skill.exception;
+
+/**
+ * Skill 校验失败异常。
+ */
+public class SkillValidationException extends SkillException {
+
+ /**
+ * 创建 Skill 校验异常。
+ *
+ * @param message 异常信息
+ */
+ public SkillValidationException(String message) {
+ super(message);
+ }
+
+ /**
+ * 创建 Skill 校验异常。
+ *
+ * @param message 异常信息
+ * @param cause 原始异常
+ */
+ public SkillValidationException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/factory/SkillFactory.java b/easy-agents-skill/src/main/java/com/easyagents/skill/factory/SkillFactory.java
new file mode 100644
index 0000000..2a899cd
--- /dev/null
+++ b/easy-agents-skill/src/main/java/com/easyagents/skill/factory/SkillFactory.java
@@ -0,0 +1,52 @@
+package com.easyagents.skill.factory;
+
+import com.easyagents.skill.model.*;
+import com.easyagents.skill.util.SkillFrontmatter;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Skill 标准构建入口。
+ */
+public final class SkillFactory {
+
+ private SkillFactory() {
+ }
+
+ /**
+ * 基于 SKILL.md 内容创建 Skill。
+ *
+ * @param id Skill ID
+ * @param skillContent SKILL.md 原始内容
+ * @return Skill 聚合
+ */
+ public static Skill create(String id, String skillContent) {
+ return create(id, skillContent, null, null, null);
+ }
+
+ /**
+ * 基于 SKILL.md 内容和资源列表创建 Skill。
+ *
+ * @param id Skill ID
+ * @param skillContent SKILL.md 原始内容
+ * @param references reference 文档列表
+ * @param scripts script 脚本列表
+ * @param assets asset 资产列表
+ * @return Skill 聚合
+ */
+ public static Skill create(String id, String skillContent, List references,
+ List scripts, List assets) {
+ Map values = SkillFrontmatter.parse(skillContent);
+ Skill skill = new Skill();
+ skill.setId(id);
+ skill.setName(values.get("name").toString());
+ skill.setDescription(values.get("description").toString());
+ skill.setMetadata(new SkillMetadata(values));
+ skill.setSkillContent(skillContent);
+ skill.setReferences(references);
+ skill.setScripts(scripts);
+ skill.setAssets(assets);
+ return skill;
+ }
+}
diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/model/Skill.java b/easy-agents-skill/src/main/java/com/easyagents/skill/model/Skill.java
new file mode 100644
index 0000000..e845538
--- /dev/null
+++ b/easy-agents-skill/src/main/java/com/easyagents/skill/model/Skill.java
@@ -0,0 +1,175 @@
+package com.easyagents.skill.model;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Skill 聚合根。
+ */
+public class Skill implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ private String id;
+ private String name;
+ private String description;
+ private SkillMetadata metadata = new SkillMetadata();
+ private String skillContent;
+ private List references = new ArrayList<>();
+ private List scripts = new ArrayList<>();
+ private List assets = new ArrayList<>();
+
+ /**
+ * 获取 Skill ID。
+ *
+ * @return Skill ID
+ */
+ public String getId() {
+ return id;
+ }
+
+ /**
+ * 设置 Skill ID。
+ *
+ * @param id Skill ID
+ */
+ public void setId(String id) {
+ this.id = id;
+ }
+
+ /**
+ * 获取名称。
+ *
+ * @return 名称
+ */
+ public String getName() {
+ return name;
+ }
+
+ /**
+ * 设置名称。
+ *
+ * @param name 名称
+ */
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ /**
+ * 获取描述。
+ *
+ * @return 描述
+ */
+ public String getDescription() {
+ return description;
+ }
+
+ /**
+ * 设置描述。
+ *
+ * @param description 描述
+ */
+ public void setDescription(String description) {
+ this.description = description;
+ }
+
+ /**
+ * 获取元数据。
+ *
+ * @return 元数据
+ */
+ public SkillMetadata getMetadata() {
+ return metadata;
+ }
+
+ /**
+ * 设置元数据。
+ *
+ * @param metadata 元数据
+ */
+ public void setMetadata(SkillMetadata metadata) {
+ this.metadata = metadata == null ? new SkillMetadata() : metadata;
+ }
+
+ /**
+ * 获取 SKILL.md 原始内容。
+ *
+ * @return SKILL.md 原始内容
+ */
+ public String getSkillContent() {
+ return skillContent;
+ }
+
+ /**
+ * 设置 SKILL.md 原始内容。
+ *
+ * @param skillContent SKILL.md 原始内容
+ */
+ public void setSkillContent(String skillContent) {
+ this.skillContent = skillContent;
+ }
+
+ /**
+ * 获取参考文档列表。
+ *
+ * @return 参考文档列表
+ */
+ public List getReferences() {
+ return references;
+ }
+
+ /**
+ * 设置参考文档列表。
+ *
+ * @param references 参考文档列表
+ */
+ public void setReferences(List references) {
+ this.references = references == null ? new ArrayList<>() : new ArrayList<>(references);
+ }
+
+ /**
+ * 获取脚本列表。
+ *
+ * @return 脚本列表
+ */
+ public List getScripts() {
+ return scripts;
+ }
+
+ /**
+ * 设置脚本列表。
+ *
+ * @param scripts 脚本列表
+ */
+ public void setScripts(List scripts) {
+ this.scripts = scripts == null ? new ArrayList<>() : new ArrayList<>(scripts);
+ }
+
+ /**
+ * 获取资产列表。
+ *
+ * @return 资产列表
+ */
+ public List getAssets() {
+ return assets;
+ }
+
+ /**
+ * 设置资产列表。
+ *
+ * @param assets 资产列表
+ */
+ public void setAssets(List assets) {
+ this.assets = assets == null ? new ArrayList<>() : new ArrayList<>(assets);
+ }
+
+ /**
+ * 转换为轻量描述。
+ *
+ * @return Skill 描述
+ */
+ public SkillDescriptor toDescriptor() {
+ return new SkillDescriptor(id, name, description, metadata);
+ }
+}
diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillAsset.java b/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillAsset.java
new file mode 100644
index 0000000..e5bfda1
--- /dev/null
+++ b/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillAsset.java
@@ -0,0 +1,145 @@
+package com.easyagents.skill.model;
+
+import java.io.Serializable;
+
+/**
+ * Skill 静态资产。
+ */
+public class SkillAsset implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ private String path;
+ private String name;
+ private String mediaType;
+ private String contentRef;
+ private String contentHash;
+ private long size;
+ private SkillMetadata metadata = new SkillMetadata();
+
+ /**
+ * 获取逻辑路径。
+ *
+ * @return 逻辑路径
+ */
+ public String getPath() {
+ return path;
+ }
+
+ /**
+ * 设置逻辑路径。
+ *
+ * @param path 逻辑路径
+ */
+ public void setPath(String path) {
+ this.path = path;
+ }
+
+ /**
+ * 获取名称。
+ *
+ * @return 名称
+ */
+ public String getName() {
+ return name;
+ }
+
+ /**
+ * 设置名称。
+ *
+ * @param name 名称
+ */
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ /**
+ * 获取媒体类型。
+ *
+ * @return 媒体类型
+ */
+ public String getMediaType() {
+ return mediaType;
+ }
+
+ /**
+ * 设置媒体类型。
+ *
+ * @param mediaType 媒体类型
+ */
+ public void setMediaType(String mediaType) {
+ this.mediaType = mediaType;
+ }
+
+ /**
+ * 获取内容引用。
+ *
+ * @return 内容引用
+ */
+ public String getContentRef() {
+ return contentRef;
+ }
+
+ /**
+ * 设置内容引用。
+ *
+ * @param contentRef 内容引用
+ */
+ public void setContentRef(String contentRef) {
+ this.contentRef = contentRef;
+ }
+
+ /**
+ * 获取内容 hash。
+ *
+ * @return 内容 hash
+ */
+ public String getContentHash() {
+ return contentHash;
+ }
+
+ /**
+ * 设置内容 hash。
+ *
+ * @param contentHash 内容 hash
+ */
+ public void setContentHash(String contentHash) {
+ this.contentHash = contentHash;
+ }
+
+ /**
+ * 获取文件大小。
+ *
+ * @return 文件大小
+ */
+ public long getSize() {
+ return size;
+ }
+
+ /**
+ * 设置文件大小。
+ *
+ * @param size 文件大小
+ */
+ public void setSize(long size) {
+ this.size = size;
+ }
+
+ /**
+ * 获取元数据。
+ *
+ * @return 元数据
+ */
+ public SkillMetadata getMetadata() {
+ return metadata;
+ }
+
+ /**
+ * 设置元数据。
+ *
+ * @param metadata 元数据
+ */
+ public void setMetadata(SkillMetadata metadata) {
+ this.metadata = metadata == null ? new SkillMetadata() : metadata;
+ }
+}
diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillDescriptor.java b/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillDescriptor.java
new file mode 100644
index 0000000..a25df10
--- /dev/null
+++ b/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillDescriptor.java
@@ -0,0 +1,111 @@
+package com.easyagents.skill.model;
+
+import java.io.Serial;
+import java.io.Serializable;
+
+/**
+ * Skill 轻量描述。
+ */
+public class SkillDescriptor implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ private String id;
+ private String name;
+ private String description;
+ private SkillMetadata metadata = new SkillMetadata();
+
+ /**
+ * 创建空 Skill 描述。
+ */
+ public SkillDescriptor() {
+ }
+
+ /**
+ * 创建 Skill 描述。
+ *
+ * @param id Skill ID
+ * @param name Skill 名称
+ * @param description Skill 描述
+ * @param metadata 元数据
+ */
+ public SkillDescriptor(String id, String name, String description, SkillMetadata metadata) {
+ this.id = id;
+ this.name = name;
+ this.description = description;
+ setMetadata(metadata);
+ }
+
+ /**
+ * 获取 Skill ID。
+ *
+ * @return Skill ID
+ */
+ public String getId() {
+ return id;
+ }
+
+ /**
+ * 设置 Skill ID。
+ *
+ * @param id Skill ID
+ */
+ public void setId(String id) {
+ this.id = id;
+ }
+
+ /**
+ * 获取名称。
+ *
+ * @return 名称
+ */
+ public String getName() {
+ return name;
+ }
+
+ /**
+ * 设置名称。
+ *
+ * @param name 名称
+ */
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ /**
+ * 获取描述。
+ *
+ * @return 描述
+ */
+ public String getDescription() {
+ return description;
+ }
+
+ /**
+ * 设置描述。
+ *
+ * @param description 描述
+ */
+ public void setDescription(String description) {
+ this.description = description;
+ }
+
+ /**
+ * 获取元数据。
+ *
+ * @return 元数据
+ */
+ public SkillMetadata getMetadata() {
+ return metadata;
+ }
+
+ /**
+ * 设置元数据。
+ *
+ * @param metadata 元数据
+ */
+ public void setMetadata(SkillMetadata metadata) {
+ this.metadata = metadata == null ? new SkillMetadata() : metadata;
+ }
+}
diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillMetadata.java b/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillMetadata.java
new file mode 100644
index 0000000..f05e768
--- /dev/null
+++ b/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillMetadata.java
@@ -0,0 +1,79 @@
+package com.easyagents.skill.model;
+
+import java.io.Serializable;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Skill 元数据容器。
+ */
+public class SkillMetadata implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ private Map values = new LinkedHashMap<>();
+
+ /**
+ * 创建空元数据。
+ */
+ public SkillMetadata() {
+ }
+
+ /**
+ * 使用已有键值创建元数据。
+ *
+ * @param values 元数据键值
+ */
+ public SkillMetadata(Map values) {
+ setValues(values);
+ }
+
+ /**
+ * 获取元数据键值。
+ *
+ * @return 元数据键值
+ */
+ public Map getValues() {
+ return values;
+ }
+
+ /**
+ * 设置元数据键值。
+ *
+ * @param values 元数据键值
+ */
+ public void setValues(Map values) {
+ this.values = values == null ? new LinkedHashMap<>() : new LinkedHashMap<>(values);
+ }
+
+ /**
+ * 写入一个元数据键值。
+ *
+ * @param key 元数据键
+ * @param value 元数据值
+ */
+ public void put(String key, Object value) {
+ if (key != null && !key.isBlank()) {
+ values.put(key, value);
+ }
+ }
+
+ /**
+ * 读取元数据值。
+ *
+ * @param key 元数据键
+ * @return 元数据值
+ */
+ public Object get(String key) {
+ return values.get(key);
+ }
+
+ /**
+ * 判断是否为空。
+ *
+ * @return 无元数据时为 true
+ */
+ public boolean isEmpty() {
+ return values.isEmpty();
+ }
+}
diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillReference.java b/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillReference.java
new file mode 100644
index 0000000..c27af7e
--- /dev/null
+++ b/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillReference.java
@@ -0,0 +1,126 @@
+package com.easyagents.skill.model;
+
+import java.io.Serializable;
+
+/**
+ * Skill Markdown 参考文档。
+ */
+public class SkillReference implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ private String path;
+ private String name;
+ private String content;
+ private String contentHash;
+ private long size;
+ private SkillMetadata metadata = new SkillMetadata();
+
+ /**
+ * 获取逻辑路径。
+ *
+ * @return 逻辑路径
+ */
+ public String getPath() {
+ return path;
+ }
+
+ /**
+ * 设置逻辑路径。
+ *
+ * @param path 逻辑路径
+ */
+ public void setPath(String path) {
+ this.path = path;
+ }
+
+ /**
+ * 获取名称。
+ *
+ * @return 名称
+ */
+ public String getName() {
+ return name;
+ }
+
+ /**
+ * 设置名称。
+ *
+ * @param name 名称
+ */
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ /**
+ * 获取 Markdown 内容。
+ *
+ * @return Markdown 内容
+ */
+ public String getContent() {
+ return content;
+ }
+
+ /**
+ * 设置 Markdown 内容。
+ *
+ * @param content Markdown 内容
+ */
+ public void setContent(String content) {
+ this.content = content;
+ }
+
+ /**
+ * 获取内容 hash。
+ *
+ * @return 内容 hash
+ */
+ public String getContentHash() {
+ return contentHash;
+ }
+
+ /**
+ * 设置内容 hash。
+ *
+ * @param contentHash 内容 hash
+ */
+ public void setContentHash(String contentHash) {
+ this.contentHash = contentHash;
+ }
+
+ /**
+ * 获取文件大小。
+ *
+ * @return 文件大小
+ */
+ public long getSize() {
+ return size;
+ }
+
+ /**
+ * 设置文件大小。
+ *
+ * @param size 文件大小
+ */
+ public void setSize(long size) {
+ this.size = size;
+ }
+
+ /**
+ * 获取元数据。
+ *
+ * @return 元数据
+ */
+ public SkillMetadata getMetadata() {
+ return metadata;
+ }
+
+ /**
+ * 设置元数据。
+ *
+ * @param metadata 元数据
+ */
+ public void setMetadata(SkillMetadata metadata) {
+ this.metadata = metadata == null ? new SkillMetadata() : metadata;
+ }
+}
diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillScript.java b/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillScript.java
new file mode 100644
index 0000000..edab344
--- /dev/null
+++ b/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillScript.java
@@ -0,0 +1,126 @@
+package com.easyagents.skill.model;
+
+import java.io.Serializable;
+
+/**
+ * Skill 脚本源码。
+ */
+public class SkillScript implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ private String path;
+ private SkillScriptLanguage language = SkillScriptLanguage.UNKNOWN;
+ private String content;
+ private String contentHash;
+ private long size;
+ private SkillMetadata metadata = new SkillMetadata();
+
+ /**
+ * 获取逻辑路径。
+ *
+ * @return 逻辑路径
+ */
+ public String getPath() {
+ return path;
+ }
+
+ /**
+ * 设置逻辑路径。
+ *
+ * @param path 逻辑路径
+ */
+ public void setPath(String path) {
+ this.path = path;
+ }
+
+ /**
+ * 获取脚本语言。
+ *
+ * @return 脚本语言
+ */
+ public SkillScriptLanguage getLanguage() {
+ return language;
+ }
+
+ /**
+ * 设置脚本语言。
+ *
+ * @param language 脚本语言
+ */
+ public void setLanguage(SkillScriptLanguage language) {
+ this.language = language == null ? SkillScriptLanguage.UNKNOWN : language;
+ }
+
+ /**
+ * 获取脚本源码。
+ *
+ * @return 脚本源码
+ */
+ public String getContent() {
+ return content;
+ }
+
+ /**
+ * 设置脚本源码。
+ *
+ * @param content 脚本源码
+ */
+ public void setContent(String content) {
+ this.content = content;
+ }
+
+ /**
+ * 获取内容 hash。
+ *
+ * @return 内容 hash
+ */
+ public String getContentHash() {
+ return contentHash;
+ }
+
+ /**
+ * 设置内容 hash。
+ *
+ * @param contentHash 内容 hash
+ */
+ public void setContentHash(String contentHash) {
+ this.contentHash = contentHash;
+ }
+
+ /**
+ * 获取文件大小。
+ *
+ * @return 文件大小
+ */
+ public long getSize() {
+ return size;
+ }
+
+ /**
+ * 设置文件大小。
+ *
+ * @param size 文件大小
+ */
+ public void setSize(long size) {
+ this.size = size;
+ }
+
+ /**
+ * 获取元数据。
+ *
+ * @return 元数据
+ */
+ public SkillMetadata getMetadata() {
+ return metadata;
+ }
+
+ /**
+ * 设置元数据。
+ *
+ * @param metadata 元数据
+ */
+ public void setMetadata(SkillMetadata metadata) {
+ this.metadata = metadata == null ? new SkillMetadata() : metadata;
+ }
+}
diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillScriptLanguage.java b/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillScriptLanguage.java
new file mode 100644
index 0000000..c9ce329
--- /dev/null
+++ b/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillScriptLanguage.java
@@ -0,0 +1,50 @@
+package com.easyagents.skill.model;
+
+/**
+ * Skill 脚本语言。
+ */
+public enum SkillScriptLanguage {
+
+ /**
+ * Python 脚本。
+ */
+ PYTHON,
+
+ /**
+ * JavaScript 脚本。
+ */
+ JAVASCRIPT,
+
+ /**
+ * Shell 脚本。
+ */
+ SHELL,
+
+ /**
+ * 未知脚本语言。
+ */
+ UNKNOWN;
+
+ /**
+ * 按脚本路径识别语言。
+ *
+ * @param path 脚本逻辑路径
+ * @return 脚本语言
+ */
+ public static SkillScriptLanguage fromPath(String path) {
+ if (path == null) {
+ return UNKNOWN;
+ }
+ String lower = path.toLowerCase();
+ if (lower.endsWith(".py")) {
+ return PYTHON;
+ }
+ if (lower.endsWith(".js")) {
+ return JAVASCRIPT;
+ }
+ if (lower.endsWith(".sh")) {
+ return SHELL;
+ }
+ return UNKNOWN;
+ }
+}
diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/repository/SkillRepository.java b/easy-agents-skill/src/main/java/com/easyagents/skill/repository/SkillRepository.java
new file mode 100644
index 0000000..03552ee
--- /dev/null
+++ b/easy-agents-skill/src/main/java/com/easyagents/skill/repository/SkillRepository.java
@@ -0,0 +1,58 @@
+package com.easyagents.skill.repository;
+
+import com.easyagents.skill.model.Skill;
+import com.easyagents.skill.model.SkillDescriptor;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Skill 聚合存储接口。
+ */
+public interface SkillRepository {
+
+ /**
+ * 保存 Skill。
+ *
+ * @param skill Skill 聚合
+ */
+ void save(Skill skill);
+
+ /**
+ * 获取完整 Skill。
+ *
+ * @param skillId Skill ID
+ * @return Skill 聚合
+ */
+ Optional get(String skillId);
+
+ /**
+ * 获取 Skill 描述。
+ *
+ * @param skillId Skill ID
+ * @return Skill 描述
+ */
+ Optional getDescriptor(String skillId);
+
+ /**
+ * 列出 Skill 描述。
+ *
+ * @return Skill 描述列表
+ */
+ List listDescriptors();
+
+ /**
+ * 删除 Skill。
+ *
+ * @param skillId Skill ID
+ */
+ void delete(String skillId);
+
+ /**
+ * 判断 Skill 是否存在。
+ *
+ * @param skillId Skill ID
+ * @return 存在时为 true
+ */
+ boolean exists(String skillId);
+}
diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/repository/memory/InMemorySkillRepository.java b/easy-agents-skill/src/main/java/com/easyagents/skill/repository/memory/InMemorySkillRepository.java
new file mode 100644
index 0000000..60795d1
--- /dev/null
+++ b/easy-agents-skill/src/main/java/com/easyagents/skill/repository/memory/InMemorySkillRepository.java
@@ -0,0 +1,176 @@
+package com.easyagents.skill.repository.memory;
+
+import com.easyagents.skill.exception.SkillValidationException;
+import com.easyagents.skill.model.*;
+import com.easyagents.skill.repository.SkillRepository;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+
+/**
+ * 基于内存的 Skill 聚合仓储实现。
+ */
+public class InMemorySkillRepository implements SkillRepository {
+
+ private final ConcurrentMap skills = new ConcurrentHashMap<>();
+
+ /**
+ * 保存 Skill 聚合。
+ *
+ * @param skill Skill 聚合
+ */
+ @Override
+ public void save(Skill skill) {
+ if (skill == null || isBlank(skill.getId())) {
+ throw new SkillValidationException("Skill id is required.");
+ }
+ skills.put(skill.getId(), copySkill(skill));
+ }
+
+ /**
+ * 获取完整 Skill。
+ *
+ * @param skillId Skill ID
+ * @return Skill 聚合
+ */
+ @Override
+ public Optional get(String skillId) {
+ Skill skill = skills.get(skillId);
+ return skill == null ? Optional.empty() : Optional.of(copySkill(skill));
+ }
+
+ /**
+ * 获取 Skill 描述。
+ *
+ * @param skillId Skill ID
+ * @return Skill 描述
+ */
+ @Override
+ public Optional getDescriptor(String skillId) {
+ Skill skill = skills.get(skillId);
+ return skill == null ? Optional.empty() : Optional.of(copyDescriptor(skill.toDescriptor()));
+ }
+
+ /**
+ * 列出 Skill 描述。
+ *
+ * @return Skill 描述列表
+ */
+ @Override
+ public List listDescriptors() {
+ List descriptors = new ArrayList<>();
+ for (Skill skill : skills.values()) {
+ descriptors.add(copyDescriptor(skill.toDescriptor()));
+ }
+ return descriptors;
+ }
+
+ /**
+ * 删除 Skill。
+ *
+ * @param skillId Skill ID
+ */
+ @Override
+ public void delete(String skillId) {
+ skills.remove(skillId);
+ }
+
+ /**
+ * 判断 Skill 是否存在。
+ *
+ * @param skillId Skill ID
+ * @return 存在时为 true
+ */
+ @Override
+ public boolean exists(String skillId) {
+ return skills.containsKey(skillId);
+ }
+
+ private static Skill copySkill(Skill source) {
+ Skill target = new Skill();
+ target.setId(source.getId());
+ target.setName(source.getName());
+ target.setDescription(source.getDescription());
+ target.setMetadata(copyMetadata(source.getMetadata()));
+ target.setSkillContent(source.getSkillContent());
+ target.setReferences(copyReferences(source.getReferences()));
+ target.setScripts(copyScripts(source.getScripts()));
+ target.setAssets(copyAssets(source.getAssets()));
+ return target;
+ }
+
+ private static List copyReferences(List sources) {
+ List targets = new ArrayList<>();
+ if (sources == null) {
+ return targets;
+ }
+ for (SkillReference source : sources) {
+ SkillReference target = new SkillReference();
+ target.setPath(source.getPath());
+ target.setName(source.getName());
+ target.setContent(source.getContent());
+ target.setContentHash(source.getContentHash());
+ target.setSize(source.getSize());
+ target.setMetadata(copyMetadata(source.getMetadata()));
+ targets.add(target);
+ }
+ return targets;
+ }
+
+ private static List copyScripts(List sources) {
+ List targets = new ArrayList<>();
+ if (sources == null) {
+ return targets;
+ }
+ for (SkillScript source : sources) {
+ SkillScript target = new SkillScript();
+ target.setPath(source.getPath());
+ target.setLanguage(source.getLanguage());
+ target.setContent(source.getContent());
+ target.setContentHash(source.getContentHash());
+ target.setSize(source.getSize());
+ target.setMetadata(copyMetadata(source.getMetadata()));
+ targets.add(target);
+ }
+ return targets;
+ }
+
+ private static List copyAssets(List sources) {
+ List targets = new ArrayList<>();
+ if (sources == null) {
+ return targets;
+ }
+ for (SkillAsset source : sources) {
+ SkillAsset target = new SkillAsset();
+ target.setPath(source.getPath());
+ target.setName(source.getName());
+ target.setMediaType(source.getMediaType());
+ target.setContentRef(source.getContentRef());
+ target.setContentHash(source.getContentHash());
+ target.setSize(source.getSize());
+ target.setMetadata(copyMetadata(source.getMetadata()));
+ targets.add(target);
+ }
+ return targets;
+ }
+
+ private static SkillDescriptor copyDescriptor(SkillDescriptor source) {
+ return new SkillDescriptor(
+ source.getId(),
+ source.getName(),
+ source.getDescription(),
+ copyMetadata(source.getMetadata())
+ );
+ }
+
+ private static SkillMetadata copyMetadata(SkillMetadata source) {
+ return source == null ? new SkillMetadata() : new SkillMetadata(source.getValues());
+ }
+
+ private static boolean isBlank(String value) {
+ return value == null || value.isBlank();
+ }
+}
diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/store/SkillContentStore.java b/easy-agents-skill/src/main/java/com/easyagents/skill/store/SkillContentStore.java
new file mode 100644
index 0000000..e712c4e
--- /dev/null
+++ b/easy-agents-skill/src/main/java/com/easyagents/skill/store/SkillContentStore.java
@@ -0,0 +1,41 @@
+package com.easyagents.skill.store;
+
+import java.io.InputStream;
+
+/**
+ * Skill 二进制内容存储接口。
+ */
+public interface SkillContentStore {
+
+ /**
+ * 保存内容并返回内容引用。
+ *
+ * @param bytes 内容字节
+ * @return 内容引用
+ */
+ String put(byte[] bytes);
+
+ /**
+ * 打开内容流。
+ *
+ * @param contentRef 内容引用
+ * @return 内容流
+ */
+ InputStream open(String contentRef);
+
+ /**
+ * 读取全部内容。
+ *
+ * @param contentRef 内容引用
+ * @return 内容字节
+ */
+ byte[] readAllBytes(String contentRef);
+
+ /**
+ * 判断内容是否存在。
+ *
+ * @param contentRef 内容引用
+ * @return 存在时为 true
+ */
+ boolean exists(String contentRef);
+}
diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/store/memory/InMemorySkillContentStore.java b/easy-agents-skill/src/main/java/com/easyagents/skill/store/memory/InMemorySkillContentStore.java
new file mode 100644
index 0000000..98632aa
--- /dev/null
+++ b/easy-agents-skill/src/main/java/com/easyagents/skill/store/memory/InMemorySkillContentStore.java
@@ -0,0 +1,70 @@
+package com.easyagents.skill.store.memory;
+
+import com.easyagents.skill.exception.SkillException;
+import com.easyagents.skill.store.SkillContentStore;
+import com.easyagents.skill.util.SkillHashes;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.util.Arrays;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+
+/**
+ * 基于内存的 Skill 二进制内容存储。
+ */
+public class InMemorySkillContentStore implements SkillContentStore {
+
+ private final ConcurrentMap contents = new ConcurrentHashMap<>();
+
+ /**
+ * 保存内容并返回内容引用。
+ *
+ * @param bytes 内容字节
+ * @return 内容引用
+ */
+ @Override
+ public String put(byte[] bytes) {
+ byte[] safeBytes = bytes == null ? new byte[0] : Arrays.copyOf(bytes, bytes.length);
+ String contentRef = SkillHashes.sha256Ref(safeBytes);
+ contents.putIfAbsent(contentRef, safeBytes);
+ return contentRef;
+ }
+
+ /**
+ * 打开内容流。
+ *
+ * @param contentRef 内容引用
+ * @return 内容流
+ */
+ @Override
+ public InputStream open(String contentRef) {
+ return new ByteArrayInputStream(readAllBytes(contentRef));
+ }
+
+ /**
+ * 读取全部内容。
+ *
+ * @param contentRef 内容引用
+ * @return 内容字节
+ */
+ @Override
+ public byte[] readAllBytes(String contentRef) {
+ byte[] bytes = contents.get(contentRef);
+ if (bytes == null) {
+ throw new SkillException("Skill content does not exist: " + contentRef);
+ }
+ return Arrays.copyOf(bytes, bytes.length);
+ }
+
+ /**
+ * 判断内容是否存在。
+ *
+ * @param contentRef 内容引用
+ * @return 存在时为 true
+ */
+ @Override
+ public boolean exists(String contentRef) {
+ return contents.containsKey(contentRef);
+ }
+}
diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/util/SkillFrontmatter.java b/easy-agents-skill/src/main/java/com/easyagents/skill/util/SkillFrontmatter.java
new file mode 100644
index 0000000..85e748e
--- /dev/null
+++ b/easy-agents-skill/src/main/java/com/easyagents/skill/util/SkillFrontmatter.java
@@ -0,0 +1,89 @@
+package com.easyagents.skill.util;
+
+import com.easyagents.skill.exception.SkillValidationException;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * SKILL.md frontmatter 解析工具。
+ */
+public final class SkillFrontmatter {
+
+ private SkillFrontmatter() {
+ }
+
+ /**
+ * 解析 SKILL.md 开头的 frontmatter。
+ *
+ * @param content SKILL.md 原始内容
+ * @return frontmatter 键值
+ */
+ public static Map parse(String content) {
+ if (content == null || content.isBlank()) {
+ throw new SkillValidationException("SKILL.md content is required.");
+ }
+ String[] lines = content.split("\\R", -1);
+ if (lines.length == 0 || !"---".equals(lines[0])) {
+ throw new SkillValidationException("SKILL.md must start with frontmatter.");
+ }
+
+ Map values = new LinkedHashMap<>();
+ boolean closed = false;
+ for (int i = 1; i < lines.length; i++) {
+ String line = lines[i];
+ if ("---".equals(line)) {
+ closed = true;
+ break;
+ }
+ if (line.isBlank()) {
+ continue;
+ }
+ if (Character.isWhitespace(line.charAt(0))) {
+ throw new SkillValidationException("Nested frontmatter is not supported.");
+ }
+ parseScalarLine(line, values);
+ }
+ if (!closed) {
+ throw new SkillValidationException("SKILL.md frontmatter is not closed.");
+ }
+ if (isBlank(values.get("name"))) {
+ throw new SkillValidationException("SKILL.md frontmatter name is required.");
+ }
+ if (isBlank(values.get("description"))) {
+ throw new SkillValidationException("SKILL.md frontmatter description is required.");
+ }
+ return values;
+ }
+
+ private static void parseScalarLine(String line, Map values) {
+ int colonIndex = line.indexOf(':');
+ if (colonIndex <= 0) {
+ throw new SkillValidationException("Only single-line key: value frontmatter is supported.");
+ }
+ String key = line.substring(0, colonIndex).trim();
+ String value = stripQuotes(line.substring(colonIndex + 1).trim());
+ if (key.isBlank()) {
+ throw new SkillValidationException("Frontmatter key cannot be blank.");
+ }
+ if (value.isBlank()) {
+ throw new SkillValidationException("Frontmatter value cannot be blank: " + key);
+ }
+ values.put(key, value);
+ }
+
+ private static String stripQuotes(String value) {
+ if (value.length() >= 2) {
+ boolean doubleQuoted = value.startsWith("\"") && value.endsWith("\"");
+ boolean singleQuoted = value.startsWith("'") && value.endsWith("'");
+ if (doubleQuoted || singleQuoted) {
+ return value.substring(1, value.length() - 1);
+ }
+ }
+ return value;
+ }
+
+ private static boolean isBlank(Object value) {
+ return value == null || value.toString().isBlank();
+ }
+}
diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/util/SkillHashes.java b/easy-agents-skill/src/main/java/com/easyagents/skill/util/SkillHashes.java
new file mode 100644
index 0000000..cefd9e1
--- /dev/null
+++ b/easy-agents-skill/src/main/java/com/easyagents/skill/util/SkillHashes.java
@@ -0,0 +1,47 @@
+package com.easyagents.skill.util;
+
+import com.easyagents.skill.exception.SkillException;
+
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+
+/**
+ * Skill 内容 hash 工具。
+ */
+public final class SkillHashes {
+
+ private static final String SHA_256 = "SHA-256";
+
+ private SkillHashes() {
+ }
+
+ /**
+ * 计算 SHA-256 内容引用。
+ *
+ * @param bytes 内容字节
+ * @return sha256 内容引用
+ */
+ public static String sha256Ref(byte[] bytes) {
+ return "sha256:" + sha256Hex(bytes);
+ }
+
+ /**
+ * 计算 SHA-256 十六进制值。
+ *
+ * @param bytes 内容字节
+ * @return 十六进制 hash
+ */
+ public static String sha256Hex(byte[] bytes) {
+ try {
+ MessageDigest digest = MessageDigest.getInstance(SHA_256);
+ byte[] hashed = digest.digest(bytes == null ? new byte[0] : bytes);
+ StringBuilder builder = new StringBuilder(hashed.length * 2);
+ for (byte item : hashed) {
+ builder.append(String.format("%02x", item));
+ }
+ return builder.toString();
+ } catch (NoSuchAlgorithmException e) {
+ throw new SkillException("SHA-256 algorithm is unavailable.", e);
+ }
+ }
+}
diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/util/SkillPaths.java b/easy-agents-skill/src/main/java/com/easyagents/skill/util/SkillPaths.java
new file mode 100644
index 0000000..a2c61ba
--- /dev/null
+++ b/easy-agents-skill/src/main/java/com/easyagents/skill/util/SkillPaths.java
@@ -0,0 +1,118 @@
+package com.easyagents.skill.util;
+
+import com.easyagents.skill.exception.SkillValidationException;
+
+import java.util.Locale;
+
+/**
+ * Skill 逻辑路径工具。
+ */
+public final class SkillPaths {
+
+ /**
+ * Skill 主文件名。
+ */
+ public static final String SKILL_FILE = "SKILL.md";
+
+ /**
+ * reference 顶级目录。
+ */
+ public static final String REFERENCES_DIR = "references";
+
+ /**
+ * script 顶级目录。
+ */
+ public static final String SCRIPTS_DIR = "scripts";
+
+ /**
+ * asset 顶级目录。
+ */
+ public static final String ASSETS_DIR = "assets";
+
+ private SkillPaths() {
+ }
+
+ /**
+ * 规范化逻辑路径。
+ *
+ * @param path 原始路径
+ * @return 规范化后的路径
+ */
+ public static String normalize(String path) {
+ if (path == null) {
+ throw new SkillValidationException("Skill path is required.");
+ }
+ String normalized = path.replace('\\', '/').trim();
+ while (normalized.startsWith("./")) {
+ normalized = normalized.substring(2);
+ }
+ if (normalized.isEmpty()) {
+ throw new SkillValidationException("Skill path cannot be empty.");
+ }
+ if (normalized.startsWith("/") || normalized.matches("^[A-Za-z]:.*")) {
+ throw new SkillValidationException("Absolute skill path is not allowed: " + path);
+ }
+ String[] segments = normalized.split("/");
+ for (String segment : segments) {
+ if (segment.isEmpty() || ".".equals(segment) || "..".equals(segment)) {
+ throw new SkillValidationException("Unsafe skill path is not allowed: " + path);
+ }
+ if (segment.startsWith(".")) {
+ throw new SkillValidationException("Hidden skill path is not allowed: " + path);
+ }
+ }
+ return normalized;
+ }
+
+ /**
+ * 获取一级目录或文件名。
+ *
+ * @param path 逻辑路径
+ * @return 一级路径段
+ */
+ public static String firstSegment(String path) {
+ String normalized = normalize(path);
+ int index = normalized.indexOf('/');
+ return index < 0 ? normalized : normalized.substring(0, index);
+ }
+
+ /**
+ * 获取文件名。
+ *
+ * @param path 逻辑路径
+ * @return 文件名
+ */
+ public static String fileName(String path) {
+ String normalized = normalize(path);
+ int index = normalized.lastIndexOf('/');
+ return index < 0 ? normalized : normalized.substring(index + 1);
+ }
+
+ /**
+ * 判断扩展名是否匹配。
+ *
+ * @param path 逻辑路径
+ * @param extension 扩展名
+ * @return 匹配时为 true
+ */
+ public static boolean hasExtension(String path, String extension) {
+ return normalize(path).toLowerCase(Locale.ROOT).endsWith(extension.toLowerCase(Locale.ROOT));
+ }
+
+ /**
+ * 判断是否为系统文件路径。
+ *
+ * @param path 原始路径
+ * @return 系统文件时为 true
+ */
+ public static boolean isIgnoredSystemPath(String path) {
+ if (path == null) {
+ return true;
+ }
+ String normalized = path.replace('\\', '/');
+ return normalized.startsWith("__MACOSX/")
+ || normalized.contains("/__MACOSX/")
+ || normalized.endsWith("/.DS_Store")
+ || ".DS_Store".equals(normalized);
+ }
+}
diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/validation/SkillValidator.java b/easy-agents-skill/src/main/java/com/easyagents/skill/validation/SkillValidator.java
new file mode 100644
index 0000000..b0ef7dc
--- /dev/null
+++ b/easy-agents-skill/src/main/java/com/easyagents/skill/validation/SkillValidator.java
@@ -0,0 +1,16 @@
+package com.easyagents.skill.validation;
+
+import com.easyagents.skill.model.Skill;
+
+/**
+ * Skill 校验接口。
+ */
+public interface SkillValidator {
+
+ /**
+ * 校验 Skill 聚合。
+ *
+ * @param skill Skill 聚合
+ */
+ void validate(Skill skill);
+}
diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/validation/defaults/DefaultSkillValidator.java b/easy-agents-skill/src/main/java/com/easyagents/skill/validation/defaults/DefaultSkillValidator.java
new file mode 100644
index 0000000..06fc481
--- /dev/null
+++ b/easy-agents-skill/src/main/java/com/easyagents/skill/validation/defaults/DefaultSkillValidator.java
@@ -0,0 +1,156 @@
+package com.easyagents.skill.validation.defaults;
+
+import com.easyagents.skill.exception.SkillValidationException;
+import com.easyagents.skill.model.*;
+import com.easyagents.skill.util.SkillFrontmatter;
+import com.easyagents.skill.util.SkillHashes;
+import com.easyagents.skill.util.SkillPaths;
+import com.easyagents.skill.validation.SkillValidator;
+
+import java.nio.charset.StandardCharsets;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 默认 Skill 聚合校验器。
+ */
+public class DefaultSkillValidator implements SkillValidator {
+
+ /**
+ * 校验 Skill 聚合。
+ *
+ * @param skill Skill 聚合
+ */
+ @Override
+ public void validate(Skill skill) {
+ if (skill == null) {
+ throw new SkillValidationException("Skill is required.");
+ }
+ requireText(skill.getId(), "Skill id is required.");
+ requireText(skill.getName(), "Skill name is required.");
+ requireText(skill.getDescription(), "Skill description is required.");
+ requireText(skill.getSkillContent(), "SKILL.md content is required.");
+ validateSkillFrontmatter(skill);
+
+ Set paths = new HashSet<>();
+ paths.add(SkillPaths.SKILL_FILE);
+ validateReferences(skill.getReferences(), paths);
+ validateScripts(skill.getScripts(), paths);
+ validateAssets(skill.getAssets(), paths);
+ }
+
+ private static void validateReferences(List references, Set paths) {
+ if (references == null) {
+ return;
+ }
+ for (SkillReference reference : references) {
+ if (reference == null) {
+ throw new SkillValidationException("Skill reference cannot be null.");
+ }
+ String path = validateFilePath(reference.getPath(), SkillPaths.REFERENCES_DIR, paths);
+ if (!SkillPaths.hasExtension(path, ".md")) {
+ throw new SkillValidationException("Skill reference must be a markdown file: " + path);
+ }
+ requireContent(reference.getContent(), "Skill reference content is required: " + path);
+ validateTextHash(path, reference.getContent(), reference.getContentHash());
+ validateSize(reference.getSize(), path);
+ }
+ }
+
+ private static void validateScripts(List scripts, Set paths) {
+ if (scripts == null) {
+ return;
+ }
+ for (SkillScript script : scripts) {
+ if (script == null) {
+ throw new SkillValidationException("Skill script cannot be null.");
+ }
+ String path = validateFilePath(script.getPath(), SkillPaths.SCRIPTS_DIR, paths);
+ if (SkillScriptLanguage.fromPath(path) == SkillScriptLanguage.UNKNOWN) {
+ throw new SkillValidationException("Unsupported skill script extension: " + path);
+ }
+ if (script.getLanguage() == SkillScriptLanguage.UNKNOWN
+ || script.getLanguage() != SkillScriptLanguage.fromPath(path)) {
+ throw new SkillValidationException("Skill script language does not match path: " + path);
+ }
+ requireContent(script.getContent(), "Skill script content is required: " + path);
+ validateTextHash(path, script.getContent(), script.getContentHash());
+ validateSize(script.getSize(), path);
+ }
+ }
+
+ private static void validateAssets(List assets, Set paths) {
+ if (assets == null) {
+ return;
+ }
+ for (SkillAsset asset : assets) {
+ if (asset == null) {
+ throw new SkillValidationException("Skill asset cannot be null.");
+ }
+ String path = validateFilePath(asset.getPath(), SkillPaths.ASSETS_DIR, paths);
+ requireText(asset.getName(), "Skill asset name is required: " + path);
+ requireText(asset.getMediaType(), "Skill asset media type is required: " + path);
+ requireText(asset.getContentRef(), "Skill asset content ref is required: " + path);
+ requireText(asset.getContentHash(), "Skill asset content hash is required: " + path);
+ if (!asset.getContentRef().equals("sha256:" + asset.getContentHash())) {
+ throw new SkillValidationException("Skill asset content ref does not match hash: " + path);
+ }
+ validateSize(asset.getSize(), path);
+ }
+ }
+
+ private static void validateSkillFrontmatter(Skill skill) {
+ Map values = SkillFrontmatter.parse(skill.getSkillContent());
+ if (!skill.getName().equals(values.get("name").toString())) {
+ throw new SkillValidationException("Skill name must match SKILL.md frontmatter.");
+ }
+ if (!skill.getDescription().equals(values.get("description").toString())) {
+ throw new SkillValidationException("Skill description must match SKILL.md frontmatter.");
+ }
+ if (skill.getMetadata() == null || !skill.getMetadata().getValues().equals(values)) {
+ throw new SkillValidationException("Skill metadata must match SKILL.md frontmatter.");
+ }
+ }
+
+ private static String validateFilePath(String path, String expectedTopDir, Set paths) {
+ String normalized = SkillPaths.normalize(path);
+ if (!expectedTopDir.equals(SkillPaths.firstSegment(normalized))) {
+ throw new SkillValidationException("Skill file must be under " + expectedTopDir + "/: " + normalized);
+ }
+ if (normalized.indexOf('/') < 0 || normalized.endsWith("/")) {
+ throw new SkillValidationException("Skill file path must include file name: " + normalized);
+ }
+ if (!paths.add(normalized)) {
+ throw new SkillValidationException("Duplicate skill file path: " + normalized);
+ }
+ return normalized;
+ }
+
+ private static void validateTextHash(String path, String content, String contentHash) {
+ requireText(contentHash, "Skill file content hash is required: " + path);
+ String actualHash = SkillHashes.sha256Hex(content.getBytes(StandardCharsets.UTF_8));
+ if (!actualHash.equals(contentHash)) {
+ throw new SkillValidationException("Skill file content hash does not match: " + path);
+ }
+ }
+
+ private static void validateSize(long size, String path) {
+ if (size < 0) {
+ throw new SkillValidationException("Skill file size cannot be negative: " + path);
+ }
+ }
+
+ private static void requireText(String value, String message) {
+ if (value == null || value.isBlank()) {
+ throw new SkillValidationException(message);
+ }
+ }
+
+ private static void requireContent(String value, String message) {
+ if (value == null) {
+ throw new SkillValidationException(message);
+ }
+ }
+}
diff --git a/easy-agents-skill/src/test/java/com/easyagents/skill/codec/ZipSkillPackageCodecTest.java b/easy-agents-skill/src/test/java/com/easyagents/skill/codec/ZipSkillPackageCodecTest.java
new file mode 100644
index 0000000..50831bc
--- /dev/null
+++ b/easy-agents-skill/src/test/java/com/easyagents/skill/codec/ZipSkillPackageCodecTest.java
@@ -0,0 +1,275 @@
+package com.easyagents.skill.codec;
+
+import com.easyagents.skill.exception.SkillPackageException;
+import com.easyagents.skill.model.Skill;
+import com.easyagents.skill.store.SkillContentStore;
+import com.easyagents.skill.store.memory.InMemorySkillContentStore;
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+/**
+ * ZipSkillPackageCodec 单元测试。
+ */
+public class ZipSkillPackageCodecTest {
+
+ /**
+ * 导入包含一个 Skill 文件夹的 zip。
+ */
+ @Test
+ public void importOneSkillFolder() {
+ List skills = importZip(files(
+ "skill-a/SKILL.md", skillMd("Skill A", "Desc A"),
+ "skill-a/references/rules/a.md", "# Rule",
+ "skill-a/scripts/run.py", "print('ok')",
+ "skill-a/assets/images/logo.png", "png-data"
+ ));
+
+ Assert.assertEquals(1, skills.size());
+ Skill skill = skills.get(0);
+ Assert.assertEquals("skill-a", skill.getId());
+ Assert.assertEquals("Skill A", skill.getName());
+ Assert.assertEquals("Desc A", skill.getDescription());
+ Assert.assertEquals(1, skill.getReferences().size());
+ Assert.assertEquals("references/rules/a.md", skill.getReferences().get(0).getPath());
+ Assert.assertEquals(1, skill.getScripts().size());
+ Assert.assertEquals(1, skill.getAssets().size());
+ Assert.assertEquals("assets/images/logo.png", skill.getAssets().get(0).getPath());
+ }
+
+ /**
+ * 导入包含多个 Skill 文件夹的 zip。
+ */
+ @Test
+ public void importMultipleSkillFolders() {
+ List skills = importZip(files(
+ "skill-a/SKILL.md", skillMd("Skill A", "Desc A"),
+ "skill-b/SKILL.md", skillMd("Skill B", "Desc B")
+ ));
+
+ Assert.assertEquals(2, skills.size());
+ Assert.assertEquals("skill-a", skills.get(0).getId());
+ Assert.assertEquals("skill-b", skills.get(1).getId());
+ }
+
+ /**
+ * 拒绝 zip 根目录直接包含 SKILL.md。
+ */
+ @Test(expected = SkillPackageException.class)
+ public void rejectRootSkillMd() {
+ importZip(files("SKILL.md", skillMd("Skill", "Desc")));
+ }
+
+ /**
+ * 拒绝未知顶级目录。
+ */
+ @Test(expected = SkillPackageException.class)
+ public void rejectUnknownTopLevelDirectory() {
+ importZip(files(
+ "skill-a/SKILL.md", skillMd("Skill A", "Desc A"),
+ "skill-a/unknown/a.md", "# Unknown"
+ ));
+ }
+
+ /**
+ * 拒绝 references 下的非 md 文件。
+ */
+ @Test(expected = SkillPackageException.class)
+ public void rejectNonMarkdownReference() {
+ importZip(files(
+ "skill-a/SKILL.md", skillMd("Skill A", "Desc A"),
+ "skill-a/references/a.txt", "text"
+ ));
+ }
+
+ /**
+ * 拒绝不支持的脚本扩展名。
+ */
+ @Test(expected = SkillPackageException.class)
+ public void rejectUnsupportedScript() {
+ importZip(files(
+ "skill-a/SKILL.md", skillMd("Skill A", "Desc A"),
+ "skill-a/scripts/run.rb", "puts 'ok'"
+ ));
+ }
+
+ /**
+ * assets 允许任意扩展名。
+ */
+ @Test
+ public void allowArbitraryAssetExtension() {
+ List skills = importZip(files(
+ "skill-a/SKILL.md", skillMd("Skill A", "Desc A"),
+ "skill-a/assets/model.custom", "asset"
+ ));
+
+ Assert.assertEquals(1, skills.get(0).getAssets().size());
+ Assert.assertEquals("application/octet-stream", skills.get(0).getAssets().get(0).getMediaType());
+ }
+
+ /**
+ * 忽略 macOS 系统文件和空目录。
+ */
+ @Test
+ public void ignoreSystemFilesAndEmptyDirectories() {
+ Map files = files(
+ "__MACOSX/._x", "ignored",
+ "skill-a/.DS_Store", "ignored",
+ "skill-a/SKILL.md", skillMd("Skill A", "Desc A")
+ );
+ List skills = new ZipSkillPackageCodec().importZip(new ByteArrayInputStream(zip(files, "skill-a/references/")));
+
+ Assert.assertEquals(1, skills.size());
+ Assert.assertEquals("skill-a", skills.get(0).getId());
+ }
+
+ /**
+ * 嵌套 references 和 assets 路径正常导入。
+ */
+ @Test
+ public void allowNestedManagedDirectories() {
+ List skills = importZip(files(
+ "skill-a/SKILL.md", skillMd("Skill A", "Desc A"),
+ "skill-a/references/rules/a.md", "# Nested",
+ "skill-a/assets/images/logo.png", "logo"
+ ));
+
+ Assert.assertEquals("references/rules/a.md", skills.get(0).getReferences().get(0).getPath());
+ Assert.assertEquals("assets/images/logo.png", skills.get(0).getAssets().get(0).getPath());
+ }
+
+ /**
+ * 资产字节写入内容存储。
+ */
+ @Test
+ public void storeAssetContent() {
+ InMemorySkillContentStore store = new InMemorySkillContentStore();
+ List skills = new ZipSkillPackageCodec(store).importZip(new ByteArrayInputStream(zip(files(
+ "skill-a/SKILL.md", skillMd("Skill A", "Desc A"),
+ "skill-a/assets/data.bin", "abc"
+ ))));
+
+ String contentRef = skills.get(0).getAssets().get(0).getContentRef();
+ Assert.assertTrue(store.exists(contentRef));
+ Assert.assertArrayEquals("abc".getBytes(StandardCharsets.UTF_8), store.readAllBytes(contentRef));
+ }
+
+ /**
+ * 导入失败时不写入资产内容。
+ */
+ @Test
+ public void failedImportDoesNotWriteAssetContent() {
+ CountingContentStore store = new CountingContentStore();
+
+ try {
+ new ZipSkillPackageCodec(store).importZip(new ByteArrayInputStream(zip(files(
+ "skill-a/SKILL.md", skillMd("Skill A", "Desc A"),
+ "skill-a/assets/data.bin", "abc",
+ "skill-a/unknown/a.md", "# Unknown"
+ ))));
+ Assert.fail("Import should fail.");
+ } catch (SkillPackageException expected) {
+ Assert.assertEquals(0, store.putCount);
+ }
+ }
+
+ /**
+ * 多 Skill 包中后续 Skill 失败时,不提前写入前面 Skill 的资产。
+ */
+ @Test
+ public void failedMultiSkillImportDoesNotWriteEarlierAssetContent() {
+ CountingContentStore store = new CountingContentStore();
+
+ try {
+ new ZipSkillPackageCodec(store).importZip(new ByteArrayInputStream(zip(files(
+ "skill-a/SKILL.md", skillMd("Skill A", "Desc A"),
+ "skill-a/assets/data.bin", "abc",
+ "skill-b/references/a.md", "# Missing SKILL.md"
+ ))));
+ Assert.fail("Import should fail.");
+ } catch (SkillPackageException expected) {
+ Assert.assertEquals(0, store.putCount);
+ }
+ }
+
+ /**
+ * 拒绝嵌套 frontmatter。
+ */
+ @Test(expected = SkillPackageException.class)
+ public void rejectNestedFrontmatter() {
+ importZip(files(
+ "skill-a/SKILL.md", "---\nname: Skill A\ndescription: Desc A\nconfig:\n mode: strict\n---\n# Skill A\n"
+ ));
+ }
+
+ private static List importZip(Map files) {
+ return new ZipSkillPackageCodec().importZip(new ByteArrayInputStream(zip(files)));
+ }
+
+ private static Map files(String... keyValues) {
+ Map files = new LinkedHashMap<>();
+ for (int i = 0; i < keyValues.length; i += 2) {
+ files.put(keyValues[i], keyValues[i + 1]);
+ }
+ return files;
+ }
+
+ private static byte[] zip(Map files, String... directories) {
+ try {
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ try (ZipOutputStream zipOutputStream = new ZipOutputStream(bytes)) {
+ for (String directory : directories) {
+ zipOutputStream.putNextEntry(new ZipEntry(directory));
+ zipOutputStream.closeEntry();
+ }
+ for (Map.Entry file : files.entrySet()) {
+ zipOutputStream.putNextEntry(new ZipEntry(file.getKey()));
+ zipOutputStream.write(file.getValue().getBytes(StandardCharsets.UTF_8));
+ zipOutputStream.closeEntry();
+ }
+ }
+ return bytes.toByteArray();
+ } catch (Exception e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
+ private static String skillMd(String name, String description) {
+ return "---\nname: " + name + "\ndescription: " + description + "\n---\n# " + name + "\n";
+ }
+
+ private static final class CountingContentStore implements SkillContentStore {
+
+ private int putCount;
+
+ @Override
+ public String put(byte[] bytes) {
+ putCount++;
+ return "sha256:" + com.easyagents.skill.util.SkillHashes.sha256Hex(bytes);
+ }
+
+ @Override
+ public InputStream open(String contentRef) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public byte[] readAllBytes(String contentRef) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public boolean exists(String contentRef) {
+ return false;
+ }
+ }
+}
diff --git a/easy-agents-skill/src/test/java/com/easyagents/skill/repository/memory/InMemorySkillRepositoryTest.java b/easy-agents-skill/src/test/java/com/easyagents/skill/repository/memory/InMemorySkillRepositoryTest.java
new file mode 100644
index 0000000..fa3a550
--- /dev/null
+++ b/easy-agents-skill/src/test/java/com/easyagents/skill/repository/memory/InMemorySkillRepositoryTest.java
@@ -0,0 +1,83 @@
+package com.easyagents.skill.repository.memory;
+
+import com.easyagents.skill.model.Skill;
+import com.easyagents.skill.model.SkillDescriptor;
+import com.easyagents.skill.model.SkillReference;
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.util.Optional;
+
+/**
+ * InMemorySkillRepository 单元测试。
+ */
+public class InMemorySkillRepositoryTest {
+
+ /**
+ * 覆盖保存、读取、描述、列表、删除和存在性判断。
+ */
+ @Test
+ public void crudSkill() {
+ InMemorySkillRepository repository = new InMemorySkillRepository();
+ Skill skill = skill();
+
+ repository.save(skill);
+
+ Assert.assertTrue(repository.exists("skill-a"));
+ Assert.assertTrue(repository.get("skill-a").isPresent());
+ Assert.assertTrue(repository.getDescriptor("skill-a").isPresent());
+ Assert.assertEquals(1, repository.listDescriptors().size());
+
+ repository.delete("skill-a");
+
+ Assert.assertFalse(repository.exists("skill-a"));
+ Assert.assertFalse(repository.get("skill-a").isPresent());
+ }
+
+ /**
+ * descriptor 不携带 references/scripts/assets 内容。
+ */
+ @Test
+ public void descriptorDoesNotExposeResourceContent() {
+ InMemorySkillRepository repository = new InMemorySkillRepository();
+ repository.save(skill());
+
+ Optional descriptor = repository.getDescriptor("skill-a");
+
+ Assert.assertTrue(descriptor.isPresent());
+ Assert.assertEquals("skill-a", descriptor.get().getId());
+ Assert.assertEquals("Skill A", descriptor.get().getName());
+ }
+
+ /**
+ * 读取返回副本,避免外部修改仓储内部状态。
+ */
+ @Test
+ public void getReturnsCopy() {
+ InMemorySkillRepository repository = new InMemorySkillRepository();
+ repository.save(skill());
+
+ Skill loaded = repository.get("skill-a").get();
+ loaded.setName("Changed");
+ loaded.getReferences().get(0).setContent("changed");
+
+ Skill reloaded = repository.get("skill-a").get();
+ Assert.assertEquals("Skill A", reloaded.getName());
+ Assert.assertEquals("# A", reloaded.getReferences().get(0).getContent());
+ }
+
+ private static Skill skill() {
+ Skill skill = new Skill();
+ skill.setId("skill-a");
+ skill.setName("Skill A");
+ skill.setDescription("Desc A");
+ skill.setSkillContent("---\nname: Skill A\ndescription: Desc A\n---\n# Skill A\n");
+
+ SkillReference reference = new SkillReference();
+ reference.setPath("references/a.md");
+ reference.setName("a.md");
+ reference.setContent("# A");
+ skill.getReferences().add(reference);
+ return skill;
+ }
+}
diff --git a/easy-agents-skill/src/test/java/com/easyagents/skill/store/memory/InMemorySkillContentStoreTest.java b/easy-agents-skill/src/test/java/com/easyagents/skill/store/memory/InMemorySkillContentStoreTest.java
new file mode 100644
index 0000000..1050cbe
--- /dev/null
+++ b/easy-agents-skill/src/test/java/com/easyagents/skill/store/memory/InMemorySkillContentStoreTest.java
@@ -0,0 +1,46 @@
+package com.easyagents.skill.store.memory;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * InMemorySkillContentStore 单元测试。
+ */
+public class InMemorySkillContentStoreTest {
+
+ /**
+ * put 返回 sha256 内容引用,且相同内容引用相同。
+ */
+ @Test
+ public void putReturnsStableSha256Ref() {
+ InMemorySkillContentStore store = new InMemorySkillContentStore();
+
+ String firstRef = store.put("abc".getBytes(StandardCharsets.UTF_8));
+ String secondRef = store.put("abc".getBytes(StandardCharsets.UTF_8));
+
+ Assert.assertTrue(firstRef.startsWith("sha256:"));
+ Assert.assertEquals(firstRef, secondRef);
+ }
+
+ /**
+ * open、readAllBytes 和 exists 正常工作。
+ *
+ * @throws Exception 读取流失败时抛出
+ */
+ @Test
+ public void openReadAndExists() throws Exception {
+ InMemorySkillContentStore store = new InMemorySkillContentStore();
+ byte[] bytes = "abc".getBytes(StandardCharsets.UTF_8);
+
+ String contentRef = store.put(bytes);
+
+ Assert.assertTrue(store.exists(contentRef));
+ Assert.assertArrayEquals(bytes, store.readAllBytes(contentRef));
+ try (InputStream inputStream = store.open(contentRef)) {
+ Assert.assertArrayEquals(bytes, inputStream.readAllBytes());
+ }
+ }
+}
diff --git a/easy-agents-skill/src/test/java/com/easyagents/skill/validation/defaults/DefaultSkillValidatorTest.java b/easy-agents-skill/src/test/java/com/easyagents/skill/validation/defaults/DefaultSkillValidatorTest.java
new file mode 100644
index 0000000..fdaab0a
--- /dev/null
+++ b/easy-agents-skill/src/test/java/com/easyagents/skill/validation/defaults/DefaultSkillValidatorTest.java
@@ -0,0 +1,193 @@
+package com.easyagents.skill.validation.defaults;
+
+import com.easyagents.skill.exception.SkillValidationException;
+import com.easyagents.skill.factory.SkillFactory;
+import com.easyagents.skill.model.*;
+import com.easyagents.skill.util.SkillHashes;
+import org.junit.Test;
+
+import java.nio.charset.StandardCharsets;
+
+/**
+ * DefaultSkillValidator 单元测试。
+ */
+public class DefaultSkillValidatorTest {
+
+ private final DefaultSkillValidator validator = new DefaultSkillValidator();
+
+ /**
+ * 缺失 SKILL.md 内容时失败。
+ */
+ @Test(expected = SkillValidationException.class)
+ public void rejectMissingSkillContent() {
+ Skill skill = validSkill();
+ skill.setSkillContent(null);
+
+ validator.validate(skill);
+ }
+
+ /**
+ * 缺失名称时失败。
+ */
+ @Test(expected = SkillValidationException.class)
+ public void rejectMissingName() {
+ Skill skill = validSkill();
+ skill.setName("");
+
+ validator.validate(skill);
+ }
+
+ /**
+ * 缺失描述时失败。
+ */
+ @Test(expected = SkillValidationException.class)
+ public void rejectMissingDescription() {
+ Skill skill = validSkill();
+ skill.setDescription("");
+
+ validator.validate(skill);
+ }
+
+ /**
+ * 字段名称必须与 SKILL.md frontmatter 一致。
+ */
+ @Test(expected = SkillValidationException.class)
+ public void rejectNameMismatchWithFrontmatter() {
+ Skill skill = validSkill();
+ skill.setName("Changed");
+
+ validator.validate(skill);
+ }
+
+ /**
+ * 元数据必须与 SKILL.md frontmatter 一致。
+ */
+ @Test(expected = SkillValidationException.class)
+ public void rejectMetadataMismatchWithFrontmatter() {
+ Skill skill = validSkill();
+ skill.getMetadata().put("extra", "x");
+
+ validator.validate(skill);
+ }
+
+ /**
+ * 拒绝嵌套 frontmatter。
+ */
+ @Test(expected = SkillValidationException.class)
+ public void rejectNestedFrontmatter() {
+ Skill skill = validSkill();
+ skill.setSkillContent("---\nname: Skill A\ndescription: Desc A\nconfig:\n mode: strict\n---\n# Skill A\n");
+
+ validator.validate(skill);
+ }
+
+ /**
+ * 绝对路径失败。
+ */
+ @Test(expected = SkillValidationException.class)
+ public void rejectAbsolutePath() {
+ Skill skill = validSkill();
+ SkillReference reference = reference("/references/a.md", "# A");
+ skill.getReferences().add(reference);
+
+ validator.validate(skill);
+ }
+
+ /**
+ * ../ 路径失败。
+ */
+ @Test(expected = SkillValidationException.class)
+ public void rejectParentPath() {
+ Skill skill = validSkill();
+ skill.getReferences().add(reference("references/../a.md", "# A"));
+
+ validator.validate(skill);
+ }
+
+ /**
+ * 重复路径失败。
+ */
+ @Test(expected = SkillValidationException.class)
+ public void rejectDuplicatePath() {
+ Skill skill = validSkill();
+ skill.getReferences().add(reference("references/a.md", "# A"));
+ skill.getReferences().add(reference("references/a.md", "# B"));
+
+ validator.validate(skill);
+ }
+
+ /**
+ * 不因为文件大小较大而失败。
+ */
+ @Test
+ public void allowLargeRecordedSize() {
+ Skill skill = validSkill();
+ SkillReference reference = reference("references/a.md", "# A");
+ reference.setSize(Long.MAX_VALUE);
+ skill.getReferences().add(reference);
+
+ validator.validate(skill);
+ }
+
+ /**
+ * 允许空内容文件,只校验 hash 和 size 记录。
+ */
+ @Test
+ public void allowEmptyReferenceAndScriptContent() {
+ Skill skill = validSkill();
+ skill.getReferences().add(reference("references/empty.md", ""));
+ skill.getScripts().add(script("scripts/empty.sh", ""));
+
+ validator.validate(skill);
+ }
+
+ /**
+ * 校验通过完整 Skill。
+ */
+ @Test
+ public void validateCompleteSkill() {
+ Skill skill = validSkill();
+ skill.getReferences().add(reference("references/a.md", "# A"));
+ skill.getScripts().add(script("scripts/run.sh", "echo ok"));
+ skill.getAssets().add(asset("assets/a.bin", "abc"));
+
+ validator.validate(skill);
+ }
+
+ private static Skill validSkill() {
+ return SkillFactory.create("skill-a", "---\nname: Skill A\ndescription: Desc A\n---\n# Skill A\n");
+ }
+
+ private static SkillReference reference(String path, String content) {
+ SkillReference reference = new SkillReference();
+ reference.setPath(path);
+ reference.setName("a.md");
+ reference.setContent(content);
+ reference.setContentHash(SkillHashes.sha256Hex(content.getBytes(StandardCharsets.UTF_8)));
+ reference.setSize(content.getBytes(StandardCharsets.UTF_8).length);
+ return reference;
+ }
+
+ private static SkillScript script(String path, String content) {
+ SkillScript script = new SkillScript();
+ script.setPath(path);
+ script.setLanguage(SkillScriptLanguage.fromPath(path));
+ script.setContent(content);
+ script.setContentHash(SkillHashes.sha256Hex(content.getBytes(StandardCharsets.UTF_8)));
+ script.setSize(content.getBytes(StandardCharsets.UTF_8).length);
+ return script;
+ }
+
+ private static SkillAsset asset(String path, String content) {
+ byte[] bytes = content.getBytes(StandardCharsets.UTF_8);
+ String hash = SkillHashes.sha256Hex(bytes);
+ SkillAsset asset = new SkillAsset();
+ asset.setPath(path);
+ asset.setName("a.bin");
+ asset.setMediaType("application/octet-stream");
+ asset.setContentRef("sha256:" + hash);
+ asset.setContentHash(hash);
+ asset.setSize(bytes.length);
+ return asset;
+ }
+}
diff --git a/pom.xml b/pom.xml
index 07e17d1..d9ed2c4 100644
--- a/pom.xml
+++ b/pom.xml
@@ -27,6 +27,7 @@
easy-agents-embedding
easy-agents-tool
easy-agents-mcp
+ easy-agents-skill
easy-agents-agent-runtime
easy-agents-flow
easy-agents-support
@@ -430,6 +431,12 @@
${revision}
+
+ com.easyagents
+ easy-agents-skill
+ ${revision}
+
+
com.easyagents
easy-agents-agent-runtime