diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/OfficeCliTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/OfficeCliTool.java
new file mode 100644
index 00000000..056c717c
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/OfficeCliTool.java
@@ -0,0 +1,484 @@
+package vip.mate.tool.builtin;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.ai.chat.model.ToolContext;
+import org.springframework.ai.tool.annotation.Tool;
+import org.springframework.ai.tool.annotation.ToolParam;
+import org.springframework.lang.Nullable;
+import org.springframework.stereotype.Component;
+import vip.mate.tool.ConcurrencyUnsafe;
+import vip.mate.tool.document.FilenameSanitizer;
+import vip.mate.tool.document.GeneratedFileCache;
+import vip.mate.tool.document.GeneratedFileLink;
+import vip.mate.tool.guard.WorkspacePathGuard;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Optional structured adapter for iOfficeAI/OfficeCLI (issue #583).
+ *
+ *
The adapter deliberately exposes a narrow operation vocabulary instead of
+ * accepting an arbitrary command line. Every input is copied into a private
+ * scratch directory before OfficeCLI sees it, so even mutating operations are
+ * copy-on-write and can never overwrite the user's source document. Generated
+ * bytes are immediately moved into {@link GeneratedFileCache}; scratch files
+ * are removed at the end of the call.
+ */
+@Slf4j
+@Component
+public class OfficeCliTool {
+
+ private static final Set OFFICE_EXTENSIONS = Set.of("docx", "xlsx", "pptx");
+ private static final Set INSPECT_MODES = Set.of(
+ "outline", "stats", "issues", "text", "annotated");
+ private static final Map RENDER_EXTENSIONS = Map.of(
+ "html", "html",
+ "screenshot", "png",
+ "svg", "svg",
+ "pdf", "pdf");
+ private static final int DEFAULT_TIMEOUT_SECONDS = 90;
+ private static final int MAX_TIMEOUT_SECONDS = 300;
+ private static final int MAX_OUTPUT_BYTES = 50_000;
+ private static final long MAX_INPUT_BYTES = 50L * 1024 * 1024;
+ private static final long MAX_ARTIFACT_BYTES = 20L * 1024 * 1024;
+
+ private final GeneratedFileCache generatedFileCache;
+ private final ObjectMapper objectMapper;
+ private final String executable;
+
+ @Autowired
+ public OfficeCliTool(GeneratedFileCache generatedFileCache, ObjectMapper objectMapper) {
+ this(generatedFileCache, objectMapper, "officecli");
+ }
+
+ /** Test seam for a fake executable; production deliberately resolves from PATH. */
+ OfficeCliTool(GeneratedFileCache generatedFileCache,
+ ObjectMapper objectMapper,
+ String executable) {
+ this.generatedFileCache = generatedFileCache;
+ this.objectMapper = objectMapper;
+ this.executable = executable == null || executable.isBlank() ? "officecli" : executable.trim();
+ }
+
+ @ConcurrencyUnsafe("OfficeCLI starts native processes and may use document-level locks")
+ @Tool(description = """
+ Use the optional iOfficeAI/OfficeCLI engine to inspect, validate, batch-edit,
+ merge, or render an existing .docx, .xlsx, or .pptx file. This complements
+ the built-in Markdown renderers: use those for simple new files, and use this
+ tool for existing templates, complex structure, validation, or visual QA.
+
+ Actions:
+ - inspect: read structure/content. mode is outline|stats|issues|text|annotated.
+ - validate: run OpenXML validation.
+ - batch: apply an OfficeCLI batch JSON array to a COPY of the input. payload is required.
+ - merge: replace template placeholders using a JSON object. payload is required.
+ - render: render to html|screenshot|svg|pdf. mode is required.
+
+ Mutating actions NEVER overwrite the source. The result is returned as a
+ generated-file download link. OfficeCLI must be installed on the MateClaw
+ server; missing installations return a setup error.
+ """)
+ public String office_document(
+ @ToolParam(description = "inspect | validate | batch | merge | render") String action,
+ @ToolParam(description = "Workspace path or uploaded attachment name for a .docx/.xlsx/.pptx file") String filePath,
+ @ToolParam(description = "Inspect/render mode; omitted for validate/batch/merge", required = false) String mode,
+ @ToolParam(description = "JSON array for batch or JSON object for merge", required = false) String payload,
+ @ToolParam(description = "Optional result filename; extension is normalized", required = false) String outputFilename,
+ @ToolParam(description = "Timeout in seconds, default 90, maximum 300", required = false) Integer timeoutSeconds,
+ @Nullable ToolContext ctx) {
+
+ String normalizedAction = normalize(action);
+ if (!Set.of("inspect", "validate", "batch", "merge", "render").contains(normalizedAction)) {
+ return error("Unsupported action: " + action);
+ }
+
+ Path source;
+ try {
+ source = resolveInput(filePath, ctx);
+ } catch (Exception e) {
+ return error(e.getMessage());
+ }
+
+ String inputExtension = extension(source.getFileName().toString());
+ if (!OFFICE_EXTENSIONS.contains(inputExtension)) {
+ return error("OfficeCLI supports .docx, .xlsx, and .pptx inputs only");
+ }
+ if (!Files.isRegularFile(source)) {
+ return error("Office input not found or not a regular file: " + filePath);
+ }
+ try {
+ if (Files.size(source) > MAX_INPUT_BYTES) {
+ return error("Office input exceeds the 50 MB limit");
+ }
+ } catch (IOException e) {
+ return error("Cannot inspect Office input size: " + e.getMessage());
+ }
+
+ int timeout = timeoutSeconds == null || timeoutSeconds <= 0
+ ? DEFAULT_TIMEOUT_SECONDS
+ : Math.min(timeoutSeconds, MAX_TIMEOUT_SECONDS);
+
+ Path scratch = null;
+ try {
+ scratch = Files.createTempDirectory("mc_officecli_");
+ Path scratchInput = scratch.resolve("input." + inputExtension);
+ Files.copy(source, scratchInput, StandardCopyOption.REPLACE_EXISTING);
+
+ return switch (normalizedAction) {
+ case "inspect" -> inspect(scratchInput, mode, timeout);
+ case "validate" -> validate(scratchInput, timeout);
+ case "batch" -> batch(scratchInput, inputExtension, payload, outputFilename, timeout, ctx);
+ case "merge" -> merge(scratchInput, inputExtension, payload, outputFilename, timeout, ctx);
+ case "render" -> render(scratchInput, mode, outputFilename, timeout, ctx);
+ default -> error("Unsupported action: " + action);
+ };
+ } catch (Exception e) {
+ log.warn("[OfficeCLI] action={} failed: {}", normalizedAction, e.getMessage());
+ return error("OfficeCLI execution failed: " + e.getMessage());
+ } finally {
+ deleteTreeQuietly(scratch);
+ }
+ }
+
+ private String inspect(Path input, String mode, int timeout) throws IOException, InterruptedException {
+ String inspectMode = normalize(mode);
+ if (!INSPECT_MODES.contains(inspectMode)) {
+ return error("inspect mode must be one of: " + String.join(", ", INSPECT_MODES));
+ }
+ ProcessResult result = run(input.getParent(), timeout,
+ List.of("view", input.toString(), inspectMode, "--json"));
+ return processOnlyResult("inspect", result);
+ }
+
+ private String validate(Path input, int timeout) throws IOException, InterruptedException {
+ ProcessResult result = run(input.getParent(), timeout,
+ List.of("validate", input.toString(), "--json"));
+ return processOnlyResult("validate", result);
+ }
+
+ private String batch(Path input, String extension, String payload, String outputFilename,
+ int timeout, @Nullable ToolContext ctx) throws IOException, InterruptedException {
+ JsonNode commands = parsePayload(payload, true);
+ if (commands == null) {
+ return error("batch payload must be a non-empty JSON array");
+ }
+ String unsafeReason = validateBatchCommands(commands);
+ if (unsafeReason != null) {
+ return error(unsafeReason);
+ }
+ String displayName = outputName(outputFilename, "officecli-edited", extension);
+ Path output = input.getParent().resolve("result." + extension);
+ Files.copy(input, output, StandardCopyOption.REPLACE_EXISTING);
+
+ ProcessResult result = run(input.getParent(), timeout,
+ List.of("batch", output.toString(), "--commands", commands.toString(), "--json"));
+ return generatedResult("batch", result, output, displayName, mimeFor(extension), ctx);
+ }
+
+ private String merge(Path input, String extension, String payload, String outputFilename,
+ int timeout, @Nullable ToolContext ctx) throws IOException, InterruptedException {
+ JsonNode data = parsePayload(payload, false);
+ if (data == null) {
+ return error("merge payload must be a non-empty JSON object");
+ }
+ String displayName = outputName(outputFilename, "officecli-merged", extension);
+ Path output = input.getParent().resolve("result." + extension);
+
+ ProcessResult result = run(input.getParent(), timeout,
+ List.of("merge", input.toString(), output.toString(), "--data", data.toString(), "--json"));
+ return generatedResult("merge", result, output, displayName, mimeFor(extension), ctx);
+ }
+
+ private String render(Path input, String mode, String outputFilename,
+ int timeout, @Nullable ToolContext ctx) throws IOException, InterruptedException {
+ String renderMode = normalize(mode);
+ String extension = RENDER_EXTENSIONS.get(renderMode);
+ if (extension == null) {
+ return error("render mode must be one of: html, screenshot, svg, pdf");
+ }
+ String displayName = outputName(outputFilename, "officecli-preview", extension);
+ Path output = input.getParent().resolve("rendered." + extension);
+
+ // html/svg are streamed to stdout by OfficeCLI; screenshot/pdf accept -o.
+ // Keep artifact bytes separate from the bounded diagnostic capture.
+ ProcessResult result = Set.of("html", "svg").contains(renderMode)
+ ? run(input.getParent(), timeout,
+ List.of("view", input.toString(), renderMode), output)
+ : run(input.getParent(), timeout,
+ List.of("view", input.toString(), renderMode, "-o", output.toString()));
+ return generatedResult("render", result, output, displayName, mimeFor(extension), ctx);
+ }
+
+ private JsonNode parsePayload(String payload, boolean array) {
+ if (payload == null || payload.isBlank()) return null;
+ try {
+ JsonNode node = objectMapper.readTree(payload);
+ if (array ? node.isArray() && !node.isEmpty() : node.isObject() && !node.isEmpty()) {
+ return node;
+ }
+ } catch (Exception ignore) {
+ // A concise validation error is returned by the caller.
+ }
+ return null;
+ }
+
+ private String generatedResult(String action, ProcessResult result, Path output,
+ String displayName, String mime, @Nullable ToolContext ctx) throws IOException {
+ if (result.exitCode() != 0 || result.timedOut()) {
+ return processOnlyResult(action, result);
+ }
+ if (!Files.isRegularFile(output) || Files.size(output) == 0) {
+ return error("OfficeCLI completed without producing the expected output file");
+ }
+ if (Files.size(output) > MAX_ARTIFACT_BYTES) {
+ return error("OfficeCLI output exceeds the 20 MB delivery limit");
+ }
+ String link = GeneratedFileLink.resultEn(
+ Files.readAllBytes(output), displayName, mime, generatedFileCache, "Office file", 1, ctx);
+ ObjectNode json = baseResult(action, result);
+ json.put("generatedFile", link);
+ return pretty(json);
+ }
+
+ private String processOnlyResult(String action, ProcessResult result) {
+ return pretty(baseResult(action, result));
+ }
+
+ private ObjectNode baseResult(String action, ProcessResult result) {
+ ObjectNode json = objectMapper.createObjectNode();
+ json.put("success", result.exitCode() == 0 && !result.timedOut());
+ json.put("action", action);
+ json.put("exitCode", result.exitCode());
+ json.put("stdout", result.stdout());
+ json.put("stderr", result.stderr());
+ json.put("timedOut", result.timedOut());
+ if (result.setupMissing()) {
+ json.put("setupRequired", true);
+ json.put("message", "OfficeCLI is not installed on the MateClaw server PATH");
+ }
+ return json;
+ }
+
+ private ProcessResult run(Path workingDir, int timeoutSeconds, List args)
+ throws IOException, InterruptedException {
+ return run(workingDir, timeoutSeconds, args, null);
+ }
+
+ private ProcessResult run(Path workingDir, int timeoutSeconds, List args,
+ @Nullable Path stdoutArtifact)
+ throws IOException, InterruptedException {
+ List command = new ArrayList<>(args.size() + 1);
+ command.add(executable);
+ command.addAll(args);
+
+ Path stdoutFile = stdoutArtifact == null
+ ? Files.createTempFile(workingDir, "stdout-", ".log")
+ : stdoutArtifact;
+ Path stderrFile = Files.createTempFile(workingDir, "stderr-", ".log");
+ Process process = null;
+ try {
+ ProcessBuilder pb = new ProcessBuilder(command);
+ pb.directory(workingDir.toFile());
+ pb.redirectOutput(stdoutFile.toFile());
+ pb.redirectError(stderrFile.toFile());
+ pb.environment().put("OFFICECLI_SKIP_UPDATE", "1");
+ pb.environment().put("OFFICECLI_NO_AUTO_RESIDENT", "1");
+ pb.environment().keySet().removeIf(key -> {
+ String upper = key.toUpperCase(Locale.ROOT);
+ return upper.contains("KEY") || upper.contains("SECRET") || upper.contains("TOKEN")
+ || upper.contains("PASSWORD") || upper.contains("CREDENTIAL");
+ });
+
+ try {
+ process = pb.start();
+ } catch (IOException e) {
+ if (isMissingExecutable(e)) {
+ return new ProcessResult(-1, "", e.getMessage(), false, true);
+ }
+ throw e;
+ }
+
+ boolean finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS);
+ if (!finished) {
+ killProcessTree(process);
+ }
+ int exitCode = finished ? process.exitValue() : -1;
+ return new ProcessResult(exitCode,
+ stdoutArtifact == null ? readTruncated(stdoutFile) : "",
+ readTruncated(stderrFile),
+ !finished,
+ false);
+ } catch (InterruptedException e) {
+ if (process != null && process.isAlive()) killProcessTree(process);
+ Thread.currentThread().interrupt();
+ throw e;
+ } finally {
+ if (stdoutArtifact == null) Files.deleteIfExists(stdoutFile);
+ Files.deleteIfExists(stderrFile);
+ }
+ }
+
+ private Path resolveInput(String filePath, @Nullable ToolContext ctx) {
+ if (filePath == null || filePath.isBlank()) {
+ throw new IllegalArgumentException("filePath is required");
+ }
+ try {
+ Path path = WorkspacePathGuard.validatePath(filePath, ctx);
+ if (Files.exists(path)) return path;
+ } catch (IllegalArgumentException boundary) {
+ Path attachment = ChatUploadResolver.resolve(filePath);
+ if (attachment != null) return attachment;
+ throw boundary;
+ }
+ Path attachment = ChatUploadResolver.resolve(filePath);
+ if (attachment != null) return attachment;
+ throw new IllegalArgumentException("Office input not found: " + filePath);
+ }
+
+ private String outputName(String requested, String fallback, String extension) {
+ String base = FilenameSanitizer.sanitize(requested, fallback, "." + extension);
+ return base + "." + extension;
+ }
+
+ /**
+ * Keep the first-draft batch surface structural. Raw XML and importer verbs
+ * can make OfficeCLI read arbitrary host files through relationship/media
+ * properties even though the document itself lives in scratch space.
+ */
+ @Nullable
+ private String validateBatchCommands(JsonNode commands) {
+ Set allowed = Set.of("add", "set", "remove", "move", "swap", "validate");
+ for (JsonNode command : commands) {
+ if (!command.isObject()) {
+ return "Each batch item must be a JSON object";
+ }
+ String verb = normalize(command.path("command").asText(command.path("op").asText()));
+ if (!allowed.contains(verb)) {
+ return "Unsupported batch command in the safe adapter: " + verb;
+ }
+ String type = normalize(command.path("type").asText());
+ if (Set.of("image", "picture", "video", "audio", "ole", "embeddedobject").contains(type)) {
+ return "External media/OLE batch operations are not supported by the safe adapter";
+ }
+ String unsafeValue = findUnsafeHostPath(command);
+ if (unsafeValue != null) {
+ return "Batch payload contains a host filesystem reference that is not allowed: " + unsafeValue;
+ }
+ }
+ return null;
+ }
+
+ @Nullable
+ private String findUnsafeHostPath(JsonNode node) {
+ if (node.isTextual()) {
+ String value = node.asText().trim();
+ String lower = value.toLowerCase(Locale.ROOT);
+ if (lower.startsWith("file://") || lower.startsWith("~/") || lower.startsWith("~\\")
+ || value.matches("^[A-Za-z]:[\\\\/].*")) {
+ return value;
+ }
+ if (value.matches(".*(^|[/\\\\])\\.\\.([/\\\\]|$).*")
+ || value.matches("^/(etc|var|tmp|home|users|root|opt|proc|sys|dev)(/.*)?$")) {
+ return value;
+ }
+ return null;
+ }
+ if (node.isContainerNode()) {
+ for (JsonNode child : node) {
+ String unsafe = findUnsafeHostPath(child);
+ if (unsafe != null) return unsafe;
+ }
+ }
+ return null;
+ }
+
+ private static String extension(String filename) {
+ int dot = filename.lastIndexOf('.');
+ return dot < 0 ? "" : filename.substring(dot + 1).toLowerCase(Locale.ROOT);
+ }
+
+ private static String normalize(String value) {
+ return value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
+ }
+
+ private static String mimeFor(String extension) {
+ return switch (extension) {
+ case "docx" -> "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
+ case "xlsx" -> "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
+ case "pptx" -> "application/vnd.openxmlformats-officedocument.presentationml.presentation";
+ case "html" -> "text/html";
+ case "png" -> "image/png";
+ case "svg" -> "image/svg+xml";
+ case "pdf" -> "application/pdf";
+ default -> "application/octet-stream";
+ };
+ }
+
+ private static boolean isMissingExecutable(IOException e) {
+ String message = e.getMessage();
+ return message != null && (message.contains("No such file") || message.contains("CreateProcess error=2"));
+ }
+
+ private String readTruncated(Path path) throws IOException {
+ byte[] bytes = Files.readAllBytes(path);
+ if (bytes.length <= MAX_OUTPUT_BYTES) return new String(bytes, StandardCharsets.UTF_8);
+ return new String(bytes, 0, MAX_OUTPUT_BYTES, StandardCharsets.UTF_8)
+ + "\n... [output truncated]";
+ }
+
+ private static void killProcessTree(Process process) {
+ process.descendants().forEach(handle -> {
+ try { handle.destroyForcibly(); } catch (Exception ignore) { }
+ });
+ process.destroyForcibly();
+ try {
+ process.waitFor(5, TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ private void deleteTreeQuietly(@Nullable Path root) {
+ if (root == null || !Files.exists(root)) return;
+ try (var paths = Files.walk(root)) {
+ paths.sorted(Comparator.reverseOrder()).forEach(path -> {
+ try { Files.deleteIfExists(path); } catch (IOException ignore) { }
+ });
+ } catch (IOException e) {
+ log.debug("[OfficeCLI] failed to delete scratch directory {}: {}", root, e.getMessage());
+ }
+ }
+
+ private String error(String message) {
+ ObjectNode json = objectMapper.createObjectNode();
+ json.put("success", false);
+ json.put("error", message == null ? "Unknown OfficeCLI error" : message);
+ return pretty(json);
+ }
+
+ private String pretty(JsonNode json) {
+ try {
+ return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(json);
+ } catch (Exception e) {
+ return json.toString();
+ }
+ }
+
+ private record ProcessResult(int exitCode, String stdout, String stderr,
+ boolean timedOut, boolean setupMissing) { }
+}
diff --git a/mateclaw-server/src/main/resources/db/data-en.sql b/mateclaw-server/src/main/resources/db/data-en.sql
index 4d717f2f..40096f7e 100644
--- a/mateclaw-server/src/main/resources/db/data-en.sql
+++ b/mateclaw-server/src/main/resources/db/data-en.sql
@@ -475,6 +475,10 @@ MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name,
KEY (id)
VALUES (1000000028, 'ChannelMessageTool', 'Channel Message Push', 'Proactively push messages to IM channel conversations. list_channel_sessions discovers pushable conversations; send_channel_message performs a one-way push — for alerts, reminders, and async task results.', 'builtin', 'channelMessageTool', '📤', TRUE, TRUE, NOW(), NOW(), 0);
+MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+KEY (id)
+VALUES (1000000029, 'OfficeCliTool', 'OfficeCLI Advanced Documents', 'Inspect, validate, copy-edit, merge, and render DOCX/XLSX/PPTX through the optional iOfficeAI/OfficeCLI binary. Mutations are copy-on-write and return generated-file links.', 'builtin', 'officeCliTool', '🏢', TRUE, TRUE, NOW(), NOW(), 0);
+
-- Built-in tool: Edit File (enabled by default, dangerous ops controlled by ToolGuard)
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)
@@ -707,7 +711,11 @@ MERGE INTO mate_skill (id, name, description, skill_type, icon, version, author,
KEY (id)
VALUES (1000000019, 'multi_agent_collaboration', 'When a task requires the professional capabilities of multiple Agents, orchestrate parallel or serial multi-agent collaboration and integrate results.', 'builtin', '🤝', '1.4.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'multi-agent,collaboration,orchestration,parallel', NOW(), NOW(), 0);
--- RFC-042 §2.2 — bilingual display names for the 19 builtin skills.
+MERGE INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted)
+KEY (id)
+VALUES (1000000020, 'officecli', 'Use optional iOfficeAI/OfficeCLI for advanced inspection, validation, copy editing, template merge, and visual rendering of existing DOCX/XLSX/PPTX files.', 'builtin', '🏢', '1.0.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'office,officecli,docx,xlsx,pptx,render,validate', NOW(), NOW(), 0);
+
+-- RFC-042 §2.2 — bilingual display names for the 20 builtin skills.
-- Identical across all four data-*.sql files because name_zh / name_en are
-- permanent attributes, not locale-conditional. The UI picks which one to
-- show based on the active i18n locale and falls back to `name` when null.
@@ -718,6 +726,7 @@ UPDATE mate_skill SET name_zh = '邮件管理', name_en = 'Email (Himalaya
UPDATE mate_skill SET name_zh = '新闻查询', name_en = 'News' WHERE name = 'news';
UPDATE mate_skill SET name_zh = 'PDF 处理', name_en = 'PDF' WHERE name = 'pdf';
UPDATE mate_skill SET name_zh = 'Word 文档', name_en = 'Word Document' WHERE name = 'docx';
+UPDATE mate_skill SET name_zh = 'OfficeCLI 高级文档', name_en = 'OfficeCLI Advanced Documents' WHERE name = 'officecli';
UPDATE mate_skill SET name_zh = 'PPT 演示', name_en = 'PowerPoint' WHERE name = 'pptx';
UPDATE mate_skill SET name_zh = 'Excel 表格', name_en = 'Excel' WHERE name = 'xlsx';
UPDATE mate_skill SET name_zh = '可见浏览器', name_en = 'Visible Browser' WHERE name = 'browser_visible';
diff --git a/mateclaw-server/src/main/resources/db/data-kingbase-en.sql b/mateclaw-server/src/main/resources/db/data-kingbase-en.sql
index 999c56af..4c7b2993 100644
--- a/mateclaw-server/src/main/resources/db/data-kingbase-en.sql
+++ b/mateclaw-server/src/main/resources/db/data-kingbase-en.sql
@@ -519,6 +519,10 @@ INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name
VALUES (1000000028, 'ChannelMessageTool', 'Channel Message Push', 'Proactively push messages to IM channel conversations. list_channel_sessions discovers pushable conversations; send_channel_message performs a one-way push — for alerts, reminders, and async task results.', 'builtin', 'channelMessageTool', '📤', TRUE, TRUE, NOW(), NOW(), 0)
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
+INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+VALUES (1000000029, 'OfficeCliTool', 'OfficeCLI Advanced Documents', 'Inspect, validate, copy-edit, merge, and render DOCX/XLSX/PPTX through the optional iOfficeAI/OfficeCLI binary. Mutations are copy-on-write and return generated-file links.', 'builtin', 'officeCliTool', '🏢', TRUE, TRUE, NOW(), NOW(), 0)
+ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
+
-- Built-in tool: Edit File (enabled by default, dangerous ops controlled by ToolGuard)
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000006, 'EditFileTool', 'Edit File', 'Edit file content via find-and-replace. Matches old_text exactly and replaces with new_text. Requires user approval.', 'builtin', 'editFileTool', '✏️', TRUE, TRUE, NOW(), NOW(), 0)
@@ -699,7 +703,11 @@ INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author
VALUES (1000000019, 'multi_agent_collaboration', 'When a task requires the professional capabilities of multiple Agents, orchestrate parallel or serial multi-agent collaboration and integrate results.', 'builtin', '🤝', '1.4.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'multi-agent,collaboration,orchestration,parallel', NOW(), NOW(), 0)
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, skill_type=EXCLUDED.skill_type, icon=EXCLUDED.icon, version=EXCLUDED.version, author=EXCLUDED.author, config_json=EXCLUDED.config_json, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
--- RFC-042 §2.2 — bilingual display names for the 19 builtin skills.
+INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted)
+VALUES (1000000020, 'officecli', 'Use optional iOfficeAI/OfficeCLI for advanced inspection, validation, copy editing, template merge, and visual rendering of existing DOCX/XLSX/PPTX files.', 'builtin', '🏢', '1.0.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'office,officecli,docx,xlsx,pptx,render,validate', NOW(), NOW(), 0)
+ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, skill_type=EXCLUDED.skill_type, icon=EXCLUDED.icon, version=EXCLUDED.version, author=EXCLUDED.author, config_json=EXCLUDED.config_json, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
+
+-- RFC-042 §2.2 — bilingual display names for the 20 builtin skills.
-- Identical across all four data-*.sql files because name_zh / name_en are
-- permanent attributes, not locale-conditional. The UI picks which one to
-- show based on the active i18n locale and falls back to name when null.
@@ -710,6 +718,7 @@ UPDATE mate_skill SET name_zh = '邮件管理', name_en = 'Email (Himalaya
UPDATE mate_skill SET name_zh = '新闻查询', name_en = 'News' WHERE name = 'news';
UPDATE mate_skill SET name_zh = 'PDF 处理', name_en = 'PDF' WHERE name = 'pdf';
UPDATE mate_skill SET name_zh = 'Word 文档', name_en = 'Word Document' WHERE name = 'docx';
+UPDATE mate_skill SET name_zh = 'OfficeCLI 高级文档', name_en = 'OfficeCLI Advanced Documents' WHERE name = 'officecli';
UPDATE mate_skill SET name_zh = 'PPT 演示', name_en = 'PowerPoint' WHERE name = 'pptx';
UPDATE mate_skill SET name_zh = 'Excel 表格', name_en = 'Excel' WHERE name = 'xlsx';
UPDATE mate_skill SET name_zh = '可见浏览器', name_en = 'Visible Browser' WHERE name = 'browser_visible';
diff --git a/mateclaw-server/src/main/resources/db/data-kingbase-zh.sql b/mateclaw-server/src/main/resources/db/data-kingbase-zh.sql
index d173527d..e90c8f8d 100644
--- a/mateclaw-server/src/main/resources/db/data-kingbase-zh.sql
+++ b/mateclaw-server/src/main/resources/db/data-kingbase-zh.sql
@@ -514,6 +514,10 @@ INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name
VALUES (1000000028, 'ChannelMessageTool', '渠道消息推送', '主动向 IM 渠道会话推送消息。list_channel_sessions 查询可推送的会话,send_channel_message 单向推送消息,用于告警通知、定时任务结果回推等场景。', 'builtin', 'channelMessageTool', '📤', TRUE, TRUE, NOW(), NOW(), 0)
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
+INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+VALUES (1000000029, 'OfficeCliTool', 'OfficeCLI 高级文档', '通过可选的 iOfficeAI/OfficeCLI 二进制检查、校验、复制编辑、模板合并和渲染 DOCX/XLSX/PPTX;所有修改均为副本写入并返回生成文件链接。', 'builtin', 'officeCliTool', '🏢', TRUE, TRUE, NOW(), NOW(), 0)
+ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
+
-- 内置工具:编辑文件(默认启用,危险操作由 ToolGuard 审批控制)
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000006, 'EditFileTool', '编辑文件', '通过查找替换编辑文件内容,精确匹配 old_text 并替换为 new_text。每次执行需要用户审批确认。', 'builtin', 'editFileTool', '✏️', TRUE, TRUE, NOW(), NOW(), 0)
@@ -696,7 +700,11 @@ INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author
VALUES (1000000019, 'multi_agent_collaboration', '当任务需要多个 Agent 的专业能力协同完成时,编排多 Agent 并行或串行协作,整合各方结果。', 'builtin', '🤝', '1.4.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'multi-agent,collaboration,orchestration,parallel', NOW(), NOW(), 0)
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, skill_type=EXCLUDED.skill_type, icon=EXCLUDED.icon, version=EXCLUDED.version, author=EXCLUDED.author, config_json=EXCLUDED.config_json, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
--- RFC-042 §2.2 — bilingual display names for the 19 builtin skills.
+INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted)
+VALUES (1000000020, 'officecli', '使用可选的 iOfficeAI/OfficeCLI 对已有 DOCX/XLSX/PPTX 进行检查、校验、复制编辑、模板合并和视觉渲染。', 'builtin', '🏢', '1.0.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'office,officecli,docx,xlsx,pptx,render,validate', NOW(), NOW(), 0)
+ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, skill_type=EXCLUDED.skill_type, icon=EXCLUDED.icon, version=EXCLUDED.version, author=EXCLUDED.author, config_json=EXCLUDED.config_json, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
+
+-- RFC-042 §2.2 — bilingual display names for the 20 builtin skills.
-- Identical across all four data-*.sql files because name_zh / name_en are
-- permanent attributes, not locale-conditional. The UI picks which one to
-- show based on the active i18n locale and falls back to name when null.
@@ -707,6 +715,7 @@ UPDATE mate_skill SET name_zh = '邮件管理', name_en = 'Email (Himalaya
UPDATE mate_skill SET name_zh = '新闻查询', name_en = 'News' WHERE name = 'news';
UPDATE mate_skill SET name_zh = 'PDF 处理', name_en = 'PDF' WHERE name = 'pdf';
UPDATE mate_skill SET name_zh = 'Word 文档', name_en = 'Word Document' WHERE name = 'docx';
+UPDATE mate_skill SET name_zh = 'OfficeCLI 高级文档', name_en = 'OfficeCLI Advanced Documents' WHERE name = 'officecli';
UPDATE mate_skill SET name_zh = 'PPT 演示', name_en = 'PowerPoint' WHERE name = 'pptx';
UPDATE mate_skill SET name_zh = 'Excel 表格', name_en = 'Excel' WHERE name = 'xlsx';
UPDATE mate_skill SET name_zh = '可见浏览器', name_en = 'Visible Browser' WHERE name = 'browser_visible';
diff --git a/mateclaw-server/src/main/resources/db/data-mysql-en.sql b/mateclaw-server/src/main/resources/db/data-mysql-en.sql
index 15954fad..a751e3dd 100644
--- a/mateclaw-server/src/main/resources/db/data-mysql-en.sql
+++ b/mateclaw-server/src/main/resources/db/data-mysql-en.sql
@@ -528,6 +528,10 @@ INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name
VALUES (1000000028, 'ChannelMessageTool', 'Channel Message Push', 'Proactively push messages to IM channel conversations. list_channel_sessions discovers pushable conversations; send_channel_message performs a one-way push — for alerts, reminders, and async task results.', 'builtin', 'channelMessageTool', '📤', TRUE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
+INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+VALUES (1000000029, 'OfficeCliTool', 'OfficeCLI Advanced Documents', 'Inspect, validate, copy-edit, merge, and render DOCX/XLSX/PPTX through the optional iOfficeAI/OfficeCLI binary. Mutations are copy-on-write and return generated-file links.', 'builtin', 'officeCliTool', '🏢', TRUE, TRUE, NOW(), NOW(), 0)
+ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
+
-- Built-in tool: Edit File (enabled by default, dangerous ops controlled by ToolGuard)
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000006, 'EditFileTool', 'Edit File', 'Edit file content via find-and-replace. Matches old_text exactly and replaces with new_text. Requires user approval.', 'builtin', 'editFileTool', '✏️', TRUE, TRUE, NOW(), NOW(), 0)
@@ -757,7 +761,11 @@ INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author
VALUES (1000000019, 'multi_agent_collaboration', 'When a task requires the professional capabilities of multiple Agents, orchestrate parallel or serial multi-agent collaboration and integrate results.', 'builtin', '🤝', '1.4.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'multi-agent,collaboration,orchestration,parallel', NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), skill_type=VALUES(skill_type), icon=VALUES(icon), version=VALUES(version), author=VALUES(author), config_json=VALUES(config_json), enabled=VALUES(enabled), builtin=VALUES(builtin), tags=VALUES(tags), update_time=VALUES(update_time), deleted=VALUES(deleted);
--- RFC-042 §2.2 — bilingual display names for the 19 builtin skills.
+INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted)
+VALUES (1000000020, 'officecli', 'Use optional iOfficeAI/OfficeCLI for advanced inspection, validation, copy editing, template merge, and visual rendering of existing DOCX/XLSX/PPTX files.', 'builtin', '🏢', '1.0.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'office,officecli,docx,xlsx,pptx,render,validate', NOW(), NOW(), 0)
+ON DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), skill_type=VALUES(skill_type), icon=VALUES(icon), version=VALUES(version), author=VALUES(author), config_json=VALUES(config_json), enabled=VALUES(enabled), builtin=VALUES(builtin), tags=VALUES(tags), update_time=VALUES(update_time), deleted=VALUES(deleted);
+
+-- RFC-042 §2.2 — bilingual display names for the 20 builtin skills.
-- Identical across all four data-*.sql files because name_zh / name_en are
-- permanent attributes, not locale-conditional. The UI picks which one to
-- show based on the active i18n locale and falls back to `name` when null.
@@ -768,6 +776,7 @@ UPDATE mate_skill SET name_zh = '邮件管理', name_en = 'Email (Himalaya
UPDATE mate_skill SET name_zh = '新闻查询', name_en = 'News' WHERE name = 'news';
UPDATE mate_skill SET name_zh = 'PDF 处理', name_en = 'PDF' WHERE name = 'pdf';
UPDATE mate_skill SET name_zh = 'Word 文档', name_en = 'Word Document' WHERE name = 'docx';
+UPDATE mate_skill SET name_zh = 'OfficeCLI 高级文档', name_en = 'OfficeCLI Advanced Documents' WHERE name = 'officecli';
UPDATE mate_skill SET name_zh = 'PPT 演示', name_en = 'PowerPoint' WHERE name = 'pptx';
UPDATE mate_skill SET name_zh = 'Excel 表格', name_en = 'Excel' WHERE name = 'xlsx';
UPDATE mate_skill SET name_zh = '可见浏览器', name_en = 'Visible Browser' WHERE name = 'browser_visible';
diff --git a/mateclaw-server/src/main/resources/db/data-mysql-zh.sql b/mateclaw-server/src/main/resources/db/data-mysql-zh.sql
index b45ae676..22987e54 100644
--- a/mateclaw-server/src/main/resources/db/data-mysql-zh.sql
+++ b/mateclaw-server/src/main/resources/db/data-mysql-zh.sql
@@ -523,6 +523,10 @@ INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name
VALUES (1000000028, 'ChannelMessageTool', '渠道消息推送', '主动向 IM 渠道会话推送消息。list_channel_sessions 查询可推送的会话,send_channel_message 单向推送消息,用于告警通知、定时任务结果回推等场景。', 'builtin', 'channelMessageTool', '📤', TRUE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
+INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+VALUES (1000000029, 'OfficeCliTool', 'OfficeCLI 高级文档', '通过可选的 iOfficeAI/OfficeCLI 二进制检查、校验、复制编辑、模板合并和渲染 DOCX/XLSX/PPTX;所有修改均为副本写入并返回生成文件链接。', 'builtin', 'officeCliTool', '🏢', TRUE, TRUE, NOW(), NOW(), 0)
+ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
+
-- 内置工具:编辑文件(默认启用,危险操作由 ToolGuard 审批控制)
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000006, 'EditFileTool', '编辑文件', '通过查找替换编辑文件内容,精确匹配 old_text 并替换为 new_text。每次执行需要用户审批确认。', 'builtin', 'editFileTool', '✏️', TRUE, TRUE, NOW(), NOW(), 0)
@@ -754,7 +758,11 @@ INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author
VALUES (1000000019, 'multi_agent_collaboration', '当任务需要多个 Agent 的专业能力协同完成时,编排多 Agent 并行或串行协作,整合各方结果。', 'builtin', '🤝', '1.4.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'multi-agent,collaboration,orchestration,parallel', NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), skill_type=VALUES(skill_type), icon=VALUES(icon), version=VALUES(version), author=VALUES(author), config_json=VALUES(config_json), enabled=VALUES(enabled), builtin=VALUES(builtin), tags=VALUES(tags), update_time=VALUES(update_time), deleted=VALUES(deleted);
--- RFC-042 §2.2 — bilingual display names for the 19 builtin skills.
+INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted)
+VALUES (1000000020, 'officecli', '使用可选的 iOfficeAI/OfficeCLI 对已有 DOCX/XLSX/PPTX 进行检查、校验、复制编辑、模板合并和视觉渲染。', 'builtin', '🏢', '1.0.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'office,officecli,docx,xlsx,pptx,render,validate', NOW(), NOW(), 0)
+ON DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), skill_type=VALUES(skill_type), icon=VALUES(icon), version=VALUES(version), author=VALUES(author), config_json=VALUES(config_json), enabled=VALUES(enabled), builtin=VALUES(builtin), tags=VALUES(tags), update_time=VALUES(update_time), deleted=VALUES(deleted);
+
+-- RFC-042 §2.2 — bilingual display names for the 20 builtin skills.
-- Identical across all four data-*.sql files because name_zh / name_en are
-- permanent attributes, not locale-conditional. The UI picks which one to
-- show based on the active i18n locale and falls back to `name` when null.
@@ -765,6 +773,7 @@ UPDATE mate_skill SET name_zh = '邮件管理', name_en = 'Email (Himalaya
UPDATE mate_skill SET name_zh = '新闻查询', name_en = 'News' WHERE name = 'news';
UPDATE mate_skill SET name_zh = 'PDF 处理', name_en = 'PDF' WHERE name = 'pdf';
UPDATE mate_skill SET name_zh = 'Word 文档', name_en = 'Word Document' WHERE name = 'docx';
+UPDATE mate_skill SET name_zh = 'OfficeCLI 高级文档', name_en = 'OfficeCLI Advanced Documents' WHERE name = 'officecli';
UPDATE mate_skill SET name_zh = 'PPT 演示', name_en = 'PowerPoint' WHERE name = 'pptx';
UPDATE mate_skill SET name_zh = 'Excel 表格', name_en = 'Excel' WHERE name = 'xlsx';
UPDATE mate_skill SET name_zh = '可见浏览器', name_en = 'Visible Browser' WHERE name = 'browser_visible';
diff --git a/mateclaw-server/src/main/resources/db/data-zh.sql b/mateclaw-server/src/main/resources/db/data-zh.sql
index 9ed92ccb..f527ecf6 100644
--- a/mateclaw-server/src/main/resources/db/data-zh.sql
+++ b/mateclaw-server/src/main/resources/db/data-zh.sql
@@ -476,6 +476,10 @@ MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name,
KEY (id)
VALUES (1000000028, 'ChannelMessageTool', '渠道消息推送', '主动向 IM 渠道会话推送消息。list_channel_sessions 查询可推送的会话,send_channel_message 单向推送消息,用于告警通知、定时任务结果回推等场景。', 'builtin', 'channelMessageTool', '📤', TRUE, TRUE, NOW(), NOW(), 0);
+MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+KEY (id)
+VALUES (1000000029, 'OfficeCliTool', 'OfficeCLI 高级文档', '通过可选的 iOfficeAI/OfficeCLI 二进制检查、校验、复制编辑、模板合并和渲染 DOCX/XLSX/PPTX;所有修改均为副本写入并返回生成文件链接。', 'builtin', 'officeCliTool', '🏢', TRUE, TRUE, NOW(), NOW(), 0);
+
-- 内置工具:编辑文件(默认启用,危险操作由 ToolGuard 审批控制)
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)
@@ -708,7 +712,11 @@ MERGE INTO mate_skill (id, name, description, skill_type, icon, version, author,
KEY (id)
VALUES (1000000019, 'multi_agent_collaboration', '当任务需要多个 Agent 的专业能力协同完成时,编排多 Agent 并行或串行协作,整合各方结果。', 'builtin', '🤝', '1.4.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'multi-agent,collaboration,orchestration,parallel', NOW(), NOW(), 0);
--- RFC-042 §2.2 — bilingual display names for the 19 builtin skills.
+MERGE INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted)
+KEY (id)
+VALUES (1000000020, 'officecli', '使用可选的 iOfficeAI/OfficeCLI 对已有 DOCX/XLSX/PPTX 进行检查、校验、复制编辑、模板合并和视觉渲染。', 'builtin', '🏢', '1.0.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'office,officecli,docx,xlsx,pptx,render,validate', NOW(), NOW(), 0);
+
+-- RFC-042 §2.2 — bilingual display names for the 20 builtin skills.
-- Identical across all four data-*.sql files because name_zh / name_en are
-- permanent attributes, not locale-conditional. The UI picks which one to
-- show based on the active i18n locale and falls back to `name` when null.
@@ -719,6 +727,7 @@ UPDATE mate_skill SET name_zh = '邮件管理', name_en = 'Email (Himalaya
UPDATE mate_skill SET name_zh = '新闻查询', name_en = 'News' WHERE name = 'news';
UPDATE mate_skill SET name_zh = 'PDF 处理', name_en = 'PDF' WHERE name = 'pdf';
UPDATE mate_skill SET name_zh = 'Word 文档', name_en = 'Word Document' WHERE name = 'docx';
+UPDATE mate_skill SET name_zh = 'OfficeCLI 高级文档', name_en = 'OfficeCLI Advanced Documents' WHERE name = 'officecli';
UPDATE mate_skill SET name_zh = 'PPT 演示', name_en = 'PowerPoint' WHERE name = 'pptx';
UPDATE mate_skill SET name_zh = 'Excel 表格', name_en = 'Excel' WHERE name = 'xlsx';
UPDATE mate_skill SET name_zh = '可见浏览器', name_en = 'Visible Browser' WHERE name = 'browser_visible';
diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V185__register_officecli_support.sql b/mateclaw-server/src/main/resources/db/migration/h2/V185__register_officecli_support.sql
new file mode 100644
index 00000000..9a7a43c1
--- /dev/null
+++ b/mateclaw-server/src/main/resources/db/migration/h2/V185__register_officecli_support.sql
@@ -0,0 +1,10 @@
+-- Issue #583 / RFC-048: optional iOfficeAI/OfficeCLI advanced Office engine.
+MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+KEY (id)
+VALUES (1000000029, 'OfficeCliTool', 'OfficeCLI Advanced Documents', 'Inspect, validate, copy-edit, merge, and render DOCX/XLSX/PPTX through the optional iOfficeAI/OfficeCLI binary. Mutations are copy-on-write and return generated-file links.', 'builtin', 'officeCliTool', '🏢', TRUE, TRUE, NOW(), NOW(), 0);
+
+MERGE INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted)
+KEY (id)
+VALUES (1000000020, 'officecli', 'Use optional iOfficeAI/OfficeCLI for advanced inspection, validation, copy editing, template merge, and visual rendering of existing DOCX/XLSX/PPTX files.', 'builtin', '🏢', '1.0.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'office,officecli,docx,xlsx,pptx,render,validate', NOW(), NOW(), 0);
+
+UPDATE mate_skill SET name_zh = 'OfficeCLI 高级文档', name_en = 'OfficeCLI Advanced Documents' WHERE name = 'officecli';
diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V185__register_officecli_support.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V185__register_officecli_support.sql
new file mode 100644
index 00000000..1bf6e837
--- /dev/null
+++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V185__register_officecli_support.sql
@@ -0,0 +1,10 @@
+-- Issue #583 / RFC-048: optional iOfficeAI/OfficeCLI advanced Office engine.
+INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+VALUES (1000000029, 'OfficeCliTool', 'OfficeCLI Advanced Documents', 'Inspect, validate, copy-edit, merge, and render DOCX/XLSX/PPTX through the optional iOfficeAI/OfficeCLI binary. Mutations are copy-on-write and return generated-file links.', 'builtin', 'officeCliTool', '🏢', TRUE, TRUE, NOW(), NOW(), 0)
+ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
+
+INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted)
+VALUES (1000000020, 'officecli', 'Use optional iOfficeAI/OfficeCLI for advanced inspection, validation, copy editing, template merge, and visual rendering of existing DOCX/XLSX/PPTX files.', 'builtin', '🏢', '1.0.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'office,officecli,docx,xlsx,pptx,render,validate', NOW(), NOW(), 0)
+ON CONFLICT (id) DO UPDATE SET description=EXCLUDED.description, skill_type=EXCLUDED.skill_type, icon=EXCLUDED.icon, version=EXCLUDED.version, author=EXCLUDED.author, config_json=EXCLUDED.config_json, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
+
+UPDATE mate_skill SET name_zh = 'OfficeCLI 高级文档', name_en = 'OfficeCLI Advanced Documents' WHERE name = 'officecli';
diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V185__register_officecli_support.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V185__register_officecli_support.sql
new file mode 100644
index 00000000..256c4e2c
--- /dev/null
+++ b/mateclaw-server/src/main/resources/db/migration/mysql/V185__register_officecli_support.sql
@@ -0,0 +1,10 @@
+-- Issue #583 / RFC-048: optional iOfficeAI/OfficeCLI advanced Office engine.
+INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+VALUES (1000000029, 'OfficeCliTool', 'OfficeCLI Advanced Documents', 'Inspect, validate, copy-edit, merge, and render DOCX/XLSX/PPTX through the optional iOfficeAI/OfficeCLI binary. Mutations are copy-on-write and return generated-file links.', 'builtin', 'officeCliTool', '🏢', TRUE, TRUE, NOW(), NOW(), 0)
+ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
+
+INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted)
+VALUES (1000000020, 'officecli', 'Use optional iOfficeAI/OfficeCLI for advanced inspection, validation, copy editing, template merge, and visual rendering of existing DOCX/XLSX/PPTX files.', 'builtin', '🏢', '1.0.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'office,officecli,docx,xlsx,pptx,render,validate', NOW(), NOW(), 0)
+ON DUPLICATE KEY UPDATE description=VALUES(description), skill_type=VALUES(skill_type), icon=VALUES(icon), version=VALUES(version), author=VALUES(author), config_json=VALUES(config_json), enabled=VALUES(enabled), builtin=VALUES(builtin), tags=VALUES(tags), update_time=VALUES(update_time), deleted=VALUES(deleted);
+
+UPDATE mate_skill SET name_zh = 'OfficeCLI 高级文档', name_en = 'OfficeCLI Advanced Documents' WHERE name = 'officecli';
diff --git a/mateclaw-server/src/main/resources/skills/officecli/SKILL.md b/mateclaw-server/src/main/resources/skills/officecli/SKILL.md
new file mode 100644
index 00000000..e8c0e108
--- /dev/null
+++ b/mateclaw-server/src/main/resources/skills/officecli/SKILL.md
@@ -0,0 +1,96 @@
+---
+name: officecli
+version: "1.0.0"
+description: "Use the optional iOfficeAI/OfficeCLI engine for advanced inspection, validation, copy-on-write editing, template merge, or visual rendering of existing .docx, .xlsx, and .pptx files. Prefer MateClaw's built-in renderDocx/renderXlsx/renderPptx tools for simple new documents. Use this skill when preserving an existing template, modifying complex Office structure, checking formatting issues, validating OpenXML, or rendering a document for visual QA. This integration targets https://github.com/iOfficeAI/OfficeCLI, not the unrelated prompt-generation project with the same name."
+requires:
+ - key: officecli
+ type: binary
+ check: officecli
+ description: "iOfficeAI/OfficeCLI executable on the MateClaw server"
+ install:
+ macos: "brew install officecli"
+ linux: "Install a pinned iOfficeAI/OfficeCLI release and verify its SHA256"
+ windows: "scoop install officecli"
+dependencies:
+ tools:
+ - office_document
+platforms:
+ - macos
+ - linux
+ - windows
+---
+
+# OfficeCLI advanced Office operations
+
+This skill supplements MateClaw's native Office renderers. It never replaces them.
+
+## Routing
+
+| User intent | Use |
+|---|---|
+| Create a simple new document from Markdown | `renderDocx`, `renderXlsx`, or `renderPptx` |
+| Inspect an existing Office file | `office_document(action="inspect")` |
+| Validate OpenXML structure | `office_document(action="validate")` |
+| Apply several structured changes | `office_document(action="batch")` |
+| Fill an existing template's placeholders | `office_document(action="merge")` |
+| Render for visual QA | `office_document(action="render")` |
+
+## Safety contract
+
+- `batch` and `merge` operate on a private copy and never overwrite the source.
+- The tool only accepts `.docx`, `.xlsx`, and `.pptx` inputs inside the active workspace or current chat uploads.
+- Do not install OfficeCLI from inside a chat. If the dependency is missing, explain that an administrator must install it on the MateClaw server.
+- Do not fall back to arbitrary shell commands when `office_document` rejects an action.
+- Return the generated markdown link verbatim so the user can download and preview the result.
+
+## Operations
+
+### Inspect
+
+Use one of `outline`, `stats`, `issues`, `text`, or `annotated`:
+
+```text
+office_document(action="inspect", filePath="report.docx", mode="issues")
+```
+
+### Validate
+
+```text
+office_document(action="validate", filePath="workbook.xlsx")
+```
+
+### Batch edit
+
+`payload` must be a non-empty OfficeCLI batch JSON array. Prefer stable element IDs returned by inspection over positional paths when available.
+
+```text
+office_document(
+ action="batch",
+ filePath="deck.pptx",
+ payload="[{\"command\":\"set\",\"path\":\"/slide[1]/shape[@id=42]\",\"props\":{\"text\":\"Updated\"}}]",
+ outputFilename="deck-updated.pptx"
+)
+```
+
+### Template merge
+
+`payload` must be a non-empty JSON object:
+
+```text
+office_document(
+ action="merge",
+ filePath="invoice-template.docx",
+ payload="{\"client\":\"Acme\",\"total\":\"$5,200\"}",
+ outputFilename="invoice-acme.docx"
+)
+```
+
+### Render
+
+Use `html`, `screenshot`, `svg`, or `pdf`. Prefer `screenshot` for visual QA.
+
+```text
+office_document(action="render", filePath="deck.pptx", mode="screenshot")
+```
+
+After rendering, inspect the returned preview before claiming that layout or formatting is correct.
diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/OfficeCliToolTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/OfficeCliToolTest.java
new file mode 100644
index 00000000..f7efdb47
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/OfficeCliToolTest.java
@@ -0,0 +1,241 @@
+package vip.mate.tool.builtin;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import vip.mate.tool.document.GeneratedFileCache;
+import vip.mate.tool.guard.WorkspacePathGuard;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/** Regression coverage for the optional OfficeCLI adapter introduced by issue #583. */
+class OfficeCliToolTest {
+
+ private static final Pattern GENERATED_ID = Pattern.compile(
+ "/api/v1/files/generated/([a-zA-Z0-9-]+)");
+
+ @TempDir
+ Path tempDir;
+
+ private final ObjectMapper mapper = new ObjectMapper();
+ private GeneratedFileCache cache;
+ private Path fakeCli;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ WorkspacePathGuard.setDefaultRoot(tempDir.toString());
+ cache = new GeneratedFileCache(tempDir.resolve("cache"));
+ fakeCli = tempDir.resolve("officecli-fake");
+ Files.writeString(fakeCli, """
+ #!/bin/sh
+ command="$1"
+ shift
+ case "$command" in
+ batch)
+ file="$1"
+ printf '\nEDITED-BY-OFFICECLI' >> "$file"
+ printf '{"ok":true,"command":"batch"}\n'
+ ;;
+ merge)
+ input="$1"
+ output="$2"
+ cp "$input" "$output"
+ printf '\nMERGED-BY-OFFICECLI' >> "$output"
+ printf '{"ok":true,"command":"merge"}\n'
+ ;;
+ validate)
+ printf '{"valid":true}\n'
+ ;;
+ view)
+ input="$1"
+ mode="$2"
+ shift 2
+ output=""
+ while [ "$#" -gt 0 ]; do
+ if [ "$1" = "-o" ]; then output="$2"; shift 2; else shift; fi
+ done
+ if [ -n "$output" ]; then
+ printf 'rendered:%s' "$mode" > "$output"
+ elif [ "$mode" = "html" ] || [ "$mode" = "svg" ]; then
+ printf 'rendered:%s' "$mode"
+ else
+ printf '{"mode":"%s","source":"%s"}\n' "$mode" "$input"
+ fi
+ ;;
+ *)
+ printf 'unsupported fake command\n' >&2
+ exit 2
+ ;;
+ esac
+ """, StandardCharsets.UTF_8);
+ assertTrue(fakeCli.toFile().setExecutable(true));
+ }
+
+ @AfterEach
+ void tearDown() {
+ WorkspacePathGuard.setDefaultRoot(null);
+ }
+
+ @Test
+ @DisplayName("batch edits a scratch copy, preserves the source, and returns a cached download")
+ void batchIsCopyOnWriteAndDownloadable() throws Exception {
+ byte[] original = "PK-original-docx".getBytes(StandardCharsets.UTF_8);
+ Path source = Files.write(tempDir.resolve("source.docx"), original);
+ OfficeCliTool tool = new OfficeCliTool(cache, mapper, fakeCli.toString());
+
+ String result = tool.office_document(
+ "batch", source.toString(), null,
+ "[{\"command\":\"set\",\"path\":\"/body/p[1]\",\"props\":{\"text\":\"new\"}}]",
+ "客户/报告.docx", 10, null);
+
+ JsonNode json = mapper.readTree(result);
+ assertTrue(json.path("success").asBoolean(), result);
+ assertArrayEquals(original, Files.readAllBytes(source), "source document must never be mutated");
+
+ GeneratedFileCache.Entry entry = cachedEntry(json.path("generatedFile").asText());
+ assertEquals("客户_报告.docx", entry.filename());
+ assertTrue(new String(entry.bytes(), StandardCharsets.UTF_8).contains("EDITED-BY-OFFICECLI"));
+ }
+
+ @Test
+ @DisplayName("render returns the requested preview artifact through GeneratedFileCache")
+ void renderProducesDownload() throws Exception {
+ Path source = Files.writeString(tempDir.resolve("slides.pptx"), "PK-pptx");
+ OfficeCliTool tool = new OfficeCliTool(cache, mapper, fakeCli.toString());
+
+ String result = tool.office_document(
+ "render", source.toString(), "screenshot", null,
+ "预览.png", 10, null);
+
+ JsonNode json = mapper.readTree(result);
+ assertTrue(json.path("success").asBoolean(), result);
+ GeneratedFileCache.Entry entry = cachedEntry(json.path("generatedFile").asText());
+ assertEquals("预览.png", entry.filename());
+ assertEquals("image/png", entry.mimeType());
+ assertEquals("rendered:screenshot", new String(entry.bytes(), StandardCharsets.UTF_8));
+ }
+
+ @Test
+ @DisplayName("stdout render modes are captured as artifacts instead of diagnostics")
+ void stdoutRenderProducesDownload() throws Exception {
+ Path source = Files.writeString(tempDir.resolve("slides.pptx"), "PK-pptx");
+ OfficeCliTool tool = new OfficeCliTool(cache, mapper, fakeCli.toString());
+
+ JsonNode result = mapper.readTree(tool.office_document(
+ "render", source.toString(), "svg", null, null, 10, null));
+
+ assertTrue(result.path("success").asBoolean(), result.toString());
+ GeneratedFileCache.Entry entry = cachedEntry(result.path("generatedFile").asText());
+ assertEquals("image/svg+xml", entry.mimeType());
+ assertEquals("rendered:svg", new String(entry.bytes(), StandardCharsets.UTF_8));
+ assertEquals("", result.path("stdout").asText());
+ }
+
+ @Test
+ @DisplayName("read-only validation returns structured stdout without generating a file")
+ void validateIsReadOnly() throws Exception {
+ Path source = Files.writeString(tempDir.resolve("book.xlsx"), "PK-xlsx");
+ OfficeCliTool tool = new OfficeCliTool(cache, mapper, fakeCli.toString());
+
+ JsonNode result = mapper.readTree(tool.office_document(
+ "validate", source.toString(), null, null, null, 10, null));
+
+ assertTrue(result.path("success").asBoolean());
+ assertTrue(result.path("stdout").asText().contains("\"valid\":true"));
+ assertFalse(result.has("generatedFile"));
+ }
+
+ @Test
+ @DisplayName("invalid payloads and unsupported formats fail before a subprocess mutates anything")
+ void rejectsInvalidRequests() throws Exception {
+ Path office = Files.writeString(tempDir.resolve("a.docx"), "PK-docx");
+ Path text = Files.writeString(tempDir.resolve("a.txt"), "text");
+ OfficeCliTool tool = new OfficeCliTool(cache, mapper, fakeCli.toString());
+
+ JsonNode badPayload = mapper.readTree(tool.office_document(
+ "batch", office.toString(), null, "{}", null, 10, null));
+ JsonNode badFormat = mapper.readTree(tool.office_document(
+ "validate", text.toString(), null, null, null, 10, null));
+
+ assertFalse(badPayload.path("success").asBoolean());
+ assertTrue(badPayload.path("error").asText().contains("JSON array"));
+ assertFalse(badFormat.path("success").asBoolean());
+ assertTrue(badFormat.path("error").asText().contains(".docx"));
+ }
+
+ @Test
+ @DisplayName("batch rejects raw XML, external media, and host filesystem references")
+ void rejectsUnsafeBatchSurface() throws Exception {
+ Path office = Files.writeString(tempDir.resolve("a.docx"), "PK-docx");
+ OfficeCliTool tool = new OfficeCliTool(cache, mapper, fakeCli.toString());
+
+ JsonNode raw = mapper.readTree(tool.office_document(
+ "batch", office.toString(), null,
+ "[{\"command\":\"raw-set\",\"path\":\"/body\",\"xml\":\"\"}]",
+ null, 10, null));
+ JsonNode media = mapper.readTree(tool.office_document(
+ "batch", office.toString(), null,
+ "[{\"command\":\"add\",\"path\":\"/body\",\"type\":\"image\",\"props\":{\"source\":\"/etc/passwd\"}}]",
+ null, 10, null));
+ JsonNode hostPath = mapper.readTree(tool.office_document(
+ "batch", office.toString(), null,
+ "[{\"command\":\"set\",\"path\":\"/body/p[1]\",\"props\":{\"source\":\"C:\\\\secret.txt\"}}]",
+ null, 10, null));
+
+ assertTrue(raw.path("error").asText().contains("Unsupported batch command"));
+ assertTrue(media.path("error").asText().contains("External media"));
+ assertTrue(hostPath.path("error").asText().contains("filesystem reference"));
+ }
+
+ @Test
+ @DisplayName("timeout terminates the native process and reports a bounded failure")
+ void timeoutIsEnforced() throws Exception {
+ Path sleepy = tempDir.resolve("officecli-sleepy");
+ Files.writeString(sleepy, "#!/bin/sh\nsleep 10\n", StandardCharsets.UTF_8);
+ assertTrue(sleepy.toFile().setExecutable(true));
+ Path source = Files.writeString(tempDir.resolve("slow.docx"), "PK-docx");
+ OfficeCliTool tool = new OfficeCliTool(cache, mapper, sleepy.toString());
+
+ long started = System.nanoTime();
+ JsonNode result = mapper.readTree(tool.office_document(
+ "validate", source.toString(), null, null, null, 1, null));
+ long elapsedMillis = (System.nanoTime() - started) / 1_000_000;
+
+ assertFalse(result.path("success").asBoolean());
+ assertTrue(result.path("timedOut").asBoolean());
+ assertTrue(elapsedMillis < 5_000, "timeout took too long: " + elapsedMillis + "ms");
+ }
+
+ @Test
+ @DisplayName("a missing binary is reported as setup-required instead of an opaque exception")
+ void missingBinaryReportsSetupRequired() throws Exception {
+ Path source = Files.writeString(tempDir.resolve("a.docx"), "PK-docx");
+ OfficeCliTool tool = new OfficeCliTool(cache, mapper,
+ tempDir.resolve("missing-officecli").toString());
+
+ JsonNode result = mapper.readTree(tool.office_document(
+ "validate", source.toString(), null, null, null, 10, null));
+
+ assertFalse(result.path("success").asBoolean());
+ assertTrue(result.path("setupRequired").asBoolean(), result.toString());
+ }
+
+ private GeneratedFileCache.Entry cachedEntry(String generatedFileText) {
+ Matcher matcher = GENERATED_ID.matcher(generatedFileText);
+ assertTrue(matcher.find(), generatedFileText);
+ return cache.get(matcher.group(1)).orElseThrow();
+ }
+}