统一技能安装与 Skills 市场接入链路。

新增公开安装工具与多来源 provider 支持,补齐管理端与目录加载逻辑,并同步更新相关测试与文档以保证可见性和路径安全。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-30 22:17:50 +08:00
parent d5e30542aa
commit 4d9232f3b3
66 changed files with 7369 additions and 1099 deletions

View file

@ -0,0 +1,8 @@
"""
Module office pour OpenClawd
Gestion des opérations LibreOffice
"""
from .soffice import get_soffice_env, run_soffice
__all__ = ['get_soffice_env', 'run_soffice']

View file

@ -0,0 +1,211 @@
"""
Helper pour exécuter LibreOffice (soffice) dans des environnements
où les sockets AF_UNIX peuvent être bloqués (VMs sandboxées).
Usage:
from office.soffice import run_soffice, get_soffice_env
# Option 1 – exécuter soffice directement
result = run_soffice(["--headless", "--convert-to", "pdf", "input.docx"])
# Option 2 – obtenir env dict pour vos propres appels subprocess
env = get_soffice_env()
subprocess.run(["soffice", ...], env=env)
"""
import os
import socket
import subprocess
import tempfile
from pathlib import Path
def get_soffice_env() -> dict:
"""Retourne un env dict adapté pour exécuter soffice en headless.
Définit toujours SAL_USE_VCLPLUGIN=svp pour le rendu headless (pas de X11).
Dans les environnements sandboxés où AF_UNIX est bloqué, ajoute aussi
LD_PRELOAD (socket shim).
"""
env = os.environ.copy()
env["SAL_USE_VCLPLUGIN"] = "svp"
if _needs_shim():
shim = _ensure_shim()
env["LD_PRELOAD"] = str(shim)
return env
def run_soffice(args: list, **kwargs) -> subprocess.CompletedProcess:
"""Exécute soffice avec les arguments donnés, appliquant le socket shim
si nécessaire. Accepte les mêmes arguments que subprocess.run.
Dans les environnements sandboxés, le shim gère l'arrêt propre en appelant
_exit(0) quand le socket listener de soffice.bin se ferme (après conversion).
"""
env = get_soffice_env()
return subprocess.run(["soffice"] + args, env=env, **kwargs)
# ---------------------------------------------------------------------------
# Internals
# ---------------------------------------------------------------------------
_SHIM_SO = Path(tempfile.gettempdir()) / "lo_socket_shim.so"
def _needs_shim() -> bool:
"""Vérifie si les sockets AF_UNIX sont bloqués."""
try:
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.close()
return False
except OSError:
return True
def _ensure_shim() -> Path:
"""Compile le shim .so s'il n'est pas déjà en cache."""
if _SHIM_SO.exists():
return _SHIM_SO
src = Path(tempfile.gettempdir()) / "lo_socket_shim.c"
src.write_text(_SHIM_SOURCE)
try:
subprocess.run(
["gcc", "-shared", "-fPIC", "-o", str(_SHIM_SO), str(src), "-ldl"],
check=True,
capture_output=True,
)
except (subprocess.CalledProcessError, FileNotFoundError):
# Si gcc n'est pas disponible ou échoue, on continue sans shim
pass
finally:
if src.exists():
src.unlink()
return _SHIM_SO
# ---------------------------------------------------------------------------
# LD_PRELOAD shim – source C
#
# Problème
# --------
# LibreOffice utilise des sockets AF_UNIX pour la gestion single-instance
# (OSL_PIPE). Dans les environnements sandboxés, le filtre seccomp bloque
# socket(AF_UNIX) tout en permettant socketpair(AF_UNIX). Sans ce shim,
# soffice crash ou reste bloqué après conversion.
#
# Solution
# --------
# Intercepte les appels concernés et fournit des substituts fonctionnels.
# ---------------------------------------------------------------------------
_SHIM_SOURCE = r"""
#define _GNU_SOURCE
#include <dlfcn.h>
#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <unistd.h>
static int (*real_socket)(int, int, int);
static int (*real_socketpair)(int, int, int, int[2]);
static int (*real_listen)(int, int);
static int (*real_accept)(int, struct sockaddr *, socklen_t *);
static int (*real_close)(int);
static int (*real_read)(int, void *, size_t);
static int is_shimmed[1024];
static int peer_of[1024];
static int wake_r[1024];
static int wake_w[1024];
static int listener_fd = -1;
__attribute__((constructor))
static void init(void) {
real_socket = dlsym(RTLD_NEXT, "socket");
real_socketpair = dlsym(RTLD_NEXT, "socketpair");
real_listen = dlsym(RTLD_NEXT, "listen");
real_accept = dlsym(RTLD_NEXT, "accept");
real_close = dlsym(RTLD_NEXT, "close");
real_read = dlsym(RTLD_NEXT, "read");
for (int i = 0; i < 1024; i++) {
peer_of[i] = -1;
wake_r[i] = -1;
wake_w[i] = -1;
}
}
int socket(int domain, int type, int protocol) {
if (domain == AF_UNIX) {
int fd = real_socket(domain, type, protocol);
if (fd >= 0) return fd;
int sv[2];
if (real_socketpair(domain, type, protocol, sv) == 0) {
if (sv[0] >= 0 && sv[0] < 1024) {
is_shimmed[sv[0]] = 1;
peer_of[sv[0]] = sv[1];
int wp[2];
if (pipe(wp) == 0) {
wake_r[sv[0]] = wp[0];
wake_w[sv[0]] = wp[1];
}
}
return sv[0];
}
errno = EPERM;
return -1;
}
return real_socket(domain, type, protocol);
}
int listen(int sockfd, int backlog) {
if (sockfd >= 0 && sockfd < 1024 && is_shimmed[sockfd]) {
listener_fd = sockfd;
return 0;
}
return real_listen(sockfd, backlog);
}
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen) {
if (sockfd >= 0 && sockfd < 1024 && is_shimmed[sockfd]) {
if (wake_r[sockfd] >= 0) {
char buf;
real_read(wake_r[sockfd], &buf, 1);
}
errno = ECONNABORTED;
return -1;
}
return real_accept(sockfd, addr, addrlen);
}
int close(int fd) {
if (fd >= 0 && fd < 1024 && is_shimmed[fd]) {
int was_listener = (fd == listener_fd);
is_shimmed[fd] = 0;
if (wake_w[fd] >= 0) {
char c = 0;
write(wake_w[fd], &c, 1);
real_close(wake_w[fd]);
wake_w[fd] = -1;
}
if (wake_r[fd] >= 0) { real_close(wake_r[fd]); wake_r[fd] = -1; }
if (peer_of[fd] >= 0) { real_close(peer_of[fd]); peer_of[fd] = -1; }
if (was_listener)
_exit(0);
}
return real_close(fd);
}
"""
if __name__ == "__main__":
import sys
result = run_soffice(sys.argv[1:])
sys.exit(result.returncode)

