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);
};
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={}",
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.SkillCatalogSort;
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.synthesis.SkillSynthesisService;
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.SkillFileSyncer;
import vip.mate.skill.workspace.SkillWorkspaceManager;
import vip.mate.skill.workspace.bundle.SkillBundleFiles;
import vip.mate.exception.MateClawException;
import vip.mate.skill.lifecycle.ConfirmRequiredException;
import vip.mate.skill.lifecycle.LifecycleTransition;
@ -73,6 +76,7 @@ public class SkillController {
private final SkillLifecycleService skillLifecycleService;
private final SkillCuratorJob skillCuratorJob;
private final SkillCuratorReportStore skillCuratorReportStore;
private final SkillFileService skillFileService;
@Operation(summary = "获取技能分页列表RFC-042 §2.1")
@GetMapping
@ -336,6 +340,157 @@ public class SkillController {
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
* 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;
boolean newHasScripts = bucketHasEntries(incoming, "scripts/");
boolean newHasRefs = bucketHasEntries(incoming, "references/");
boolean newHasTemplates = bucketHasEntries(incoming, "templates/");
List<SkillFileEntity> existing = listBySkillId(skillId);
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 existingHasTemplates = existing.stream().anyMatch(e -> e.getFilePath() != null && e.getFilePath().startsWith("templates/"));
boolean preserveScripts = !newHasScripts && existingHasScripts && !force;
boolean preserveRefs = !newHasRefs && existingHasRefs && !force;
boolean preserveTemplates = !newHasTemplates && existingHasTemplates && !force;
Map<String, SkillFileEntity> existingByPath = new HashMap<>();
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());
int written = 0;
@ -152,6 +162,9 @@ public class SkillFileService {
if (preserveRefs) {
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);
}
@ -163,6 +176,61 @@ public class SkillFileService {
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) {
for (String key : files.keySet()) {
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
* workspace cache so {@code scripts/} and {@code references/} files exist
* on disk wherever the skill might run.
* workspace cache so {@code scripts/}, {@code references/} and
* {@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
* 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) {
String relative = row.getFilePath();
if (relative == null || relative.isBlank()) return MaterializeOutcome.SKIPPED;
if (!relative.startsWith("references/") && !relative.startsWith("scripts/")) {
log.warn("Skipping skill_file row {} — path outside scripts/ or references/: {}",
row.getId(), relative);
if (!SkillBundleFiles.isDbEligible(relative)) {
log.warn("Skipping skill_file row {} — path outside the DB-persisted buckets ({}): {}",
row.getId(), SkillBundleFiles.DB_BUCKET_PREFIXES, relative);
return MaterializeOutcome.SKIPPED;
}
if (relative.contains("..")) {
@ -208,11 +208,11 @@ public class SkillFileSyncer {
private int backfillFromDiskIfNeeded(SkillEntity skill, Path workspaceDir) {
if (!Files.exists(workspaceDir) || !Files.isDirectory(workspaceDir)) return 0;
List<Path> roots = new ArrayList<>(2);
Path scripts = workspaceDir.resolve("scripts");
Path references = workspaceDir.resolve("references");
if (Files.isDirectory(scripts)) roots.add(scripts);
if (Files.isDirectory(references)) roots.add(references);
List<Path> roots = new ArrayList<>(SkillBundleFiles.DB_BUCKET_PREFIXES.size());
for (String prefix : SkillBundleFiles.DB_BUCKET_PREFIXES) {
Path root = workspaceDir.resolve(prefix.substring(0, prefix.length() - 1));
if (Files.isDirectory(root)) roots.add(root);
}
if (roots.isEmpty()) return 0;
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/ 目录内容保留目录本身
* 用于 overwrite 安装前清除旧版本残留文件
@ -531,8 +565,9 @@ public class SkillWorkspaceManager {
// 归一化分隔符
String normalized = relativePath.replace("\\", "/");
// 必须以 references/ scripts/ 开头
if (!normalized.startsWith("references/") && !normalized.startsWith("scripts/")) {
// 必须以 references/scripts/ templates/ 开头
if (!normalized.startsWith("references/") && !normalized.startsWith("scripts/")
&& !normalized.startsWith("templates/")) {
return null;
}

View File

@ -9,16 +9,17 @@ import java.util.Map;
/**
* 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
* classpath backfill so both agree on which paths are DB-persisted and how
* bundle contents are read into memory.
* <p>Shared by the bundled-skill startup sync, the skill file syncer's
* classpath backfill, and the admin file editor so all agree on which
* paths are DB-persisted and how bundle contents are read into memory.
*/
public final class SkillBundleFiles {
/** 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() {
}
@ -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
* {@code SkillFileService#applyBundleFiles} expects). Iteration 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.SkillSecurityService;
import vip.mate.skill.runtime.SkillValidationResult;
import vip.mate.skill.service.SkillFileService;
import vip.mate.skill.service.SkillService;
import vip.mate.skill.workspace.SkillWorkspaceManager;
@ -39,6 +40,7 @@ import java.util.regex.Pattern;
public class SkillManageTool {
private final SkillService skillService;
private final SkillFileService skillFileService;
private final SkillSecurityService securityService;
private final SkillWorkspaceManager workspaceManager;
private final SkillRuntimeService runtimeService;
@ -82,10 +84,10 @@ public class SkillManageTool {
- 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)
- 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
(e.g. a long reference doc the SKILL.md links to, or a re-runnable script). Put the
file body in 'content' and the path in 'filePath'. Keep SKILL.md itself lean and move
bulky detail into references/.
- write_file: Write a supporting file under the skill's references/, scripts/ or
templates/ directory (e.g. a long reference doc the SKILL.md links to, a re-runnable
script, or an output template). Put the file body in 'content' and the path in
'filePath'. Keep SKILL.md itself lean and move bulky detail into references/.
- delete: Remove a skill
SKILL.md format example:
@ -130,7 +132,7 @@ public class SkillManageTool {
String newText,
@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,
// 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) {
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) {
return "Error: content is required for write_file action.";
@ -386,12 +388,24 @@ public class SkillManageTool {
workspaceManager.writeWorkspaceFile(name, filePath, content);
} catch (IllegalArgumentException e) {
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) {
log.error("[SkillManage] Failed to write file '{}' for skill '{}': {}", filePath, name, e.getMessage(), e);
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);
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(
skillService, null, null, null, null, null, null, null, null, null, null,
agentBindingService, null, null,
skillLifecycleService, skillCuratorJob, skillCuratorReportStore);
skillLifecycleService, skillCuratorJob, skillCuratorReportStore, null);
}
private SkillEntity skill(String state, boolean builtin) {

View File

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

View File

@ -26,7 +26,7 @@ class SkillControllerVirtualGuardTest {
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);
@Test
@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);
SkillController c = new SkillController(
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;
SkillEntity toggled = new SkillEntity();
toggled.setName("github");
@ -98,7 +98,7 @@ class SkillControllerVirtualGuardTest {
SkillController real = new SkillController(
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);
long snowflakeId = 1_900_000_001_000_000_902L;
// updateSkill on a mocked SkillService returns null without throwing,
// 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.SkillSecurityService;
import vip.mate.skill.runtime.SkillValidationResult;
import vip.mate.skill.service.SkillFileService;
import vip.mate.skill.service.SkillService;
import vip.mate.skill.workspace.SkillWorkspaceManager;
@ -30,6 +31,7 @@ import static org.mockito.Mockito.when;
class SkillManageToolWriteFileTest {
private SkillService skillService;
private SkillFileService skillFileService;
private SkillSecurityService securityService;
private SkillWorkspaceManager workspaceManager;
private SkillManageTool tool;
@ -37,14 +39,16 @@ class SkillManageToolWriteFileTest {
@BeforeEach
void setUp() {
skillService = mock(SkillService.class);
skillFileService = mock(SkillFileService.class);
securityService = mock(SkillSecurityService.class);
workspaceManager = mock(SkillWorkspaceManager.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) {
SkillEntity s = new SkillEntity();
s.setId(42L);
s.setName(name);
s.setBuiltin(builtin);
s.setSkillContent("---\nname: " + name + "\n---\n# x");
@ -69,6 +73,22 @@ class SkillManageToolWriteFileTest {
assertTrue(result.startsWith("File 'scripts/run.sh' written"), result);
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

View File

@ -262,6 +262,18 @@ export const skillApi = {
refreshRuntime: () => http.post('/skills/runtime/refresh'),
exportWorkspace: (id: string | number) => http.post(`/skills/${id}/export-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
requirements: (id: string | number) => http.get(`/skills/${id}/requirements`),
getLessons: (id: string | number) => http.get(`/skills/${id}/lessons`),

View File

@ -3874,6 +3874,7 @@ export default {
title: 'Skill detail',
overview: 'Overview',
body: 'Body',
files: 'Files',
manifest: 'Manifest',
tools: 'Tools',
features: 'Features',
@ -3933,6 +3934,20 @@ export default {
secretDeleteSuccess: 'Secret deleted',
secretDeleteFailed: 'Failed to delete',
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: {
disabled: 'Disabled',

View File

@ -3966,6 +3966,7 @@ export default {
title: '技能详情',
overview: '概览',
body: '正文',
files: '文件',
manifest: 'Manifest',
tools: '工具',
features: '特性',
@ -4025,6 +4026,20 @@ export default {
secretDeleteSuccess: '密钥已删除',
secretDeleteFailed: '删除失败',
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: {
disabled: '已停用',

View File

@ -271,6 +271,10 @@
<button class="detail-tab" :class="{ active: detailTab === 'body' }" @click="detailTab = 'body'">
{{ t('skills.detail.body') }}
</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'">
{{ t('skills.detail.tools') }}
<span v-if="detailToolsCount > 0" class="tab-count">{{ detailToolsCount }}</span>
@ -468,6 +472,98 @@
</template>
</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 -->
<div v-if="detailTab === 'tools'" class="detail-section">
<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. */
const detailDrawerVisible = ref(false)
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 detailLessonsLoading = ref(false)
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: '',
})
// ==================== 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
* at creation time. Everything else is filled in via the drawer. */
const newForm = ref<{ name: string; description: string; icon: string }>({ name: '', description: '', icon: '' })
@ -884,7 +1116,7 @@ const detailFeaturesCount = computed(() => detailFeatures.value.length)
function openDetailDrawer(
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 } = {},
) {
detailSkill.value = skill
@ -893,6 +1125,7 @@ function openDetailDrawer(
detailEmployees.value = []
editingIdentity.value = false
editingBody.value = false
resetFilesTab()
detailDrawerVisible.value = true
// 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
@ -979,6 +1212,11 @@ watch(detailTab, (tab) => {
if (tab === 'memory' && detailDrawerVisible.value && detailEmployees.value.length === 0 && !detailEmployeesLoading.value) {
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(() => [
@ -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-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; }
.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-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; }