feat(skill): bundle file management — view and edit scripts, references, and templates from the console

The skill detail drawer could only show and edit SKILL.md; the bundle
files under scripts/ and references/ had no console surface, and
templates/ was readable by agents but absent from the canonical
store's bucket set.

- admin endpoints on /api/v1/skills/{id}/files: list (self-heals an
  empty canonical store from on-disk files), read, upsert, delete.
  Writes update the canonical row, materialize the workspace cache,
  and re-resolve the skill so agents pick changes up immediately.
  Path envelope enforces the three buckets and blocks traversal;
  builtin skill files stay read-only; virtual skills own no files.
- templates/ becomes a first-class DB-persisted bucket shared across
  the syncer, the workspace write/delete envelope, and prune guards.
- the agent-facing write_file action now mirrors into the canonical
  store and re-resolves instead of writing only the local filesystem.
- SkillMarket detail drawer gains a Files tab: grouped list, viewer,
  inline editor, create and delete, refetched on every entry.
This commit is contained in:
matevip 2026-07-22 15:39:59 +08:00
parent aa0e249ce4
commit 4ac72a90a0
16 changed files with 809 additions and 34 deletions

View File

@ -1010,7 +1010,12 @@ public class ChannelMessageRouter {
case STATUS -> buildStatusReply(channelEntity, conversationId); case STATUS -> buildStatusReply(channelEntity, conversationId);
}; };
if (replyTarget != null && reply != null) { if (replyTarget != null && reply != null) {
adapter.sendMessage(replyTarget, reply); // renderAndSend (not sendMessage) so adapters that pre-post a
// "thinking..." placeholder on inbound (WeCom reply_stream)
// consume it here: the confirmation overwrites the placeholder
// bubble in place and the keepalive refresher is stopped.
// Plain sendMessage would leave the placeholder dangling forever.
adapter.renderAndSend(replyTarget, reply);
} }
log.info("[{}] Magic command handled: {} conversationId={}, sender={}", log.info("[{}] Magic command handled: {} conversationId={}, sender={}",
adapter.getChannelType(), command.type(), conversationId, adapter.getChannelType(), command.type(), conversationId,

View File

@ -20,6 +20,8 @@ import vip.mate.skill.runtime.SkillDependencyChecker;
import vip.mate.skill.runtime.SkillPackageResolver; import vip.mate.skill.runtime.SkillPackageResolver;
import vip.mate.skill.runtime.SkillCatalogSort; import vip.mate.skill.runtime.SkillCatalogSort;
import vip.mate.skill.runtime.SkillCatalogSorter; import vip.mate.skill.runtime.SkillCatalogSorter;
import vip.mate.skill.model.SkillFileEntity;
import vip.mate.skill.service.SkillFileService;
import vip.mate.skill.service.SkillService; import vip.mate.skill.service.SkillService;
import vip.mate.skill.synthesis.SkillSynthesisService; import vip.mate.skill.synthesis.SkillSynthesisService;
import vip.mate.skill.runtime.SkillRuntimeService; import vip.mate.skill.runtime.SkillRuntimeService;
@ -27,6 +29,7 @@ import vip.mate.skill.runtime.model.ResolvedSkill;
import vip.mate.skill.workspace.BundledSkillSyncer; import vip.mate.skill.workspace.BundledSkillSyncer;
import vip.mate.skill.workspace.SkillFileSyncer; import vip.mate.skill.workspace.SkillFileSyncer;
import vip.mate.skill.workspace.SkillWorkspaceManager; import vip.mate.skill.workspace.SkillWorkspaceManager;
import vip.mate.skill.workspace.bundle.SkillBundleFiles;
import vip.mate.exception.MateClawException; import vip.mate.exception.MateClawException;
import vip.mate.skill.lifecycle.ConfirmRequiredException; import vip.mate.skill.lifecycle.ConfirmRequiredException;
import vip.mate.skill.lifecycle.LifecycleTransition; import vip.mate.skill.lifecycle.LifecycleTransition;
@ -73,6 +76,7 @@ public class SkillController {
private final SkillLifecycleService skillLifecycleService; private final SkillLifecycleService skillLifecycleService;
private final SkillCuratorJob skillCuratorJob; private final SkillCuratorJob skillCuratorJob;
private final SkillCuratorReportStore skillCuratorReportStore; private final SkillCuratorReportStore skillCuratorReportStore;
private final SkillFileService skillFileService;
@Operation(summary = "获取技能分页列表RFC-042 §2.1") @Operation(summary = "获取技能分页列表RFC-042 §2.1")
@GetMapping @GetMapping
@ -336,6 +340,157 @@ public class SkillController {
return R.ok(body); return R.ok(body);
} }
// ==================== Bundle files (scripts/ + references/) ====================
/** Per-file content ceiling for the admin editor — matches the installer's per-file bound. */
private static final int MAX_BUNDLE_FILE_CHARS = 1_000_000;
@Operation(summary = "List a skill's bundle files (scripts/ + references/), without content")
@GetMapping("/{id}/files")
@RequireWorkspaceRole("member")
public R<List<Map<String, Object>>> listBundleFiles(@PathVariable Long id,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
// Virtual MCP/ACP skills mirror live servers and own no bundle files.
if (vip.mate.skill.mcp.McpSkillBridge.isVirtualMcpSkillId(id)
|| vip.mate.skill.acp.AcpSkillBridge.isVirtualAcpSkillId(id)) {
return R.ok(List.of());
}
SkillEntity skill = skillService.getSkill(id);
verifyResourceWorkspace(skill, workspaceId);
List<SkillFileEntity> rows = skillFileService.listBySkillId(id);
if (rows.isEmpty()) {
// Self-heal: ingest pre-canonical on-disk files into the DB store
// so legacy directory-based skills list their files too.
skillFileSyncer.syncOne(skill);
rows = skillFileService.listBySkillId(id);
}
List<Map<String, Object>> out = new ArrayList<>(rows.size());
rows.stream()
.sorted(java.util.Comparator.comparing(SkillFileEntity::getFilePath,
java.util.Comparator.nullsLast(String::compareTo)))
.forEach(row -> {
Map<String, Object> item = new LinkedHashMap<>();
item.put("path", row.getFilePath());
item.put("size", row.getContentSize());
item.put("sha256", row.getSha256());
item.put("updateTime", row.getUpdateTime());
out.add(item);
});
return R.ok(out);
}
@Operation(summary = "Read one bundle file's content")
@GetMapping("/{id}/files/content")
@RequireWorkspaceRole("member")
public R<Map<String, Object>> getBundleFileContent(@PathVariable Long id,
@RequestParam String path,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
SkillEntity skill = skillService.getSkill(id);
verifyResourceWorkspace(skill, workspaceId);
String normalized = normalizeBundlePath(path);
if (normalized == null) {
return R.fail("Invalid file path: " + path);
}
SkillFileEntity row = skillFileService.getFile(id, normalized);
if (row == null) {
return R.fail("File not found: " + normalized);
}
Map<String, Object> body = new LinkedHashMap<>();
body.put("path", row.getFilePath());
body.put("content", row.getContent() == null ? "" : row.getContent());
body.put("size", row.getContentSize());
body.put("sha256", row.getSha256());
body.put("updateTime", row.getUpdateTime());
return R.ok(body);
}
@Operation(summary = "Create or update one bundle file",
description = "Writes the canonical mate_skill_file row, materializes the workspace cache, " +
"and re-resolves the skill so the runtime file tree updates immediately.")
@PutMapping("/{id}/files/content")
@RequireWorkspaceRole("admin")
public R<Map<String, Object>> putBundleFileContent(@PathVariable Long id,
@RequestBody Map<String, String> body,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
rejectVirtualSkillMutation(id);
SkillEntity skill = skillService.getSkill(id);
verifyResourceWorkspace(skill, workspaceId);
if (Boolean.TRUE.equals(skill.getBuiltin())) {
return R.fail("Builtin skill files are read-only — they are restored from the shipped bundle on upgrade.");
}
String normalized = normalizeBundlePath(body.get("path"));
if (normalized == null) {
return R.fail("Invalid file path — must be under scripts/, references/ or templates/, no '..'.");
}
String content = body.get("content");
if (content == null) {
return R.fail("content is required (use the delete endpoint to remove a file).");
}
if (content.length() > MAX_BUNDLE_FILE_CHARS) {
return R.fail("Content too large (" + content.length() + " chars, max " + MAX_BUNDLE_FILE_CHARS + ").");
}
SkillFileEntity row = skillFileService.upsertFile(id, normalized, content);
try {
workspaceManager.writeWorkspaceFile(skill.getName(), normalized, content);
} catch (Exception e) {
// Canonical store is updated; the syncer heals the cache later.
}
skillRuntimeService.rescanSingle(skill);
Map<String, Object> out = new LinkedHashMap<>();
out.put("path", row.getFilePath());
out.put("size", row.getContentSize());
out.put("sha256", row.getSha256());
out.put("updateTime", row.getUpdateTime());
return R.ok(out);
}
@Operation(summary = "Delete one bundle file")
@DeleteMapping("/{id}/files")
@RequireWorkspaceRole("admin")
public R<Map<String, Object>> deleteBundleFile(@PathVariable Long id,
@RequestParam String path,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
rejectVirtualSkillMutation(id);
SkillEntity skill = skillService.getSkill(id);
verifyResourceWorkspace(skill, workspaceId);
if (Boolean.TRUE.equals(skill.getBuiltin())) {
return R.fail("Builtin skill files are read-only — they are restored from the shipped bundle on upgrade.");
}
String normalized = normalizeBundlePath(path);
if (normalized == null) {
return R.fail("Invalid file path: " + path);
}
boolean removed = skillFileService.deleteFile(id, normalized);
try {
workspaceManager.deleteWorkspaceFile(skill.getName(), normalized);
} catch (Exception e) {
// Cache cleanup is best-effort; the canonical row is gone.
}
skillRuntimeService.rescanSingle(skill);
return R.ok(Map.of("path", normalized, "removed", removed));
}
/**
* Normalize a bundle-relative path and enforce the same envelope the
* store and workspace cache use: forward slashes, must sit under a
* DB-persisted bucket ({@code scripts/}, {@code references/} or
* {@code templates/}), no traversal, no absolute paths, and a
* non-empty file name.
*
* @return normalized path, or {@code null} when rejected
*/
static String normalizeBundlePath(String path) {
if (path == null || path.isBlank()) return null;
String p = path.strip().replace('\\', '/');
if (p.startsWith("/") || p.contains("..") || p.contains("//") || p.endsWith("/")) return null;
if (!SkillBundleFiles.isDbEligible(p)) return null;
int slash = p.indexOf('/');
if (slash == p.length() - 1) return null;
return p;
}
/** /**
* Mutation paths refuse virtual MCP/ACP skill ids upfront. The bridge * Mutation paths refuse virtual MCP/ACP skill ids upfront. The bridge
* synthesizes those rows on the fly from the upstream connection * synthesizes those rows on the fly from the upstream connection

View File

@ -80,13 +80,16 @@ public class SkillFileService {
Map<String, String> incoming = newFiles == null ? Map.of() : newFiles; Map<String, String> incoming = newFiles == null ? Map.of() : newFiles;
boolean newHasScripts = bucketHasEntries(incoming, "scripts/"); boolean newHasScripts = bucketHasEntries(incoming, "scripts/");
boolean newHasRefs = bucketHasEntries(incoming, "references/"); boolean newHasRefs = bucketHasEntries(incoming, "references/");
boolean newHasTemplates = bucketHasEntries(incoming, "templates/");
List<SkillFileEntity> existing = listBySkillId(skillId); List<SkillFileEntity> existing = listBySkillId(skillId);
boolean existingHasScripts = existing.stream().anyMatch(e -> e.getFilePath() != null && e.getFilePath().startsWith("scripts/")); boolean existingHasScripts = existing.stream().anyMatch(e -> e.getFilePath() != null && e.getFilePath().startsWith("scripts/"));
boolean existingHasRefs = existing.stream().anyMatch(e -> e.getFilePath() != null && e.getFilePath().startsWith("references/")); boolean existingHasRefs = existing.stream().anyMatch(e -> e.getFilePath() != null && e.getFilePath().startsWith("references/"));
boolean existingHasTemplates = existing.stream().anyMatch(e -> e.getFilePath() != null && e.getFilePath().startsWith("templates/"));
boolean preserveScripts = !newHasScripts && existingHasScripts && !force; boolean preserveScripts = !newHasScripts && existingHasScripts && !force;
boolean preserveRefs = !newHasRefs && existingHasRefs && !force; boolean preserveRefs = !newHasRefs && existingHasRefs && !force;
boolean preserveTemplates = !newHasTemplates && existingHasTemplates && !force;
Map<String, SkillFileEntity> existingByPath = new HashMap<>(); Map<String, SkillFileEntity> existingByPath = new HashMap<>();
for (SkillFileEntity e : existing) existingByPath.put(e.getFilePath(), e); for (SkillFileEntity e : existing) existingByPath.put(e.getFilePath(), e);
@ -106,6 +109,13 @@ public class SkillFileService {
} }
} }
} }
if (preserveTemplates) {
for (SkillFileEntity e : existing) {
if (e.getFilePath() != null && e.getFilePath().startsWith("templates/")) {
keepPaths.add(e.getFilePath());
}
}
}
keepPaths.addAll(incoming.keySet()); keepPaths.addAll(incoming.keySet());
int written = 0; int written = 0;
@ -152,6 +162,9 @@ public class SkillFileService {
if (preserveRefs) { if (preserveRefs) {
log.warn("Refused to prune references/ for skill_id={} — new bundle is empty. Pass force=true to override.", skillId); log.warn("Refused to prune references/ for skill_id={} — new bundle is empty. Pass force=true to override.", skillId);
} }
if (preserveTemplates) {
log.warn("Refused to prune templates/ for skill_id={} — new bundle is empty. Pass force=true to override.", skillId);
}
return new ApplyResult(written, pruned, preserveScripts, preserveRefs); return new ApplyResult(written, pruned, preserveScripts, preserveRefs);
} }
@ -163,6 +176,61 @@ public class SkillFileService {
return mapper.deleteBySkillId(skillId); return mapper.deleteBySkillId(skillId);
} }
/** One file row by exact path, or {@code null}. */
public SkillFileEntity getFile(Long skillId, String filePath) {
if (skillId == null || filePath == null || filePath.isBlank()) return null;
QueryWrapper<SkillFileEntity> q = new QueryWrapper<>();
q.eq("skill_id", skillId).eq("file_path", filePath);
return mapper.selectOne(q);
}
/**
* Create or update a single file row. Content hash and size are
* recomputed; a same-hash write is a no-op so idempotent saves don't
* churn {@code update_time}.
*
* @return the persisted row (existing row instance on no-op)
*/
@Transactional
public SkillFileEntity upsertFile(Long skillId, String filePath, String content) {
String safeContent = content == null ? "" : content;
String hash = sha256Hex(safeContent);
LocalDateTime now = LocalDateTime.now();
SkillFileEntity existing = getFile(skillId, filePath);
if (existing != null) {
if (hash.equals(existing.getSha256())) {
return existing;
}
existing.setContent(safeContent);
existing.setContentSize(safeContent.getBytes(StandardCharsets.UTF_8).length);
existing.setSha256(hash);
existing.setUpdateTime(now);
mapper.updateById(existing);
return existing;
}
SkillFileEntity row = new SkillFileEntity();
row.setSkillId(skillId);
row.setFilePath(filePath);
row.setContent(safeContent);
row.setContentSize(safeContent.getBytes(StandardCharsets.UTF_8).length);
row.setSha256(hash);
row.setCreateTime(now);
row.setUpdateTime(now);
mapper.insert(row);
return row;
}
/** Delete a single file row. Returns {@code true} when a row was removed. */
@Transactional
public boolean deleteFile(Long skillId, String filePath) {
SkillFileEntity existing = getFile(skillId, filePath);
if (existing == null) return false;
mapper.deleteById(existing.getId());
return true;
}
private boolean bucketHasEntries(Map<String, String> files, String prefix) { private boolean bucketHasEntries(Map<String, String> files, String prefix) {
for (String key : files.keySet()) { for (String key : files.keySet()) {
if (key != null && key.startsWith(prefix)) return true; if (key != null && key.startsWith(prefix)) return true;

View File

@ -27,8 +27,8 @@ import java.util.Set;
/** /**
* Mirrors canonical {@code mate_skill_file} rows down to each node's local * Mirrors canonical {@code mate_skill_file} rows down to each node's local
* workspace cache so {@code scripts/} and {@code references/} files exist * workspace cache so {@code scripts/}, {@code references/} and
* on disk wherever the skill might run. * {@code templates/} files exist on disk wherever the skill might run.
* *
* <p>Also runs a one-time backfill: for any skill that has on-disk files * <p>Also runs a one-time backfill: for any skill that has on-disk files
* but no DB rows (typically pre-V112 installs), the local files are read * but no DB rows (typically pre-V112 installs), the local files are read
@ -166,9 +166,9 @@ public class SkillFileSyncer {
private MaterializeOutcome materializeOne(Path workspaceDir, SkillFileEntity row) { private MaterializeOutcome materializeOne(Path workspaceDir, SkillFileEntity row) {
String relative = row.getFilePath(); String relative = row.getFilePath();
if (relative == null || relative.isBlank()) return MaterializeOutcome.SKIPPED; if (relative == null || relative.isBlank()) return MaterializeOutcome.SKIPPED;
if (!relative.startsWith("references/") && !relative.startsWith("scripts/")) { if (!SkillBundleFiles.isDbEligible(relative)) {
log.warn("Skipping skill_file row {} — path outside scripts/ or references/: {}", log.warn("Skipping skill_file row {} — path outside the DB-persisted buckets ({}): {}",
row.getId(), relative); row.getId(), SkillBundleFiles.DB_BUCKET_PREFIXES, relative);
return MaterializeOutcome.SKIPPED; return MaterializeOutcome.SKIPPED;
} }
if (relative.contains("..")) { if (relative.contains("..")) {
@ -208,11 +208,11 @@ public class SkillFileSyncer {
private int backfillFromDiskIfNeeded(SkillEntity skill, Path workspaceDir) { private int backfillFromDiskIfNeeded(SkillEntity skill, Path workspaceDir) {
if (!Files.exists(workspaceDir) || !Files.isDirectory(workspaceDir)) return 0; if (!Files.exists(workspaceDir) || !Files.isDirectory(workspaceDir)) return 0;
List<Path> roots = new ArrayList<>(2); List<Path> roots = new ArrayList<>(SkillBundleFiles.DB_BUCKET_PREFIXES.size());
Path scripts = workspaceDir.resolve("scripts"); for (String prefix : SkillBundleFiles.DB_BUCKET_PREFIXES) {
Path references = workspaceDir.resolve("references"); Path root = workspaceDir.resolve(prefix.substring(0, prefix.length() - 1));
if (Files.isDirectory(scripts)) roots.add(scripts); if (Files.isDirectory(root)) roots.add(root);
if (Files.isDirectory(references)) roots.add(references); }
if (roots.isEmpty()) return 0; if (roots.isEmpty()) return 0;
Map<String, String> ingested = new LinkedHashMap<>(); Map<String, String> ingested = new LinkedHashMap<>();

View File

@ -356,6 +356,40 @@ public class SkillWorkspaceManager {
} }
} }
/**
* skill 工作区删除单个文件 {@link #writeWorkspaceFile} 相同的
* 路径安全约束文件不存在时为 no-op顺带清理删空的父目录
* 避免目录树里残留空壳
*
* @throws IllegalArgumentException 如果路径不安全
*/
public void deleteWorkspaceFile(String skillName, String relativePath) {
Path workspaceDir = resolveConventionPath(skillName);
Path safePath = validateWritePath(workspaceDir, relativePath);
if (safePath == null) {
throw new IllegalArgumentException("Unsafe file path rejected: " + relativePath);
}
try {
Files.deleteIfExists(safePath);
// Prune emptied parent dirs up to (not including) the bucket root.
Path parent = safePath.getParent();
Path scriptsRoot = workspaceDir.resolve("scripts");
Path referencesRoot = workspaceDir.resolve("references");
Path templatesRoot = workspaceDir.resolve("templates");
while (parent != null && !parent.equals(scriptsRoot) && !parent.equals(referencesRoot)
&& !parent.equals(templatesRoot)
&& parent.startsWith(workspaceDir) && !parent.equals(workspaceDir)) {
try (var children = Files.list(parent)) {
if (children.findAny().isPresent()) break;
}
Files.delete(parent);
parent = parent.getParent();
}
} catch (IOException e) {
log.warn("Failed to delete workspace file {}/{}: {}", skillName, relativePath, e.getMessage());
}
}
/** /**
* 清空 skill 工作区中的 references/ scripts/ 目录内容保留目录本身 * 清空 skill 工作区中的 references/ scripts/ 目录内容保留目录本身
* 用于 overwrite 安装前清除旧版本残留文件 * 用于 overwrite 安装前清除旧版本残留文件
@ -531,8 +565,9 @@ public class SkillWorkspaceManager {
// 归一化分隔符 // 归一化分隔符
String normalized = relativePath.replace("\\", "/"); String normalized = relativePath.replace("\\", "/");
// 必须以 references/ scripts/ 开头 // 必须以 references/scripts/ templates/ 开头
if (!normalized.startsWith("references/") && !normalized.startsWith("scripts/")) { if (!normalized.startsWith("references/") && !normalized.startsWith("scripts/")
&& !normalized.startsWith("templates/")) {
return null; return null;
} }

View File

@ -9,16 +9,17 @@ import java.util.Map;
/** /**
* Helpers for the bundle file buckets that mirror into the canonical * Helpers for the bundle file buckets that mirror into the canonical
* {@code mate_skill_file} store ({@code scripts/} and {@code references/}). * {@code mate_skill_file} store ({@code scripts/}, {@code references/}
* and {@code templates/}).
* *
* <p>Shared by the bundled-skill startup sync and the skill file syncer's * <p>Shared by the bundled-skill startup sync, the skill file syncer's
* classpath backfill so both agree on which paths are DB-persisted and how * classpath backfill, and the admin file editor so all agree on which
* bundle contents are read into memory. * paths are DB-persisted and how bundle contents are read into memory.
*/ */
public final class SkillBundleFiles { public final class SkillBundleFiles {
/** Path prefixes of the buckets persisted to {@code mate_skill_file}. */ /** Path prefixes of the buckets persisted to {@code mate_skill_file}. */
public static final List<String> DB_BUCKET_PREFIXES = List.of("scripts/", "references/"); public static final List<String> DB_BUCKET_PREFIXES = List.of("scripts/", "references/", "templates/");
private SkillBundleFiles() { private SkillBundleFiles() {
} }
@ -33,7 +34,7 @@ public final class SkillBundleFiles {
} }
/** /**
* Read every {@code scripts/} and {@code references/} file of the bundle * Read every DB-eligible bundle file ({@link #DB_BUCKET_PREFIXES})
* into memory, keyed by workspace-relative path (the key shape * into memory, keyed by workspace-relative path (the key shape
* {@code SkillFileService#applyBundleFiles} expects). Iteration order * {@code SkillFileService#applyBundleFiles} expects). Iteration order
* follows {@link SkillBundleSource#assets()} enumeration order. * follows {@link SkillBundleSource#assets()} enumeration order.

View File

@ -13,6 +13,7 @@ import vip.mate.skill.model.SkillEntity;
import vip.mate.skill.runtime.SkillRuntimeService; import vip.mate.skill.runtime.SkillRuntimeService;
import vip.mate.skill.runtime.SkillSecurityService; import vip.mate.skill.runtime.SkillSecurityService;
import vip.mate.skill.runtime.SkillValidationResult; import vip.mate.skill.runtime.SkillValidationResult;
import vip.mate.skill.service.SkillFileService;
import vip.mate.skill.service.SkillService; import vip.mate.skill.service.SkillService;
import vip.mate.skill.workspace.SkillWorkspaceManager; import vip.mate.skill.workspace.SkillWorkspaceManager;
@ -39,6 +40,7 @@ import java.util.regex.Pattern;
public class SkillManageTool { public class SkillManageTool {
private final SkillService skillService; private final SkillService skillService;
private final SkillFileService skillFileService;
private final SkillSecurityService securityService; private final SkillSecurityService securityService;
private final SkillWorkspaceManager workspaceManager; private final SkillWorkspaceManager workspaceManager;
private final SkillRuntimeService runtimeService; private final SkillRuntimeService runtimeService;
@ -82,10 +84,10 @@ public class SkillManageTool {
- create: Create a new skill with SKILL.md content (YAML frontmatter + markdown body) - create: Create a new skill with SKILL.md content (YAML frontmatter + markdown body)
- edit: Replace entire skill content (for major rewrites; preferred when changing version + body together) - edit: Replace entire skill content (for major rewrites; preferred when changing version + body together)
- patch: Find-and-replace a specific section (for small targeted fixes) - patch: Find-and-replace a specific section (for small targeted fixes)
- write_file: Write a supporting file under the skill's references/ or scripts/ directory - write_file: Write a supporting file under the skill's references/, scripts/ or
(e.g. a long reference doc the SKILL.md links to, or a re-runnable script). Put the templates/ directory (e.g. a long reference doc the SKILL.md links to, a re-runnable
file body in 'content' and the path in 'filePath'. Keep SKILL.md itself lean and move script, or an output template). Put the file body in 'content' and the path in
bulky detail into references/. 'filePath'. Keep SKILL.md itself lean and move bulky detail into references/.
- delete: Remove a skill - delete: Remove a skill
SKILL.md format example: SKILL.md format example:
@ -130,7 +132,7 @@ public class SkillManageTool {
String newText, String newText,
@JsonProperty @JsonProperty
@JsonPropertyDescription("For write_file action: relative path under references/ or scripts/ (e.g. 'references/api.md', 'scripts/run.sh'). No '..' allowed.") @JsonPropertyDescription("For write_file action: relative path under references/, scripts/ or templates/ (e.g. 'references/api.md', 'scripts/run.sh', 'templates/report.html'). No '..' allowed.")
String filePath, String filePath,
// RFC-063r §2.5: carries the calling agent's ChatOrigin; hidden // RFC-063r §2.5: carries the calling agent's ChatOrigin; hidden
@ -359,7 +361,7 @@ public class SkillManageTool {
*/ */
private String doWriteFile(String name, String filePath, String content) { private String doWriteFile(String name, String filePath, String content) {
if (filePath == null || filePath.isBlank()) { if (filePath == null || filePath.isBlank()) {
return "Error: filePath is required for write_file (e.g. 'references/api.md' or 'scripts/run.sh')."; return "Error: filePath is required for write_file (e.g. 'references/api.md', 'scripts/run.sh' or 'templates/report.html').";
} }
if (content == null) { if (content == null) {
return "Error: content is required for write_file action."; return "Error: content is required for write_file action.";
@ -386,12 +388,24 @@ public class SkillManageTool {
workspaceManager.writeWorkspaceFile(name, filePath, content); workspaceManager.writeWorkspaceFile(name, filePath, content);
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
return "Error: " + e.getMessage() return "Error: " + e.getMessage()
+ " (paths must start with references/ or scripts/, and may not contain '..')."; + " (paths must start with references/, scripts/ or templates/, and may not contain '..').";
} catch (Exception e) { } catch (Exception e) {
log.error("[SkillManage] Failed to write file '{}' for skill '{}': {}", filePath, name, e.getMessage(), e); log.error("[SkillManage] Failed to write file '{}' for skill '{}': {}", filePath, name, e.getMessage(), e);
return "Error writing skill file: " + e.getMessage(); return "Error writing skill file: " + e.getMessage();
} }
// Mirror into the canonical mate_skill_file store so the file
// survives node changes and is visible to DB-reading consumers
// (admin file editor, multi-instance workspace sync).
try {
skillFileService.upsertFile(existing.getId(), filePath.replace('\\', '/'), content);
} catch (Exception e) {
log.warn("[SkillManage] Canonical store write failed for '{}' of skill '{}': {}",
filePath, name, e.getMessage());
}
rescanQuietly(existing);
log.info("[SkillManage] Agent wrote skill file: skill={}, path={}", name, filePath); log.info("[SkillManage] Agent wrote skill file: skill={}, path={}", name, filePath);
return "File '" + filePath + "' written to skill '" + name + "' (security scan: PASSED)."; return "File '" + filePath + "' written to skill '" + name + "' (security scan: PASSED).";
} }

View File

@ -0,0 +1,187 @@
package vip.mate.skill.controller;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import vip.mate.common.result.R;
import vip.mate.skill.mcp.McpSkillBridge;
import vip.mate.skill.model.SkillEntity;
import vip.mate.skill.model.SkillFileEntity;
import vip.mate.skill.runtime.SkillRuntimeService;
import vip.mate.skill.service.SkillFileService;
import vip.mate.skill.service.SkillService;
import vip.mate.skill.workspace.SkillFileSyncer;
import vip.mate.skill.workspace.SkillWorkspaceManager;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Bundle-file admin endpoints: list / read / write / delete with the
* path envelope and builtin/virtual guards.
*/
class SkillControllerBundleFilesTest {
private SkillService skillService;
private SkillRuntimeService runtimeService;
private SkillWorkspaceManager workspaceManager;
private SkillFileSyncer fileSyncer;
private SkillFileService fileService;
private SkillController controller;
private static final long SID = 1_900_000_001_000_000_902L;
@BeforeEach
void setUp() {
skillService = mock(SkillService.class);
runtimeService = mock(SkillRuntimeService.class);
workspaceManager = mock(SkillWorkspaceManager.class);
fileSyncer = mock(SkillFileSyncer.class);
fileService = mock(SkillFileService.class);
controller = new SkillController(
skillService, runtimeService, null, workspaceManager, null, fileSyncer,
null, null, null, null, null, null, null, null, null, null, null,
fileService);
}
private SkillEntity skill(boolean builtin) {
SkillEntity s = new SkillEntity();
s.setId(SID);
s.setName("demo-skill");
s.setBuiltin(builtin);
return s;
}
private SkillFileEntity row(String path, String content) {
SkillFileEntity e = new SkillFileEntity();
e.setId(1L);
e.setSkillId(SID);
e.setFilePath(path);
e.setContent(content);
e.setContentSize(content.getBytes().length);
e.setSha256("h");
return e;
}
// ==================== path envelope ====================
@Test
@DisplayName("normalizeBundlePath accepts the three buckets and rejects escapes")
void pathEnvelope() {
assertThat(SkillController.normalizeBundlePath("scripts/run.py")).isEqualTo("scripts/run.py");
assertThat(SkillController.normalizeBundlePath("references/a/b.md")).isEqualTo("references/a/b.md");
assertThat(SkillController.normalizeBundlePath("templates/report.html")).isEqualTo("templates/report.html");
assertThat(SkillController.normalizeBundlePath("templates\\r.html")).isEqualTo("templates/r.html");
assertThat(SkillController.normalizeBundlePath(null)).isNull();
assertThat(SkillController.normalizeBundlePath(" ")).isNull();
assertThat(SkillController.normalizeBundlePath("SKILL.md")).isNull();
assertThat(SkillController.normalizeBundlePath("scripts/../etc/passwd")).isNull();
assertThat(SkillController.normalizeBundlePath("/scripts/run.py")).isNull();
assertThat(SkillController.normalizeBundlePath("scripts/")).isNull();
assertThat(SkillController.normalizeBundlePath("scripts//x.py")).isNull();
assertThat(SkillController.normalizeBundlePath("outputs/x.txt")).isNull();
}
// ==================== list ====================
@Test
@DisplayName("list returns rows without content and self-heals an empty store")
void listSelfHeals() {
when(skillService.getSkill(SID)).thenReturn(skill(false));
when(fileService.listBySkillId(SID))
.thenReturn(List.of())
.thenReturn(List.of(row("scripts/run.py", "print()")));
R<List<Map<String, Object>>> resp = controller.listBundleFiles(SID, null);
verify(fileSyncer).syncOne(any(SkillEntity.class));
assertThat(resp.getData()).hasSize(1);
assertThat(resp.getData().get(0)).containsEntry("path", "scripts/run.py");
assertThat(resp.getData().get(0)).doesNotContainKey("content");
}
@Test
@DisplayName("list on a virtual MCP skill id returns empty without a DB lookup")
void listVirtualIsEmpty() {
R<List<Map<String, Object>>> resp =
controller.listBundleFiles(McpSkillBridge.VIRTUAL_ID_BASE + 7L, null);
assertThat(resp.getData()).isEmpty();
verify(skillService, never()).getSkill(any());
}
// ==================== write ====================
@Test
@DisplayName("put writes the canonical row, materializes the cache, and rescans")
void putHappyPath() {
when(skillService.getSkill(SID)).thenReturn(skill(false));
when(fileService.upsertFile(SID, "templates/report.html", "<html/>"))
.thenReturn(row("templates/report.html", "<html/>"));
R<Map<String, Object>> resp = controller.putBundleFileContent(SID,
Map.of("path", "templates/report.html", "content", "<html/>"), null);
assertThat(resp.getData()).containsEntry("path", "templates/report.html");
verify(fileService).upsertFile(SID, "templates/report.html", "<html/>");
verify(workspaceManager).writeWorkspaceFile("demo-skill", "templates/report.html", "<html/>");
verify(runtimeService).rescanSingle(any(SkillEntity.class));
}
@Test
@DisplayName("put on a builtin skill is refused")
void putBuiltinRefused() {
when(skillService.getSkill(SID)).thenReturn(skill(true));
R<Map<String, Object>> resp = controller.putBundleFileContent(SID,
Map.of("path", "scripts/x.py", "content", "x"), null);
assertThat(resp.getMsg()).contains("read-only");
verify(fileService, never()).upsertFile(any(), any(), any());
}
@Test
@DisplayName("put with an out-of-bucket path is refused")
void putBadPathRefused() {
when(skillService.getSkill(SID)).thenReturn(skill(false));
R<Map<String, Object>> resp = controller.putBundleFileContent(SID,
Map.of("path", "secrets/creds.txt", "content", "x"), null);
assertThat(resp.getMsg()).contains("Invalid file path");
verify(fileService, never()).upsertFile(any(), any(), any());
}
// ==================== delete ====================
@Test
@DisplayName("delete removes the row and the workspace cache file")
void deleteHappyPath() {
when(skillService.getSkill(SID)).thenReturn(skill(false));
when(fileService.deleteFile(SID, "scripts/run.py")).thenReturn(true);
R<Map<String, Object>> resp = controller.deleteBundleFile(SID, "scripts/run.py", null);
assertThat(resp.getData()).containsEntry("removed", true);
verify(workspaceManager).deleteWorkspaceFile("demo-skill", "scripts/run.py");
verify(runtimeService).rescanSingle(any(SkillEntity.class));
}
@Test
@DisplayName("delete on a builtin skill is refused")
void deleteBuiltinRefused() {
when(skillService.getSkill(SID)).thenReturn(skill(true));
R<Map<String, Object>> resp = controller.deleteBundleFile(SID, "scripts/run.py", null);
assertThat(resp.getMsg()).contains("read-only");
verify(fileService, never()).deleteFile(any(), any());
}
}

View File

@ -58,7 +58,7 @@ class SkillControllerLifecycleTest {
controller = new SkillController( controller = new SkillController(
skillService, null, null, null, null, null, null, null, null, null, null, skillService, null, null, null, null, null, null, null, null, null, null,
agentBindingService, null, null, agentBindingService, null, null,
skillLifecycleService, skillCuratorJob, skillCuratorReportStore); skillLifecycleService, skillCuratorJob, skillCuratorReportStore, null);
} }
private SkillEntity skill(String state, boolean builtin) { private SkillEntity skill(String state, boolean builtin) {

View File

@ -55,7 +55,8 @@ class SkillControllerListEnabledTest {
acpSkillBridge, acpSkillBridge,
/* skillLifecycleService */ null, /* skillLifecycleService */ null,
/* skillCuratorJob */ null, /* skillCuratorJob */ null,
/* skillCuratorReportStore */ null); /* skillCuratorReportStore */ null,
/* skillFileService */ null);
// listSkills() supplies realSkillNames() for shadow base default // listSkills() supplies realSkillNames() for shadow base default
// to empty so each test can override. // to empty so each test can override.
when(skillService.listSkills(null)).thenReturn(List.of()); when(skillService.listSkills(null)).thenReturn(List.of());

View File

@ -26,7 +26,7 @@ class SkillControllerVirtualGuardTest {
private final SkillController controller = new SkillController( private final SkillController controller = new SkillController(
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
null, null, null, null); null, null, null, null, null);
@Test @Test
@DisplayName("update on a virtual MCP skill id is rejected before hitting the service") @DisplayName("update on a virtual MCP skill id is rejected before hitting the service")
@ -65,7 +65,7 @@ class SkillControllerVirtualGuardTest {
McpSkillBridge bridge = mock(McpSkillBridge.class); McpSkillBridge bridge = mock(McpSkillBridge.class);
SkillController c = new SkillController( SkillController c = new SkillController(
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
bridge, null, null, null, null); bridge, null, null, null, null, null);
long virtualMcpId = McpSkillBridge.VIRTUAL_ID_BASE + 42L; long virtualMcpId = McpSkillBridge.VIRTUAL_ID_BASE + 42L;
SkillEntity toggled = new SkillEntity(); SkillEntity toggled = new SkillEntity();
toggled.setName("github"); toggled.setName("github");
@ -98,7 +98,7 @@ class SkillControllerVirtualGuardTest {
SkillController real = new SkillController( SkillController real = new SkillController(
mock(vip.mate.skill.service.SkillService.class), mock(vip.mate.skill.service.SkillService.class),
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
null, null, null, null); null, null, null, null, null);
long snowflakeId = 1_900_000_001_000_000_902L; long snowflakeId = 1_900_000_001_000_000_902L;
// updateSkill on a mocked SkillService returns null without throwing, // updateSkill on a mocked SkillService returns null without throwing,
// which is fine we just need to confirm the guard didn't fire. // which is fine we just need to confirm the guard didn't fire.

View File

@ -7,6 +7,7 @@ import vip.mate.skill.model.SkillEntity;
import vip.mate.skill.runtime.SkillRuntimeService; import vip.mate.skill.runtime.SkillRuntimeService;
import vip.mate.skill.runtime.SkillSecurityService; import vip.mate.skill.runtime.SkillSecurityService;
import vip.mate.skill.runtime.SkillValidationResult; import vip.mate.skill.runtime.SkillValidationResult;
import vip.mate.skill.service.SkillFileService;
import vip.mate.skill.service.SkillService; import vip.mate.skill.service.SkillService;
import vip.mate.skill.workspace.SkillWorkspaceManager; import vip.mate.skill.workspace.SkillWorkspaceManager;
@ -30,6 +31,7 @@ import static org.mockito.Mockito.when;
class SkillManageToolWriteFileTest { class SkillManageToolWriteFileTest {
private SkillService skillService; private SkillService skillService;
private SkillFileService skillFileService;
private SkillSecurityService securityService; private SkillSecurityService securityService;
private SkillWorkspaceManager workspaceManager; private SkillWorkspaceManager workspaceManager;
private SkillManageTool tool; private SkillManageTool tool;
@ -37,14 +39,16 @@ class SkillManageToolWriteFileTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
skillService = mock(SkillService.class); skillService = mock(SkillService.class);
skillFileService = mock(SkillFileService.class);
securityService = mock(SkillSecurityService.class); securityService = mock(SkillSecurityService.class);
workspaceManager = mock(SkillWorkspaceManager.class); workspaceManager = mock(SkillWorkspaceManager.class);
SkillRuntimeService runtimeService = mock(SkillRuntimeService.class); SkillRuntimeService runtimeService = mock(SkillRuntimeService.class);
tool = new SkillManageTool(skillService, securityService, workspaceManager, runtimeService); tool = new SkillManageTool(skillService, skillFileService, securityService, workspaceManager, runtimeService);
} }
private SkillEntity skill(String name, boolean builtin) { private SkillEntity skill(String name, boolean builtin) {
SkillEntity s = new SkillEntity(); SkillEntity s = new SkillEntity();
s.setId(42L);
s.setName(name); s.setName(name);
s.setBuiltin(builtin); s.setBuiltin(builtin);
s.setSkillContent("---\nname: " + name + "\n---\n# x"); s.setSkillContent("---\nname: " + name + "\n---\n# x");
@ -69,6 +73,22 @@ class SkillManageToolWriteFileTest {
assertTrue(result.startsWith("File 'scripts/run.sh' written"), result); assertTrue(result.startsWith("File 'scripts/run.sh' written"), result);
verify(workspaceManager, times(1)).writeWorkspaceFile("my-skill", "scripts/run.sh", "echo hi"); verify(workspaceManager, times(1)).writeWorkspaceFile("my-skill", "scripts/run.sh", "echo hi");
// The canonical store row must be written too, not just the FS cache.
verify(skillFileService, times(1)).upsertFile(42L, "scripts/run.sh", "echo hi");
}
@Test
@DisplayName("write_file accepts templates/ paths")
void writesTemplateFile() {
when(skillService.findByName("my-skill")).thenReturn(skill("my-skill", false));
scanPasses();
String result = tool.skill_manage("write_file", "my-skill", "<html></html>",
null, null, "templates/report.html", null);
assertTrue(result.startsWith("File 'templates/report.html' written"), result);
verify(workspaceManager, times(1)).writeWorkspaceFile("my-skill", "templates/report.html", "<html></html>");
verify(skillFileService, times(1)).upsertFile(42L, "templates/report.html", "<html></html>");
} }
@Test @Test

View File

@ -262,6 +262,18 @@ export const skillApi = {
refreshRuntime: () => http.post('/skills/runtime/refresh'), refreshRuntime: () => http.post('/skills/runtime/refresh'),
exportWorkspace: (id: string | number) => http.post(`/skills/${id}/export-workspace`), exportWorkspace: (id: string | number) => http.post(`/skills/${id}/export-workspace`),
getWorkspaceInfo: (id: string | number) => http.get(`/skills/${id}/workspace`), getWorkspaceInfo: (id: string | number) => http.get(`/skills/${id}/workspace`),
/**
* Bundle files (scripts/ + references/ + templates/) canonical rows in
* mate_skill_file; writes also materialize the workspace cache and
* re-resolve the skill.
*/
listFiles: (id: string | number) => http.get(`/skills/${id}/files`),
getFileContent: (id: string | number, path: string) =>
http.get(`/skills/${id}/files/content`, { params: { path } }),
saveFileContent: (id: string | number, path: string, content: string) =>
http.put(`/skills/${id}/files/content`, { path, content }),
deleteFile: (id: string | number, path: string) =>
http.delete(`/skills/${id}/files`, { params: { path } }),
// RFC-090 §7 + §11.4 — pre-flight requirements + LESSONS.md + reverse lookup // RFC-090 §7 + §11.4 — pre-flight requirements + LESSONS.md + reverse lookup
requirements: (id: string | number) => http.get(`/skills/${id}/requirements`), requirements: (id: string | number) => http.get(`/skills/${id}/requirements`),
getLessons: (id: string | number) => http.get(`/skills/${id}/lessons`), getLessons: (id: string | number) => http.get(`/skills/${id}/lessons`),

View File

@ -3874,6 +3874,7 @@ export default {
title: 'Skill detail', title: 'Skill detail',
overview: 'Overview', overview: 'Overview',
body: 'Body', body: 'Body',
files: 'Files',
manifest: 'Manifest', manifest: 'Manifest',
tools: 'Tools', tools: 'Tools',
features: 'Features', features: 'Features',
@ -3933,6 +3934,20 @@ export default {
secretDeleteSuccess: 'Secret deleted', secretDeleteSuccess: 'Secret deleted',
secretDeleteFailed: 'Failed to delete', secretDeleteFailed: 'Failed to delete',
secretLoadFailed: 'Failed to load secrets', secretLoadFailed: 'Failed to load secrets',
filesTitle: 'Bundle files',
filesHint: 'Files under scripts/, references/ and templates/. The database is the single source of truth; saves sync every node\'s workspace and take effect for agents immediately.',
filesEmpty: 'This skill has no supporting files yet.',
fileNew: 'New file',
fileEdit: 'Edit',
fileDelete: 'Delete',
fileDeleteConfirm: 'Delete "{path}"? This removes both the database record and the workspace file. Cannot be undone.',
fileSaved: 'File saved',
fileDeleted: 'File deleted',
fileLoadFailed: 'Failed to read file',
fileSaveFailed: 'Save failed',
filePathPlaceholder: 'Path, e.g. references/api.md, scripts/run.py, templates/report.html',
fileContentPlaceholder: 'File content…',
builtinFilesReadonly: 'Built-in skill files are restored from the shipped bundle on upgrade — read-only here.',
}, },
runtime: { runtime: {
disabled: 'Disabled', disabled: 'Disabled',

View File

@ -3966,6 +3966,7 @@ export default {
title: '技能详情', title: '技能详情',
overview: '概览', overview: '概览',
body: '正文', body: '正文',
files: '文件',
manifest: 'Manifest', manifest: 'Manifest',
tools: '工具', tools: '工具',
features: '特性', features: '特性',
@ -4025,6 +4026,20 @@ export default {
secretDeleteSuccess: '密钥已删除', secretDeleteSuccess: '密钥已删除',
secretDeleteFailed: '删除失败', secretDeleteFailed: '删除失败',
secretLoadFailed: '加载密钥列表失败', secretLoadFailed: '加载密钥列表失败',
filesTitle: '技能文件',
filesHint: '技能包内的 scripts/、references/、templates/ 文件。数据库为唯一事实源,保存后立即同步各节点工作区并对 agent 生效。',
filesEmpty: '该技能还没有任何附属文件。',
fileNew: '新建文件',
fileEdit: '编辑',
fileDelete: '删除',
fileDeleteConfirm: '删除文件 "{path}"?此操作同时移除数据库记录和工作区文件,不可撤销。',
fileSaved: '文件已保存',
fileDeleted: '文件已删除',
fileLoadFailed: '读取文件失败',
fileSaveFailed: '保存失败',
filePathPlaceholder: '路径,如 references/api.md、scripts/run.py、templates/report.html',
fileContentPlaceholder: '文件内容…',
builtinFilesReadonly: '内置技能的文件随发行包升级恢复,此处只读。',
}, },
runtime: { runtime: {
disabled: '已停用', disabled: '已停用',

View File

@ -271,6 +271,10 @@
<button class="detail-tab" :class="{ active: detailTab === 'body' }" @click="detailTab = 'body'"> <button class="detail-tab" :class="{ active: detailTab === 'body' }" @click="detailTab = 'body'">
{{ t('skills.detail.body') }} {{ t('skills.detail.body') }}
</button> </button>
<button v-if="!isVirtualSkill" class="detail-tab" :class="{ active: detailTab === 'files' }" @click="detailTab = 'files'">
{{ t('skills.detail.files') }}
<span v-if="detailFiles.length > 0" class="tab-count">{{ detailFiles.length }}</span>
</button>
<button class="detail-tab" :class="{ active: detailTab === 'tools' }" @click="detailTab = 'tools'"> <button class="detail-tab" :class="{ active: detailTab === 'tools' }" @click="detailTab = 'tools'">
{{ t('skills.detail.tools') }} {{ t('skills.detail.tools') }}
<span v-if="detailToolsCount > 0" class="tab-count">{{ detailToolsCount }}</span> <span v-if="detailToolsCount > 0" class="tab-count">{{ detailToolsCount }}</span>
@ -468,6 +472,98 @@
</template> </template>
</div> </div>
</div> </div>
<!-- Files tab bundle files (scripts/ + references/ + templates/) -->
<div v-if="detailTab === 'files'" class="detail-section">
<div class="detail-block">
<div class="detail-block-head">
<h4 class="detail-block-title">{{ t('skills.detail.filesTitle') }}</h4>
<button
v-if="canEditFiles && !creatingFile"
class="detail-edit-btn detail-edit-primary"
@click="startCreateFile"
>
+ {{ t('skills.detail.fileNew') }}
</button>
</div>
<p class="detail-hint">{{ t('skills.detail.filesHint') }}</p>
<p v-if="isBuiltinDetail" class="detail-hint">{{ t('skills.detail.builtinFilesReadonly') }}</p>
<!-- New-file form -->
<div v-if="creatingFile" class="skill-file-create">
<input
v-model.trim="newFileForm.path"
type="text"
class="skill-file-path-input"
:placeholder="t('skills.detail.filePathPlaceholder')"
spellcheck="false"
/>
<textarea
v-model="newFileForm.content"
class="skill-file-editor"
:placeholder="t('skills.detail.fileContentPlaceholder')"
spellcheck="false"
></textarea>
<div class="edit-actions">
<button class="detail-edit-btn detail-edit-cancel" @click="creatingFile = false" :disabled="savingFile">
{{ t('skills.actions.cancel') }}
</button>
<button class="detail-edit-btn detail-edit-save" @click="saveNewFile" :disabled="savingFile || !newFileForm.path">
{{ savingFile ? t('common.loading') : t('skills.actions.save') }}
</button>
</div>
</div>
<p v-if="detailFilesLoading" class="detail-empty">{{ t('common.loading') }}</p>
<p v-else-if="detailFiles.length === 0 && !creatingFile" class="detail-empty">
{{ t('skills.detail.filesEmpty') }}
</p>
<ul v-else class="skill-file-list">
<li v-for="f in detailFiles" :key="f.path">
<button
class="skill-file-item"
:class="{ active: activeFile?.path === f.path }"
@click="openFile(f)"
>
<code class="skill-file-path">{{ f.path }}</code>
<span class="skill-file-size">{{ formatFileSize(f.size) }}</span>
</button>
</li>
</ul>
<!-- Viewer / editor for the selected file -->
<template v-if="activeFile && !creatingFile">
<div class="detail-block-head detail-subhead">
<h4 class="detail-block-title"><code>{{ activeFile.path }}</code></h4>
<div class="edit-actions">
<template v-if="!editingFile">
<button v-if="canEditFiles" class="detail-edit-btn detail-edit-primary" @click="startEditFile">
{{ t('skills.detail.fileEdit') }}
</button>
<button v-if="canEditFiles" class="detail-edit-btn detail-edit-cancel" @click="removeActiveFile" :disabled="savingFile">
{{ t('skills.detail.fileDelete') }}
</button>
</template>
<template v-else>
<button class="detail-edit-btn detail-edit-cancel" @click="editingFile = false" :disabled="savingFile">
{{ t('skills.actions.cancel') }}
</button>
<button class="detail-edit-btn detail-edit-save" @click="saveActiveFile" :disabled="savingFile">
{{ savingFile ? t('common.loading') : t('skills.actions.save') }}
</button>
</template>
</div>
</div>
<p v-if="fileContentLoading" class="detail-empty">{{ t('common.loading') }}</p>
<pre v-else-if="!editingFile" class="detail-pre">{{ activeFileContent }}</pre>
<textarea
v-else
v-model="fileEditContent"
class="skill-file-editor"
spellcheck="false"
></textarea>
</template>
</div>
</div>
<!-- Tools tab --> <!-- Tools tab -->
<div v-if="detailTab === 'tools'" class="detail-section"> <div v-if="detailTab === 'tools'" class="detail-section">
<p v-if="detailToolsCount === 0" class="detail-empty">{{ t('skills.detail.noTools') }}</p> <p v-if="detailToolsCount === 0" class="detail-empty">{{ t('skills.detail.noTools') }}</p>
@ -763,7 +859,7 @@ const rescanning = ref<Record<string, boolean>>({})
* tab for back-compat with any deep links that may pass it. */ * tab for back-compat with any deep links that may pass it. */
const detailDrawerVisible = ref(false) const detailDrawerVisible = ref(false)
const detailSkill = ref<Skill | null>(null) const detailSkill = ref<Skill | null>(null)
const detailTab = ref<'overview' | 'body' | 'manifest' | 'tools' | 'features' | 'security' | 'lessons' | 'secrets' | 'memory'>('overview') const detailTab = ref<'overview' | 'body' | 'files' | 'manifest' | 'tools' | 'features' | 'security' | 'lessons' | 'secrets' | 'memory'>('overview')
const detailLessonsRaw = ref<string>('') const detailLessonsRaw = ref<string>('')
const detailLessonsLoading = ref(false) const detailLessonsLoading = ref(false)
const detailEmployees = ref<Array<{ id: number; name: string; icon?: string; binding?: 'explicit' | 'implicit' }>>([]) const detailEmployees = ref<Array<{ id: number; name: string; icon?: string; binding?: 'explicit' | 'implicit' }>>([])
@ -811,6 +907,142 @@ const editBodyForm = ref<{ skillContent: string; sourceCode: string }>({
sourceCode: '', sourceCode: '',
}) })
// ==================== Bundle files tab ====================
interface SkillBundleFile {
path: string
size?: number
sha256?: string
updateTime?: string
}
const detailFiles = ref<SkillBundleFile[]>([])
const detailFilesLoading = ref(false)
const activeFile = ref<SkillBundleFile | null>(null)
const activeFileContent = ref('')
const fileContentLoading = ref(false)
const editingFile = ref(false)
const fileEditContent = ref('')
const savingFile = ref(false)
const creatingFile = ref(false)
const newFileForm = ref<{ path: string; content: string }>({ path: '', content: '' })
/** Builtin bundles are restored from the shipped jar on upgrade, and
* virtual skills own no files both stay view-only. */
const canEditFiles = computed(() => !isVirtualSkill.value && !isBuiltinDetail.value)
function resetFilesTab() {
detailFiles.value = []
activeFile.value = null
activeFileContent.value = ''
editingFile.value = false
creatingFile.value = false
newFileForm.value = { path: '', content: '' }
}
async function loadDetailFiles() {
if (!detailSkill.value || isVirtualSkill.value) return
detailFilesLoading.value = true
try {
const res: any = await skillApi.listFiles(detailSkill.value.id)
detailFiles.value = res?.data || []
} catch {
detailFiles.value = []
} finally {
detailFilesLoading.value = false
}
}
async function openFile(file: SkillBundleFile) {
if (!detailSkill.value) return
activeFile.value = file
editingFile.value = false
fileContentLoading.value = true
try {
const res: any = await skillApi.getFileContent(detailSkill.value.id, file.path)
activeFileContent.value = res?.data?.content ?? ''
} catch (e: any) {
activeFileContent.value = ''
mcToast.error(typeof e === 'string' ? e : e?.message || t('skills.detail.fileLoadFailed'))
} finally {
fileContentLoading.value = false
}
}
function startEditFile() {
fileEditContent.value = activeFileContent.value
editingFile.value = true
}
async function saveActiveFile() {
if (!detailSkill.value || !activeFile.value || savingFile.value) return
savingFile.value = true
try {
await skillApi.saveFileContent(detailSkill.value.id, activeFile.value.path, fileEditContent.value)
activeFileContent.value = fileEditContent.value
editingFile.value = false
mcToast.success(t('skills.detail.fileSaved'))
loadDetailFiles()
} catch (e: any) {
mcToast.error(typeof e === 'string' ? e : e?.message || t('skills.detail.fileSaveFailed'))
} finally {
savingFile.value = false
}
}
async function removeActiveFile() {
if (!detailSkill.value || !activeFile.value || savingFile.value) return
const ok = await mcConfirm({
title: t('skills.detail.fileDelete'),
message: t('skills.detail.fileDeleteConfirm', { path: activeFile.value.path }),
tone: 'danger',
})
if (!ok) return
savingFile.value = true
try {
await skillApi.deleteFile(detailSkill.value.id, activeFile.value.path)
activeFile.value = null
activeFileContent.value = ''
mcToast.success(t('skills.detail.fileDeleted'))
loadDetailFiles()
} catch (e: any) {
mcToast.error(typeof e === 'string' ? e : e?.message || t('skills.detail.fileSaveFailed'))
} finally {
savingFile.value = false
}
}
function startCreateFile() {
creatingFile.value = true
activeFile.value = null
activeFileContent.value = ''
editingFile.value = false
newFileForm.value = { path: 'references/', content: '' }
}
async function saveNewFile() {
if (!detailSkill.value || savingFile.value || !newFileForm.value.path) return
savingFile.value = true
try {
await skillApi.saveFileContent(detailSkill.value.id, newFileForm.value.path, newFileForm.value.content)
creatingFile.value = false
mcToast.success(t('skills.detail.fileSaved'))
await loadDetailFiles()
const created = detailFiles.value.find(f => f.path === newFileForm.value.path)
if (created) openFile(created)
} catch (e: any) {
mcToast.error(typeof e === 'string' ? e : e?.message || t('skills.detail.fileSaveFailed'))
} finally {
savingFile.value = false
}
}
function formatFileSize(size?: number): string {
if (size == null) return ''
if (size < 1024) return `${size} B`
return `${(size / 1024).toFixed(1)} KB`
}
/** New-skill modal pared down to the two questions that *must* be answered /** New-skill modal pared down to the two questions that *must* be answered
* at creation time. Everything else is filled in via the drawer. */ * at creation time. Everything else is filled in via the drawer. */
const newForm = ref<{ name: string; description: string; icon: string }>({ name: '', description: '', icon: '' }) const newForm = ref<{ name: string; description: string; icon: string }>({ name: '', description: '', icon: '' })
@ -884,7 +1116,7 @@ const detailFeaturesCount = computed(() => detailFeatures.value.length)
function openDetailDrawer( function openDetailDrawer(
skill: Skill, skill: Skill,
tab: 'overview' | 'body' | 'tools' | 'features' | 'security' | 'lessons' | 'secrets' | 'memory' = 'overview', tab: 'overview' | 'body' | 'files' | 'tools' | 'features' | 'security' | 'lessons' | 'secrets' | 'memory' = 'overview',
opts: { editIdentity?: boolean; editBody?: boolean } = {}, opts: { editIdentity?: boolean; editBody?: boolean } = {},
) { ) {
detailSkill.value = skill detailSkill.value = skill
@ -893,6 +1125,7 @@ function openDetailDrawer(
detailEmployees.value = [] detailEmployees.value = []
editingIdentity.value = false editingIdentity.value = false
editingBody.value = false editingBody.value = false
resetFilesTab()
detailDrawerVisible.value = true detailDrawerVisible.value = true
// The list rows are a snapshot from page load; the skill may have been // The list rows are a snapshot from page load; the skill may have been
// modified since (e.g. by an agent in a chat session). Refetch the row // modified since (e.g. by an agent in a chat session). Refetch the row
@ -979,6 +1212,11 @@ watch(detailTab, (tab) => {
if (tab === 'memory' && detailDrawerVisible.value && detailEmployees.value.length === 0 && !detailEmployeesLoading.value) { if (tab === 'memory' && detailDrawerVisible.value && detailEmployees.value.length === 0 && !detailEmployeesLoading.value) {
loadDetailEmployees() loadDetailEmployees()
} }
// Refetch on every entry files can change out-of-band (agent tools,
// other admins), and the list is cheap (no content payload).
if (tab === 'files' && detailDrawerVisible.value && !detailFilesLoading.value) {
loadDetailFiles()
}
}) })
const categoryTabs = computed(() => [ const categoryTabs = computed(() => [
@ -2110,6 +2348,15 @@ html.dark .scan-finding-item { background: rgba(255, 255, 255, 0.05); }
.detail-empty { color: var(--mc-text-tertiary); font-size: 13px; font-style: italic; } .detail-empty { color: var(--mc-text-tertiary); font-size: 13px; font-style: italic; }
.detail-pre { background: var(--mc-bg-sunken); padding: 12px; border-radius: 8px; max-height: 480px; overflow: auto; font-size: 12px; line-height: 1.5; color: var(--mc-text-primary); font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; white-space: pre-wrap; word-break: break-word; } .detail-pre { background: var(--mc-bg-sunken); padding: 12px; border-radius: 8px; max-height: 480px; overflow: auto; font-size: 12px; line-height: 1.5; color: var(--mc-text-primary); font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; white-space: pre-wrap; word-break: break-word; }
.detail-tool-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 6px; } .detail-tool-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 6px; }
.skill-file-list { list-style: none; padding: 0; margin: 12px 0 0; display: flex; flex-direction: column; gap: 4px; }
.skill-file-item { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; padding: 6px 10px; background: var(--mc-bg-sunken); border: 1px solid transparent; border-radius: 8px; cursor: pointer; text-align: left; }
.skill-file-item:hover { border-color: var(--mc-border-strong, var(--mc-border)); }
.skill-file-item.active { border-color: var(--mc-accent, var(--mc-text-primary)); }
.skill-file-path { font-size: 12px; color: var(--mc-text-primary); word-break: break-all; }
.skill-file-size { flex-shrink: 0; font-size: 11px; color: var(--mc-text-tertiary); }
.skill-file-editor { width: 100%; min-height: 220px; margin-top: 8px; padding: 12px; background: var(--mc-bg-sunken); border: 1px solid var(--mc-border); border-radius: 8px; font-size: 12px; line-height: 1.5; color: var(--mc-text-primary); font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; resize: vertical; }
.skill-file-create { margin-top: 12px; display: flex; flex-direction: column; gap: 8px; }
.skill-file-path-input { width: 100%; padding: 8px 10px; background: var(--mc-bg-sunken); border: 1px solid var(--mc-border); border-radius: 8px; font-size: 12px; color: var(--mc-text-primary); font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; }
.detail-tool-item code { display: block; padding: 6px 10px; background: var(--mc-bg-sunken); border-radius: 8px; font-size: 12px; color: var(--mc-text-primary); } .detail-tool-item code { display: block; padding: 6px 10px; background: var(--mc-bg-sunken); border-radius: 8px; font-size: 12px; color: var(--mc-text-primary); }
.detail-hint { margin-top: 12px; font-size: 12px; color: var(--mc-text-tertiary); line-height: 1.5; } .detail-hint { margin-top: 12px; font-size: 12px; color: var(--mc-text-tertiary); line-height: 1.5; }
.detail-feature-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 10px; } .detail-feature-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 10px; }