feat(office): add optional OfficeCLI engine (#583)

This commit is contained in:
mateaix 2026-08-17 20:28:40 +08:00
parent 81c6f4aece
commit d2df5c2797
12 changed files with 911 additions and 6 deletions

View File

@ -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).
*
* <p>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.</p>
*/
@Slf4j
@Component
public class OfficeCliTool {
private static final Set<String> OFFICE_EXTENSIONS = Set.of("docx", "xlsx", "pptx");
private static final Set<String> INSPECT_MODES = Set.of(
"outline", "stats", "issues", "text", "annotated");
private static final Map<String, String> 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<String> args)
throws IOException, InterruptedException {
return run(workingDir, timeoutSeconds, args, null);
}
private ProcessResult run(Path workingDir, int timeoutSeconds, List<String> args,
@Nullable Path stdoutArtifact)
throws IOException, InterruptedException {
List<String> 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<String> 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) { }
}

View File

@ -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';

View File

@ -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';

View File

@ -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';

View File

@ -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';

View File

@ -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';

View File

@ -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';

View File

@ -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';

View File

@ -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';

View File

@ -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';

View File

@ -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.

View File

@ -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\":\"<x/>\"}]",
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();
}
}