Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,22 @@ public static Builder builder() {
return new Builder();
}

/**
* Returns a string representation of this skill.
*
* @return String representation including name, description, and source
*/
@Override
public String toString() {
return "AgentSkill{name='"
+ name
+ "', description='"
+ description
+ "', source='"
+ source
+ "'}";
}

/**
* Builder for creating AgentSkill instances.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,17 @@

import io.agentscope.core.skill.AgentSkill;
import io.agentscope.core.skill.util.MarkdownSkillParser.ParsedMarkdown;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;

/**
* Utility class for creating AgentSkill instances.
Expand All @@ -39,6 +49,9 @@
*/
public class SkillUtil {

private static final String SKILL_FILE_NAME = "SKILL.md";
private static final String DEFAULT_SOURCE = "custom";

/**
* Private constructor to prevent instantiation.
*/
Expand All @@ -56,7 +69,7 @@ private SkillUtil() {}
* @throws IllegalArgumentException if name or description is missing, or if content is empty
*/
public static AgentSkill createFrom(String skillMd, Map<String, String> resources) {
return createFrom(skillMd, resources, "custom");
return createFrom(skillMd, resources, DEFAULT_SOURCE);
}

/**
Expand Down Expand Up @@ -92,4 +105,197 @@ public static AgentSkill createFrom(

return new AgentSkill(name, description, skillContent, resources, source);
}

/**
* Creates an AgentSkill from a skill package zip.
*
* <p>The package must contain a {@value #SKILL_FILE_NAME} entry and any optional resource
* files. The .skill and .zip extensions are aliases for the same package format.
*
* @param zipBytes Zip content as bytes
* @return Created AgentSkill instance
* @throws IllegalArgumentException if zipBytes is null/empty, if no SKILL.md is found, or if
* zip entries are invalid
*/
public static AgentSkill createFromZip(byte[] zipBytes) {
return createFromZip(zipBytes, DEFAULT_SOURCE);
}

/**
* Creates an AgentSkill from a skill package zip file path.
*
* <p>The package must contain a {@value #SKILL_FILE_NAME} entry and any optional resource
* files. The .skill and .zip extensions are aliases for the same package format.
*
* @param zipPath Zip file path
* @return Created AgentSkill instance
* @throws IllegalArgumentException if zipPath is null
* @throws RuntimeException if the zip file cannot be read
*/
public static AgentSkill createFromZip(Path zipPath) {
return createFromZip(zipPath, DEFAULT_SOURCE);
}

/**
* Creates an AgentSkill from a skill package zip input stream.
*
* <p>The package must contain a {@value #SKILL_FILE_NAME} entry and any optional resource
* files. The .skill and .zip extensions are aliases for the same package format.
*
* @param zipStream Zip content stream
* @return Created AgentSkill instance
* @throws IllegalArgumentException if zipStream is null
*/
public static AgentSkill createFromZip(InputStream zipStream) {
return createFromZip(zipStream, DEFAULT_SOURCE);
}

/**
* Creates an AgentSkill from a skill package zip with custom source.
*
* <p>The package must contain a {@value #SKILL_FILE_NAME} entry and any optional resource
* files. The .skill and .zip extensions are aliases for the same package format.
*
* @param zipBytes Zip content as bytes
* @param source Source identifier for the skill (null defaults to "custom")
* @return Created AgentSkill instance
* @throws IllegalArgumentException if zipBytes is null/empty, if no SKILL.md is found, or if
* zip entries are invalid
*/
public static AgentSkill createFromZip(byte[] zipBytes, String source) {
if (zipBytes == null || zipBytes.length == 0) {
throw new IllegalArgumentException("Zip content cannot be null or empty.");
}
return createFromZip(new ByteArrayInputStream(zipBytes), source);
}

/**
* Creates an AgentSkill from a skill package zip file path with custom source.
*
* <p>The package must contain a {@value #SKILL_FILE_NAME} entry and any optional resource
* files. The .skill and .zip extensions are aliases for the same package format.
*
* @param zipPath Zip file path
* @param source Source identifier for the skill (null defaults to "custom")
* @return Created AgentSkill instance
* @throws IllegalArgumentException if zipPath is null
* @throws RuntimeException if the zip file cannot be read
*/
public static AgentSkill createFromZip(Path zipPath, String source) {
if (zipPath == null) {
throw new IllegalArgumentException("Zip path cannot be null.");
}
try (InputStream inputStream = Files.newInputStream(zipPath)) {
return createFromZip(inputStream, source);
} catch (IOException e) {
throw new RuntimeException("Failed to read skill zip content.", e);
}
}

/**
* Creates an AgentSkill from a skill package zip input stream with custom source.
*
* <p>The package must contain a {@value #SKILL_FILE_NAME} entry and any optional resource
* files. The .skill and .zip extensions are aliases for the same package format.
*
* @param zipStream Zip content stream
* @param source Source identifier for the skill (null defaults to "custom")
* @return Created AgentSkill instance
* @throws IllegalArgumentException if zipStream is null
* @throws RuntimeException if the zip content cannot be read
*/
public static AgentSkill createFromZip(InputStream zipStream, String source) {
if (zipStream == null) {
throw new IllegalArgumentException("Zip stream cannot be null.");
}

String skillEntryName = null;
String skillMdContent = null;
String rootDir = null;
Map<String, String> resources = new HashMap<>();

try (ZipInputStream zipInputStream = new ZipInputStream(zipStream)) {
ZipEntry entry;
while ((entry = zipInputStream.getNextEntry()) != null) {
if (entry.isDirectory()) {
continue;
}
String entryName = normalizeZipEntryName(entry.getName());
int separatorIndex = entryName.indexOf('/');
if (separatorIndex <= 0) {
throw new IllegalArgumentException(
"Zip entries must be under a single root directory.");
}
String entryRoot = entryName.substring(0, separatorIndex);
if (rootDir == null) {
rootDir = entryRoot;
} else if (!rootDir.equals(entryRoot)) {
throw new IllegalArgumentException(
"Zip entries must share the same root directory.");
}
String content = readZipEntryContent(zipInputStream);

String expectedSkillEntry = entryRoot + "/" + SKILL_FILE_NAME;
if (entryName.endsWith("/" + SKILL_FILE_NAME)
&& !entryName.equals(expectedSkillEntry)) {
throw new IllegalArgumentException(
"SKILL.md must be located directly under the root directory.");
}
if (entryName.equals(expectedSkillEntry)) {
if (skillEntryName != null && !skillEntryName.equals(entryName)) {
throw new IllegalArgumentException(
"Multiple SKILL.md entries found in zip content.");
}
skillEntryName = entryName;
skillMdContent = content;
continue;
}

String rootPrefix = rootDir + "/";
if (!entryName.startsWith(rootPrefix)) {
throw new IllegalArgumentException(
"Zip entries must share the same root directory as SKILL.md.");
}
String resourceName = entryName.substring(rootPrefix.length());
resources.put(resourceName, content);
}
} catch (IOException e) {
throw new RuntimeException("Failed to read skill zip content.", e);
}

if (skillEntryName == null) {
throw new IllegalArgumentException("SKILL.md not found in zip content.");
}

return createFrom(skillMdContent, resources, source);
}

private static String normalizeZipEntryName(String entryName) {
if (entryName == null || entryName.isEmpty()) {
throw new IllegalArgumentException("Zip entry name cannot be null or empty.");
}
String normalized = entryName.replace('\\', '/');
if (normalized.startsWith("/")) {
throw new IllegalArgumentException("Zip entry name must be a relative path.");
}
String[] segments = normalized.split("/");
for (String segment : segments) {
if ("..".equals(segment)) {
throw new IllegalArgumentException(
"Zip entry name must not contain parent directory segments.");
}
}
return normalized;
}

private static String readZipEntryContent(ZipInputStream zipInputStream) throws IOException {
byte[] buffer = new byte[8192];
int read;
try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
while ((read = zipInputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, read);
}
return outputStream.toString(StandardCharsets.UTF_8);
}
}
}
Loading
Loading