From 4ac72a90a049801b73e243bf3ca3fd101ee35e60 Mon Sep 17 00:00:00 2001 From: matevip Date: Wed, 22 Jul 2026 15:39:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(skill):=20bundle=20file=20management=20?= =?UTF-8?q?=E2=80=94=20view=20and=20edit=20scripts,=20references,=20and=20?= =?UTF-8?q?templates=20from=20the=20console?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../mate/channel/ChannelMessageRouter.java | 7 +- .../skill/controller/SkillController.java | 155 +++++++++++ .../mate/skill/service/SkillFileService.java | 68 +++++ .../mate/skill/workspace/SkillFileSyncer.java | 20 +- .../workspace/SkillWorkspaceManager.java | 39 ++- .../workspace/bundle/SkillBundleFiles.java | 13 +- .../mate/tool/builtin/SkillManageTool.java | 28 +- .../SkillControllerBundleFilesTest.java | 187 +++++++++++++ .../SkillControllerLifecycleTest.java | 2 +- .../SkillControllerListEnabledTest.java | 3 +- .../SkillControllerVirtualGuardTest.java | 6 +- .../builtin/SkillManageToolWriteFileTest.java | 22 +- mateclaw-ui/src/api/index.ts | 12 + mateclaw-ui/src/i18n/locales/en-US.ts | 15 ++ mateclaw-ui/src/i18n/locales/zh-CN.ts | 15 ++ mateclaw-ui/src/views/SkillMarket.vue | 251 +++++++++++++++++- 16 files changed, 809 insertions(+), 34 deletions(-) create mode 100644 mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerBundleFilesTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java index ca68d47b..a3e20e21 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java @@ -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, diff --git a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java index feae1b20..cd6deb21 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java @@ -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>> 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 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> out = new ArrayList<>(rows.size()); + rows.stream() + .sorted(java.util.Comparator.comparing(SkillFileEntity::getFilePath, + java.util.Comparator.nullsLast(String::compareTo))) + .forEach(row -> { + Map 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> 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 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> putBundleFileContent(@PathVariable Long id, + @RequestBody Map 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 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> 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 diff --git a/mateclaw-server/src/main/java/vip/mate/skill/service/SkillFileService.java b/mateclaw-server/src/main/java/vip/mate/skill/service/SkillFileService.java index 267db5a6..3cea4cec 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/service/SkillFileService.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/service/SkillFileService.java @@ -80,13 +80,16 @@ public class SkillFileService { Map incoming = newFiles == null ? Map.of() : newFiles; boolean newHasScripts = bucketHasEntries(incoming, "scripts/"); boolean newHasRefs = bucketHasEntries(incoming, "references/"); + boolean newHasTemplates = bucketHasEntries(incoming, "templates/"); List 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 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 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 files, String prefix) { for (String key : files.keySet()) { if (key != null && key.startsWith(prefix)) return true; diff --git a/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillFileSyncer.java b/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillFileSyncer.java index f6077f69..b5367247 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillFileSyncer.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillFileSyncer.java @@ -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. * *

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 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 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 ingested = new LinkedHashMap<>(); diff --git a/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillWorkspaceManager.java b/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillWorkspaceManager.java index fdcd3417..4d9de5d8 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillWorkspaceManager.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillWorkspaceManager.java @@ -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; } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/workspace/bundle/SkillBundleFiles.java b/mateclaw-server/src/main/java/vip/mate/skill/workspace/bundle/SkillBundleFiles.java index 9dde75d3..17e453a3 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/workspace/bundle/SkillBundleFiles.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/workspace/bundle/SkillBundleFiles.java @@ -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/}). * - *

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. + *

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 DB_BUCKET_PREFIXES = List.of("scripts/", "references/"); + public static final List 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. diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillManageTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillManageTool.java index 46bcbc2b..3d8518bc 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillManageTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillManageTool.java @@ -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)."; } diff --git a/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerBundleFilesTest.java b/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerBundleFilesTest.java new file mode 100644 index 00000000..39f8b291 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerBundleFilesTest.java @@ -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>> 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>> 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", "")) + .thenReturn(row("templates/report.html", "")); + + R> resp = controller.putBundleFileContent(SID, + Map.of("path", "templates/report.html", "content", ""), null); + + assertThat(resp.getData()).containsEntry("path", "templates/report.html"); + verify(fileService).upsertFile(SID, "templates/report.html", ""); + verify(workspaceManager).writeWorkspaceFile("demo-skill", "templates/report.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> 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> 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> 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> resp = controller.deleteBundleFile(SID, "scripts/run.py", null); + + assertThat(resp.getMsg()).contains("read-only"); + verify(fileService, never()).deleteFile(any(), any()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerLifecycleTest.java b/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerLifecycleTest.java index 8bbb28d5..dbe8ed13 100644 --- a/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerLifecycleTest.java +++ b/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerLifecycleTest.java @@ -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) { diff --git a/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerListEnabledTest.java b/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerListEnabledTest.java index 5a107fa5..489e6069 100644 --- a/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerListEnabledTest.java +++ b/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerListEnabledTest.java @@ -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()); diff --git a/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerVirtualGuardTest.java b/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerVirtualGuardTest.java index 141df1af..e2683ea9 100644 --- a/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerVirtualGuardTest.java +++ b/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerVirtualGuardTest.java @@ -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. diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillManageToolWriteFileTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillManageToolWriteFileTest.java index c7af0fbe..d36491a9 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillManageToolWriteFileTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillManageToolWriteFileTest.java @@ -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", "", + 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", ""); + verify(skillFileService, times(1)).upsertFile(42L, "templates/report.html", ""); } @Test diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 25d65b70..ac181faa 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -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`), diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 00b397cf..25837b0c 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -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', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index fbb6e9d4..21e1fcd3 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -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: '已停用', diff --git a/mateclaw-ui/src/views/SkillMarket.vue b/mateclaw-ui/src/views/SkillMarket.vue index a53d22ea..e916ef0d 100644 --- a/mateclaw-ui/src/views/SkillMarket.vue +++ b/mateclaw-ui/src/views/SkillMarket.vue @@ -271,6 +271,10 @@ + + +

{{ t('skills.detail.filesHint') }}

+

{{ t('skills.detail.builtinFilesReadonly') }}

+ + +
+ + +
+ + +
+
+ +

{{ t('common.loading') }}

+

+ {{ t('skills.detail.filesEmpty') }} +

+
    +
  • + +
  • +
+ + + + +

{{ t('skills.detail.noTools') }}

@@ -763,7 +859,7 @@ const rescanning = ref>({}) * tab for back-compat with any deep links that may pass it. */ const detailDrawerVisible = ref(false) const detailSkill = ref(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('') const detailLessonsLoading = ref(false) const detailEmployees = ref>([]) @@ -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([]) +const detailFilesLoading = ref(false) +const activeFile = ref(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; }