feat(publish): support SKILL.md in subdirectory with warning for ignored files

When SKILL.md is found in a single subdirectory (e.g. my-skill/SKILL.md),
promote that directory's contents to root and discard files outside it.
Discarded files are reported as warnings through the existing confirm flow.
This commit is contained in:
xiose 2026-04-30 10:17:28 +08:00
parent 4d8f9dd3f5
commit 66fb89dc48
2 changed files with 113 additions and 0 deletions

View file

@ -18,6 +18,8 @@ import java.util.zip.ZipInputStream;
@Component
public class SkillPackageArchiveExtractor {
public record ExtractionResult(List<PackageEntry> entries, List<String> warnings) {}
private final long maxTotalPackageSize;
private final long maxSingleFileSize;
private final int maxFileCount;
@ -76,6 +78,11 @@ public class SkillPackageArchiveExtractor {
return stripSingleRootDirectory(entries);
}
public ExtractionResult extractWithWarnings(MultipartFile file) throws IOException {
List<PackageEntry> entries = extract(file);
return promoteSingleSkillMdDirectory(entries);
}
/**
* If all file paths share a single root directory prefix (e.g., "my-skill/xxx"),
* strip that prefix. Otherwise return entries unchanged.
@ -107,6 +114,51 @@ public class SkillPackageArchiveExtractor {
.toList();
}
static ExtractionResult promoteSingleSkillMdDirectory(List<PackageEntry> entries) {
boolean hasRootSkillMd = entries.stream()
.anyMatch(e -> SkillPackagePolicy.SKILL_MD_PATH.equals(e.path()));
if (hasRootSkillMd) {
return new ExtractionResult(entries, List.of());
}
Set<String> skillMdDirs = new HashSet<>();
for (PackageEntry entry : entries) {
int slashIndex = entry.path().indexOf('/');
if (slashIndex > 0) {
String relativePath = entry.path().substring(slashIndex + 1);
if (SkillPackagePolicy.SKILL_MD_PATH.equals(relativePath)) {
skillMdDirs.add(entry.path().substring(0, slashIndex));
}
}
}
if (skillMdDirs.isEmpty()) {
return new ExtractionResult(entries, List.of());
}
if (skillMdDirs.size() > 1) {
throw new IllegalArgumentException(
"Ambiguous package: SKILL.md found in multiple directories: " + skillMdDirs);
}
String prefix = skillMdDirs.iterator().next() + "/";
List<PackageEntry> promoted = new ArrayList<>();
List<String> warnings = new ArrayList<>();
for (PackageEntry entry : entries) {
if (entry.path().startsWith(prefix)) {
promoted.add(new PackageEntry(
entry.path().substring(prefix.length()),
entry.content(),
entry.size(),
entry.contentType()));
} else {
warnings.add("Ignored file outside skill directory: " + entry.path());
}
}
return new ExtractionResult(promoted, warnings);
}
private byte[] readEntry(ZipInputStream zis, String path) throws IOException {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
byte[] buffer = new byte[8192];

View file

@ -126,6 +126,67 @@ class SkillPackageArchiveExtractorTest {
assertTrue(entries.stream().anyMatch(e -> e.path().equals("dir-b/other.md")));
}
@Test
void promotesSkillMdFromSubdirectoryAndDiscardsRootFiles() throws Exception {
byte[] zipBytes = createZip(Map.of(
"my-skill/SKILL.md", "---\nname: test\n---\n".getBytes(),
"my-skill/README.md", "# readme".getBytes(),
"other.txt", "stray file".getBytes()
));
MockMultipartFile file = new MockMultipartFile("file", "test.zip", "application/zip", zipBytes);
SkillPackageArchiveExtractor.ExtractionResult result = extractor.extractWithWarnings(file);
assertEquals(2, result.entries().size());
assertTrue(result.entries().stream().anyMatch(e -> e.path().equals("SKILL.md")));
assertTrue(result.entries().stream().anyMatch(e -> e.path().equals("README.md")));
assertTrue(result.warnings().stream().anyMatch(w -> w.contains("other.txt")));
}
@Test
void rejectsAmbiguousMultipleSkillMdInSubdirectories() throws Exception {
byte[] zipBytes = createZip(Map.of(
"dir1/SKILL.md", "---\nname: a\n---\n".getBytes(),
"dir2/SKILL.md", "---\nname: b\n---\n".getBytes()
));
MockMultipartFile file = new MockMultipartFile("file", "test.zip", "application/zip", zipBytes);
IllegalArgumentException error = assertThrows(IllegalArgumentException.class,
() -> extractor.extractWithWarnings(file));
assertTrue(error.getMessage().contains("Ambiguous"));
}
@Test
void noPromotionWhenSkillMdAtRoot() throws Exception {
byte[] zipBytes = createZip(Map.of(
"SKILL.md", "---\nname: test\n---\n".getBytes(),
"sub/file.txt", "content".getBytes()
));
MockMultipartFile file = new MockMultipartFile("file", "test.zip", "application/zip", zipBytes);
SkillPackageArchiveExtractor.ExtractionResult result = extractor.extractWithWarnings(file);
assertEquals(2, result.entries().size());
assertTrue(result.warnings().isEmpty());
}
@Test
void promotesSubdirectoryPreservingNestedPaths() throws Exception {
byte[] zipBytes = createZip(Map.of(
"my-skill/SKILL.md", "---\nname: test\n---\n".getBytes(),
"my-skill/sub/deep.md", "nested".getBytes(),
"stray.txt", "ignored".getBytes()
));
MockMultipartFile file = new MockMultipartFile("file", "test.zip", "application/zip", zipBytes);
SkillPackageArchiveExtractor.ExtractionResult result = extractor.extractWithWarnings(file);
assertEquals(2, result.entries().size());
assertTrue(result.entries().stream().anyMatch(e -> e.path().equals("SKILL.md")));
assertTrue(result.entries().stream().anyMatch(e -> e.path().equals("sub/deep.md")));
assertTrue(result.warnings().stream().anyMatch(w -> w.contains("stray.txt")));
}
private byte[] createZip(String entryName, String content) throws Exception {
return createZip(entryName, content.getBytes(StandardCharsets.UTF_8));
}