diff --git a/mateclaw-server/src/main/java/vip/mate/skill/workspace/BundledSkillSyncer.java b/mateclaw-server/src/main/java/vip/mate/skill/workspace/BundledSkillSyncer.java
index 1e28c896..10e4d326 100644
--- a/mateclaw-server/src/main/java/vip/mate/skill/workspace/BundledSkillSyncer.java
+++ b/mateclaw-server/src/main/java/vip/mate/skill/workspace/BundledSkillSyncer.java
@@ -7,6 +7,9 @@ import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.core.io.support.ResourcePatternResolver;
import org.springframework.stereotype.Component;
+import vip.mate.skill.model.SkillEntity;
+import vip.mate.skill.service.SkillFileService;
+import vip.mate.skill.service.SkillService;
import vip.mate.skill.workspace.bundle.ClasspathBundleSource;
import vip.mate.skill.workspace.bundle.MaterializeOptions;
import vip.mate.skill.workspace.bundle.SkillBundleMaterializer;
@@ -18,12 +21,14 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
+import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
- * Discovers {@code classpath:skills/*/SKILL.md} at startup and copies
+ * Discovers {@code classpath:skills/*\/SKILL.md} at startup and copies
* each into the user's skill workspace, upgrading on version bumps.
*
*
Split out of {@link SkillWorkspaceManager} so the workspace service
@@ -52,6 +57,8 @@ public class BundledSkillSyncer {
private final SkillWorkspaceManager workspaceManager;
private final SkillBundleMaterializer bundleMaterializer;
private final ApplicationEventPublisher eventPublisher;
+ private final SkillService skillService;
+ private final SkillFileService skillFileService;
/**
* Run a full sync pass. Idempotent — safe to call from both startup
@@ -94,35 +101,69 @@ public class BundledSkillSyncer {
Path targetDir = workspaceManager.resolveConventionPath(skillName);
boolean firstInstall = !Files.exists(targetDir);
+ SkillBundleSource source = new ClasspathBundleSource(resolver,
+ bundledPath + "/" + skillName);
+ Map bundleFiles = loadBundleFilesFromSource(source);
+
+ SkillEntity skill = skillService.findByName(skillName);
+ if (skill != null && skill.getId() != null && !bundleFiles.isEmpty()) {
+ skillFileService.applyBundleFiles(skill.getId(), bundleFiles, false);
+ }
+
if (!firstInstall) {
String bundledVersion = readVersion(manifest);
String workspaceVersion = readVersion(targetDir.resolve("SKILL.md"));
- if (bundledVersion == null || !isNewerVersion(bundledVersion, workspaceVersion)) {
+ boolean missingScriptsOnDisk = hasScriptsInBundle(bundleFiles) && !Files.exists(targetDir.resolve("scripts"));
+
+ if (!missingScriptsOnDisk && (bundledVersion == null || !isNewerVersion(bundledVersion, workspaceVersion))) {
log.debug("Bundled skill '{}' workspace is current (bundled={}, workspace={}), skipping",
skillName, bundledVersion, workspaceVersion);
return false;
}
- log.info("Bundled skill '{}' version {} > workspace version {}, upgrading",
- skillName, bundledVersion, workspaceVersion);
- workspaceManager.archiveWorkspace(skillName);
+ if (!missingScriptsOnDisk) {
+ log.info("Bundled skill '{}' version {} > workspace version {}, upgrading",
+ skillName, bundledVersion, workspaceVersion);
+ workspaceManager.archiveWorkspace(skillName);
+ } else {
+ log.info("Bundled skill '{}' is missing scripts directory on disk, force copying bundle", skillName);
+ }
}
- copyBundle(resolver, bundledPath, skillName, targetDir);
+ copyBundle(source, targetDir);
eventPublisher.publishEvent(
new SkillWorkspaceEvent(skillName, SkillWorkspaceEvent.Type.CREATED, targetDir));
log.info("{} bundled skill '{}' → {}", firstInstall ? "Synced" : "Upgraded", skillName, targetDir);
return true;
}
- private void copyBundle(ResourcePatternResolver resolver, String bundledPath,
- String skillName, Path targetDir) {
- SkillBundleSource source = new ClasspathBundleSource(resolver,
- bundledPath + "/" + skillName);
+ private Map loadBundleFilesFromSource(SkillBundleSource source) {
+ Map files = new LinkedHashMap<>();
+ try {
+ for (SkillBundleSource.BundleAsset asset : source.assets()) {
+ String path = asset.relativePath();
+ if (path.startsWith("scripts/") || path.startsWith("references/")) {
+ try (InputStream is = asset.open().get()) {
+ String content = new String(is.readAllBytes(), StandardCharsets.UTF_8);
+ files.put(path, content);
+ }
+ }
+ }
+ } catch (Exception e) {
+ log.warn("Failed to load bundle assets from {}: {}", source.origin(), e.getMessage());
+ }
+ return files;
+ }
+
+ private boolean hasScriptsInBundle(Map bundleFiles) {
+ return bundleFiles.keySet().stream().anyMatch(p -> p.startsWith("scripts/"));
+ }
+
+ private void copyBundle(SkillBundleSource source, Path targetDir) {
try {
bundleMaterializer.materialize(source, targetDir, MaterializeOptions.verbatim());
} catch (IOException e) {
- log.warn("Failed to copy bundled skill '{}' from {}: {}",
- skillName, source.origin(), e.getMessage());
+ log.warn("Failed to copy bundled skill from {}: {}",
+ source.origin(), e.getMessage());
}
}
diff --git a/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillFileSyncer.java b/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillFileSyncer.java
index 9acafb7a..78ad5387 100644
--- a/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillFileSyncer.java
+++ b/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillFileSyncer.java
@@ -99,6 +99,9 @@ public class SkillFileSyncer {
int backfilled = 0;
if (dbFiles.isEmpty()) {
backfilled = backfillFromDiskIfNeeded(skill, workspaceDir);
+ if (backfilled == 0 && Boolean.TRUE.equals(skill.getBuiltin())) {
+ backfilled = backfillFromClasspathIfNeeded(skill);
+ }
if (backfilled > 0) {
didBackfill = true;
dbFiles = skillFileService.listBySkillId(skill.getId());
@@ -120,6 +123,38 @@ public class SkillFileSyncer {
return new PerSkillReport(materialized, alreadyCurrent, backfilled, didBackfill);
}
+ /**
+ * Backfills builtin skill bundle files from classpath when both DB and disk are empty.
+ */
+ private int backfillFromClasspathIfNeeded(SkillEntity skill) {
+ org.springframework.core.io.support.ResourcePatternResolver resolver =
+ new org.springframework.core.io.support.PathMatchingResourcePatternResolver();
+ String bundledPath = "skills/" + skill.getName();
+ vip.mate.skill.workspace.bundle.SkillBundleSource source =
+ new vip.mate.skill.workspace.bundle.ClasspathBundleSource(resolver, bundledPath);
+
+ java.util.Map ingested = new java.util.LinkedHashMap<>();
+ try {
+ for (vip.mate.skill.workspace.bundle.SkillBundleSource.BundleAsset asset : source.assets()) {
+ String relative = asset.relativePath();
+ if (relative.startsWith("scripts/") || relative.startsWith("references/")) {
+ try (java.io.InputStream is = asset.open().get()) {
+ ingested.put(relative, new String(is.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8));
+ }
+ }
+ }
+ } catch (Exception e) {
+ log.warn("Failed to backfill builtin skill '{}' from classpath: {}", skill.getName(), e.getMessage());
+ return 0;
+ }
+
+ if (ingested.isEmpty()) return 0;
+ skillFileService.applyBundleFiles(skill.getId(), ingested, false);
+ log.info("Backfilled {} bundle file(s) from classpath into mate_skill_file for builtin skill '{}' (id={})",
+ ingested.size(), skill.getName(), skill.getId());
+ return ingested.size();
+ }
+
private enum MaterializeOutcome { WROTE, CURRENT, SKIPPED }
private MaterializeOutcome materializeOne(Path workspaceDir, SkillFileEntity row) {
diff --git a/mateclaw-server/src/test/java/vip/mate/skill/workspace/BundledSkillSyncerTest.java b/mateclaw-server/src/test/java/vip/mate/skill/workspace/BundledSkillSyncerTest.java
new file mode 100644
index 00000000..c4e46771
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/skill/workspace/BundledSkillSyncerTest.java
@@ -0,0 +1,92 @@
+package vip.mate.skill.workspace;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.springframework.context.ApplicationEventPublisher;
+import vip.mate.skill.model.SkillEntity;
+import vip.mate.skill.service.SkillFileService;
+import vip.mate.skill.service.SkillService;
+import vip.mate.skill.workspace.bundle.SkillBundleMaterializer;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * Tests for {@link BundledSkillSyncer} ensuring bundled skill scripts are synced to DB and workspace disk.
+ */
+class BundledSkillSyncerTest {
+
+ @TempDir
+ Path tmp;
+
+ private SkillWorkspaceProperties properties;
+ private SkillWorkspaceManager workspaceManager;
+ private SkillBundleMaterializer bundleMaterializer;
+ private SkillService skillService;
+ private SkillFileService skillFileService;
+ private BundledSkillSyncer syncer;
+
+ @BeforeEach
+ void setUp() {
+ properties = new SkillWorkspaceProperties();
+ properties.setRoot(tmp.toString());
+ properties.setBundledSkillsPath("skills");
+
+ workspaceManager = new SkillWorkspaceManager(properties, mock(ApplicationEventPublisher.class));
+ bundleMaterializer = new SkillBundleMaterializer();
+ skillService = mock(SkillService.class);
+ skillFileService = mock(SkillFileService.class);
+
+ syncer = new BundledSkillSyncer(
+ properties,
+ workspaceManager,
+ bundleMaterializer,
+ mock(ApplicationEventPublisher.class),
+ skillService,
+ skillFileService
+ );
+ }
+
+ @Test
+ @DisplayName("Sync applies classpath bundle files to DB for existing skill entities")
+ void syncAppliesBundleFilesToDb() {
+ SkillEntity pptxSkill = new SkillEntity();
+ pptxSkill.setId(100L);
+ pptxSkill.setName("pptx");
+
+ when(skillService.findByName("pptx")).thenReturn(pptxSkill);
+
+ List synced = syncer.sync();
+
+ assertTrue(synced.contains("pptx"));
+ verify(skillFileService, atLeastOnce()).applyBundleFiles(eq(100L), anyMap(), eq(false));
+ }
+
+ @Test
+ @DisplayName("Sync forces copy to disk when scripts directory is missing despite unchanged version")
+ void syncForcesDiskCopyWhenScriptsDirMissing() throws IOException {
+ Path pptxDir = tmp.resolve("pptx");
+ Files.createDirectories(pptxDir);
+ // Create SKILL.md with current version but omit scripts directory
+ Files.writeString(pptxDir.resolve("SKILL.md"), "---\nname: pptx\nversion: 1.0.0\n---\n");
+
+ SkillEntity pptxSkill = new SkillEntity();
+ pptxSkill.setId(100L);
+ pptxSkill.setName("pptx");
+ when(skillService.findByName("pptx")).thenReturn(pptxSkill);
+
+ List synced = syncer.sync();
+
+ assertTrue(synced.contains("pptx"), "Should force re-sync when scripts directory is missing");
+ assertTrue(Files.exists(pptxDir.resolve("scripts")), "scripts directory should now be copied to disk");
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/skill/workspace/SkillFileSyncerTest.java b/mateclaw-server/src/test/java/vip/mate/skill/workspace/SkillFileSyncerTest.java
index 50e44f8a..c219a6da 100644
--- a/mateclaw-server/src/test/java/vip/mate/skill/workspace/SkillFileSyncerTest.java
+++ b/mateclaw-server/src/test/java/vip/mate/skill/workspace/SkillFileSyncerTest.java
@@ -129,6 +129,24 @@ class SkillFileSyncerTest {
verify(mapper, times(2)).insert(any(SkillFileEntity.class));
}
+ @Test
+ @DisplayName("DB and FS empty, builtin skill: backfills from classpath into DB")
+ void backfillsFromClasspathWhenBuiltinAndDbFsEmpty() {
+ SkillEntity skill = newSkill(10L, "pdf");
+ skill.setBuiltin(true);
+ when(skillService.listSkills()).thenReturn(List.of(skill));
+
+ List after = List.of(
+ newRow(1L, 10L, "scripts/pdftotext.py", "pdf script")
+ );
+ when(mapper.selectList(any())).thenReturn(List.of(), List.of(), after);
+
+ var report = syncer.syncAll();
+
+ assertEquals(1, report.skillsBackfilled());
+ verify(mapper, atLeastOnce()).insert(any(SkillFileEntity.class));
+ }
+
private static SkillEntity newSkill(Long id, String name) {
SkillEntity s = new SkillEntity();
s.setId(id);