View file

@ -0,0 +1,225 @@
#!/usr/bin/env python3
"""
Script de recalcul des formules Excel
Recalcule toutes les formules d'un fichier Excel via LibreOffice
Adapté pour OpenClawd
"""
import json
import os
import platform
import subprocess
import sys
from pathlib import Path
try:
from office.soffice import get_soffice_env
except ImportError:
# Fallback si le module office n'est pas disponible
def get_soffice_env():
env = os.environ.copy()
env["SAL_USE_VCLPLUGIN"] = "svp"
return env
try:
from openpyxl import load_workbook
except ImportError:
print("Erreur: openpyxl non installé. Exécuter: pip install openpyxl")
sys.exit(1)
# Répertoire macro LibreOffice selon plateforme
MACRO_DIR_MACOS = "~/Library/Application Support/LibreOffice/4/user/basic/Standard"
MACRO_DIR_LINUX = "~/.config/libreoffice/4/user/basic/Standard"
MACRO_FILENAME = "Module1.xba"
# Macro LibreOffice Basic pour recalcul
RECALCULATE_MACRO = """<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE script:module PUBLIC "-//OpenOffice.org//DTD OfficeDocument 1.0//EN" "module.dtd">
<script:module xmlns:script="http://openoffice.org/2000/script" script:name="Module1" script:language="StarBasic">
Sub RecalculateAndSave()
ThisComponent.calculateAll()
ThisComponent.store()
ThisComponent.close(True)
End Sub
</script:module>"""
def has_gtimeout():
"""Vérifie si gtimeout est disponible sur macOS"""
try:
subprocess.run(
["gtimeout", "--version"], capture_output=True, timeout=1, check=False
)
return True
except (FileNotFoundError, subprocess.TimeoutExpired):
return False
def setup_libreoffice_macro():
"""Configure la macro LibreOffice si pas déjà fait"""
macro_dir = os.path.expanduser(
MACRO_DIR_MACOS if platform.system() == "Darwin" else MACRO_DIR_LINUX
)
macro_file = os.path.join(macro_dir, MACRO_FILENAME)
# Vérifier si macro existe déjà
if (
os.path.exists(macro_file)
and "RecalculateAndSave" in Path(macro_file).read_text()
):
return True
# Créer répertoire macro si nécessaire
if not os.path.exists(macro_dir):
try:
subprocess.run(
["soffice", "--headless", "--terminate_after_init"],
capture_output=True,
timeout=10,
env=get_soffice_env(),
)
except Exception:
pass
os.makedirs(macro_dir, exist_ok=True)
# Écrire fichier macro
try:
Path(macro_file).write_text(RECALCULATE_MACRO)
return True
except Exception:
return False
def recalc(filename, timeout=30):
"""
Recalcule les formules d'un fichier Excel et rapporte les erreurs
Args:
filename: Chemin vers le fichier Excel
timeout: Temps max d'attente pour le recalcul (secondes)
Returns:
dict avec emplacements et compteurs d'erreurs
"""
if not Path(filename).exists():
return {"error": f"Fichier {filename} inexistant"}
abs_path = str(Path(filename).absolute())
if not setup_libreoffice_macro():
return {"error": "Échec configuration macro LibreOffice"}
cmd = [
"soffice",
"--headless",
"--norestore",
"vnd.sun.star.script:Standard.Module1.RecalculateAndSave?language=Basic&location=application",
abs_path,
]
# Encapsuler avec timeout si disponible
if platform.system() == "Linux":
cmd = ["timeout", str(timeout)] + cmd
elif platform.system() == "Darwin" and has_gtimeout():
cmd = ["gtimeout", str(timeout)] + cmd
try:
result = subprocess.run(cmd, capture_output=True, text=True, env=get_soffice_env(), timeout=timeout+10)
except subprocess.TimeoutExpired:
return {"error": "Timeout lors du recalcul"}
if result.returncode != 0 and result.returncode != 124: # 124 = code timeout
error_msg = result.stderr or "Erreur inconnue lors du recalcul"
if "Module1" in error_msg or "RecalculateAndSave" not in error_msg:
return {"error": "Macro LibreOffice mal configurée"}
return {"error": error_msg}
# Vérifier erreurs Excel dans le fichier recalculé
try:
wb = load_workbook(filename, data_only=True)
excel_errors = [
"#VALUE!",
"#DIV/0!",
"#REF!",
"#NAME?",
"#NULL!",
"#NUM!",
"#N/A",
]
error_details = {err: [] for err in excel_errors}
total_errors = 0
for sheet_name in wb.sheetnames:
ws = wb[sheet_name]
for row in ws.iter_rows():
for cell in row:
if cell.value is not None and isinstance(cell.value, str):
for err in excel_errors:
if err in cell.value:
location = f"{sheet_name}!{cell.coordinate}"
error_details[err].append(location)
total_errors += 1
break
wb.close()
# Construire résumé
result = {
"status": "success" if total_errors == 0 else "errors_found",
"total_errors": total_errors,
"error_summary": {},
}
# Ajouter catégories d'erreurs non vides
for err_type, locations in error_details.items():
if locations:
result["error_summary"][err_type] = {
"count": len(locations),
"locations": locations[:20], # Max 20 emplacements affichés
}
# Ajouter compte de formules
wb_formulas = load_workbook(filename, data_only=False)
formula_count = 0
for sheet_name in wb_formulas.sheetnames:
ws = wb_formulas[sheet_name]
for row in ws.iter_rows():
for cell in row:
if (
cell.value
and isinstance(cell.value, str)
and cell.value.startswith("=")
):
formula_count += 1
wb_formulas.close()
result["total_formulas"] = formula_count
return result
except Exception as e:
return {"error": str(e)}
def main():
if len(sys.argv) < 2:
print("Usage: python recalc.py <fichier_excel> [timeout_secondes]")
print("\nRecalcule toutes les formules d'un fichier Excel via LibreOffice")
print("\nRetourne JSON avec détails d'erreurs:")
print(" - status: 'success' ou 'errors_found'")
print(" - total_errors: Nombre total d'erreurs Excel")
print(" - total_formulas: Nombre de formules dans le fichier")
print(" - error_summary: Détail par type avec emplacements")
print(" - #VALUE!, #DIV/0!, #REF!, #NAME?, #NULL!, #NUM!, #N/A")
sys.exit(1)
filename = sys.argv[1]
timeout = int(sys.argv[2]) if len(sys.argv) > 2 else 30
result = recalc(filename, timeout)
print(json.dumps(result, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()