@nomad-e/bluma-cli 0.1.17 → 0.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +62 -12
  2. package/dist/config/native_tools.json +7 -0
  3. package/dist/config/skills/git-commit/LICENSE.txt +18 -0
  4. package/dist/config/skills/git-commit/SKILL.md +258 -0
  5. package/dist/config/skills/git-commit/references/REFERENCE.md +249 -0
  6. package/dist/config/skills/git-commit/scripts/validate_commit_msg.py +163 -0
  7. package/dist/config/skills/git-pr/LICENSE.txt +18 -0
  8. package/dist/config/skills/git-pr/SKILL.md +293 -0
  9. package/dist/config/skills/git-pr/references/REFERENCE.md +256 -0
  10. package/dist/config/skills/git-pr/scripts/validate_commits.py +112 -0
  11. package/dist/config/skills/pdf/LICENSE.txt +26 -0
  12. package/dist/config/skills/pdf/SKILL.md +327 -0
  13. package/dist/config/skills/pdf/references/FORMS.md +69 -0
  14. package/dist/config/skills/pdf/references/REFERENCE.md +52 -0
  15. package/dist/config/skills/pdf/scripts/create_report.py +59 -0
  16. package/dist/config/skills/pdf/scripts/merge_pdfs.py +39 -0
  17. package/dist/config/skills/skill-creator/LICENSE.txt +26 -0
  18. package/dist/config/skills/skill-creator/SKILL.md +229 -0
  19. package/dist/config/skills/xlsx/LICENSE.txt +18 -0
  20. package/dist/config/skills/xlsx/SKILL.md +298 -0
  21. package/dist/config/skills/xlsx/references/REFERENCE.md +337 -0
  22. package/dist/config/skills/xlsx/scripts/office/__init__.py +2 -0
  23. package/dist/config/skills/xlsx/scripts/office/__pycache__/__init__.cpython-312.pyc +0 -0
  24. package/dist/config/skills/xlsx/scripts/office/__pycache__/pack.cpython-312.pyc +0 -0
  25. package/dist/config/skills/xlsx/scripts/office/__pycache__/soffice.cpython-312.pyc +0 -0
  26. package/dist/config/skills/xlsx/scripts/office/__pycache__/unpack.cpython-312.pyc +0 -0
  27. package/dist/config/skills/xlsx/scripts/office/__pycache__/validate.cpython-312.pyc +0 -0
  28. package/dist/config/skills/xlsx/scripts/office/pack.py +58 -0
  29. package/dist/config/skills/xlsx/scripts/office/soffice.py +180 -0
  30. package/dist/config/skills/xlsx/scripts/office/unpack.py +63 -0
  31. package/dist/config/skills/xlsx/scripts/office/validate.py +122 -0
  32. package/dist/config/skills/xlsx/scripts/recalc.py +143 -0
  33. package/dist/main.js +275 -89
  34. package/package.json +1 -1
  35. package/dist/config/example.bluma-mcp.json.txt +0 -14
  36. package/dist/config/models_config.json +0 -78
  37. package/dist/skills/git-conventional/LICENSE.txt +0 -3
  38. package/dist/skills/git-conventional/SKILL.md +0 -83
  39. package/dist/skills/skill-creator/SKILL.md +0 -495
  40. package/dist/skills/testing/LICENSE.txt +0 -3
  41. package/dist/skills/testing/SKILL.md +0 -114
@@ -0,0 +1,180 @@
1
+ """
2
+ LibreOffice process management for sandboxed and standard environments.
3
+
4
+ Handles:
5
+ - Finding the soffice binary across platforms
6
+ - Running recalculation macros in headless mode
7
+ - Configuring Unix sockets for restricted environments
8
+ """
9
+
10
+ import os
11
+ import platform
12
+ import shutil
13
+ import subprocess
14
+ import tempfile
15
+
16
+
17
+ SOFFICE_SEARCH_PATHS = [
18
+ '/usr/bin/soffice',
19
+ '/usr/bin/libreoffice',
20
+ '/usr/lib/libreoffice/program/soffice',
21
+ '/snap/bin/libreoffice',
22
+ '/opt/libreoffice/program/soffice',
23
+ ]
24
+
25
+ MACOS_PATHS = [
26
+ '/Applications/LibreOffice.app/Contents/MacOS/soffice',
27
+ os.path.expanduser('~/Applications/LibreOffice.app/Contents/MacOS/soffice'),
28
+ ]
29
+
30
+
31
+ def find_soffice() -> str | None:
32
+ """Find the soffice binary on this system."""
33
+ found = shutil.which('soffice') or shutil.which('libreoffice')
34
+ if found:
35
+ return found
36
+
37
+ search = MACOS_PATHS if platform.system() == 'Darwin' else SOFFICE_SEARCH_PATHS
38
+
39
+ for path in search:
40
+ if os.path.isfile(path) and os.access(path, os.X_OK):
41
+ return path
42
+
43
+ return None
44
+
45
+
46
+ def _prepare_user_profile(base_dir: str) -> str:
47
+ """Create a temporary user profile to avoid lock conflicts."""
48
+ profile_dir = os.path.join(base_dir, 'libreoffice-profile')
49
+ os.makedirs(profile_dir, exist_ok=True)
50
+ return profile_dir
51
+
52
+
53
+ def _build_macro_content(filepath: str) -> str:
54
+ """Build a LibreOffice Basic macro that recalculates all sheets."""
55
+ abs_path = os.path.abspath(filepath)
56
+ url = 'file://' + abs_path.replace(' ', '%20')
57
+
58
+ return f"""
59
+ Sub RecalcAndSave
60
+ Dim oDoc As Object
61
+ Dim oSheets As Object
62
+ Dim oSheet As Object
63
+ Dim i As Integer
64
+
65
+ oDoc = StarDesktop.loadComponentFromURL( _
66
+ "{url}", "_blank", 0, _
67
+ Array(MakePropertyValue("Hidden", True), _
68
+ MakePropertyValue("MacroExecutionMode", 4)))
69
+
70
+ If IsNull(oDoc) Or IsEmpty(oDoc) Then
71
+ MsgBox "Failed to open document"
72
+ Exit Sub
73
+ End If
74
+
75
+ oSheets = oDoc.getSheets()
76
+ For i = 0 To oSheets.getCount() - 1
77
+ oSheet = oSheets.getByIndex(i)
78
+ Next i
79
+
80
+ oDoc.calculateAll()
81
+ oDoc.calculateAll()
82
+
83
+ oDoc.store()
84
+ oDoc.close(True)
85
+ End Sub
86
+
87
+ Function MakePropertyValue(sName As String, vValue As Variant) As com.sun.star.beans.PropertyValue
88
+ Dim oProperty As New com.sun.star.beans.PropertyValue
89
+ oProperty.Name = sName
90
+ oProperty.Value = vValue
91
+ MakePropertyValue = oProperty
92
+ End Function
93
+ """
94
+
95
+
96
+ def run_soffice_recalc(
97
+ soffice_path: str,
98
+ filepath: str,
99
+ timeout: int = 60,
100
+ ) -> tuple[bool, str]:
101
+ """
102
+ Recalculate an Excel file using LibreOffice headless mode.
103
+
104
+ Returns (success: bool, message: str).
105
+ """
106
+ abs_path = os.path.abspath(filepath)
107
+
108
+ if not os.path.isfile(abs_path):
109
+ return False, f'File not found: {abs_path}'
110
+
111
+ with tempfile.TemporaryDirectory(prefix='bluma-lo-') as tmp_dir:
112
+ profile_dir = _prepare_user_profile(tmp_dir)
113
+
114
+ macro_content = _build_macro_content(abs_path)
115
+ macro_dir = os.path.join(
116
+ profile_dir, 'user', 'basic', 'Standard'
117
+ )
118
+ os.makedirs(macro_dir, exist_ok=True)
119
+
120
+ macro_file = os.path.join(macro_dir, 'BluMaRecalc.xba')
121
+ with open(macro_file, 'w') as f:
122
+ f.write('<?xml version="1.0" encoding="UTF-8"?>\n')
123
+ f.write('<!DOCTYPE script:module PUBLIC "-//OpenOffice.org//DTD OfficeDocument 1.0//EN" "module.dtd">\n')
124
+ f.write('<script:module xmlns:script="http://openoffice.org/2000/script" ')
125
+ f.write('script:name="BluMaRecalc" script:language="StarBasic">\n')
126
+ f.write(macro_content)
127
+ f.write('\n</script:module>\n')
128
+
129
+ dialog_lc = os.path.join(macro_dir, 'dialog.xlc')
130
+ with open(dialog_lc, 'w') as f:
131
+ f.write('<?xml version="1.0" encoding="UTF-8"?>\n')
132
+ f.write('<!DOCTYPE library:library PUBLIC "-//OpenOffice.org//DTD OfficeDocument 1.0//EN" "library.dtd">\n')
133
+ f.write('<library:library xmlns:library="http://openoffice.org/2000/library" ')
134
+ f.write('library:name="Standard" library:readonly="false" library:passwordprotected="false">\n')
135
+ f.write('</library:library>\n')
136
+
137
+ script_lc = os.path.join(macro_dir, 'script.xlc')
138
+ with open(script_lc, 'w') as f:
139
+ f.write('<?xml version="1.0" encoding="UTF-8"?>\n')
140
+ f.write('<!DOCTYPE library:library PUBLIC "-//OpenOffice.org//DTD OfficeDocument 1.0//EN" "library.dtd">\n')
141
+ f.write('<library:library xmlns:library="http://openoffice.org/2000/library" ')
142
+ f.write('library:name="Standard" library:readonly="false" library:passwordprotected="false">\n')
143
+ f.write(f' <library:element library:name="BluMaRecalc"/>\n')
144
+ f.write('</library:library>\n')
145
+
146
+ env = os.environ.copy()
147
+ env['HOME'] = tmp_dir
148
+ env['SAL_USE_VCLPLUGIN'] = 'gen'
149
+
150
+ cmd = [
151
+ soffice_path,
152
+ '--headless',
153
+ '--invisible',
154
+ '--nologo',
155
+ '--norestore',
156
+ f'-env:UserInstallation=file://{profile_dir}',
157
+ f'macro:///Standard.BluMaRecalc.RecalcAndSave',
158
+ ]
159
+
160
+ try:
161
+ proc = subprocess.run(
162
+ cmd,
163
+ capture_output=True,
164
+ text=True,
165
+ timeout=timeout,
166
+ env=env,
167
+ )
168
+
169
+ if proc.returncode != 0:
170
+ stderr = proc.stderr.strip()[:500] if proc.stderr else 'No error output'
171
+ return False, f'LibreOffice exited with code {proc.returncode}: {stderr}'
172
+
173
+ return True, 'Recalculation completed'
174
+
175
+ except subprocess.TimeoutExpired:
176
+ return False, f'LibreOffice timed out after {timeout} seconds'
177
+ except FileNotFoundError:
178
+ return False, f'soffice binary not found at: {soffice_path}'
179
+ except Exception as e:
180
+ return False, f'Unexpected error: {str(e)}'
@@ -0,0 +1,63 @@
1
+ """
2
+ Unpack an Office Open XML file (.xlsx/.docx/.pptx) into a directory.
3
+
4
+ This allows direct manipulation of the internal XML structure, which is
5
+ useful for repairs, validation, or modifications not possible through
6
+ standard libraries.
7
+ """
8
+
9
+ import os
10
+ import zipfile
11
+
12
+
13
+ def unpack(filepath: str, dest_dir: str | None = None) -> str:
14
+ """
15
+ Extract an Office file into a directory.
16
+
17
+ Args:
18
+ filepath: Path to the .xlsx/.docx/.pptx file.
19
+ dest_dir: Destination directory. If None, creates one next to
20
+ the source file with '_unpacked' suffix.
21
+
22
+ Returns:
23
+ Absolute path to the unpacked directory.
24
+ """
25
+ abs_path = os.path.abspath(filepath)
26
+
27
+ if not os.path.isfile(abs_path):
28
+ raise FileNotFoundError(f'File not found: {abs_path}')
29
+
30
+ if dest_dir is None:
31
+ base = os.path.splitext(abs_path)[0]
32
+ dest_dir = base + '_unpacked'
33
+
34
+ abs_dest = os.path.abspath(dest_dir)
35
+ os.makedirs(abs_dest, exist_ok=True)
36
+
37
+ try:
38
+ with zipfile.ZipFile(abs_path, 'r') as zf:
39
+ zf.extractall(abs_dest)
40
+ except zipfile.BadZipFile:
41
+ raise ValueError(f'Not a valid Office/ZIP file: {abs_path}')
42
+
43
+ return abs_dest
44
+
45
+
46
+ def list_contents(filepath: str) -> list[dict]:
47
+ """
48
+ List the internal structure of an Office file.
49
+
50
+ Returns a list of dicts with 'name', 'size', and 'compressed_size'.
51
+ """
52
+ abs_path = os.path.abspath(filepath)
53
+
54
+ with zipfile.ZipFile(abs_path, 'r') as zf:
55
+ return [
56
+ {
57
+ 'name': info.filename,
58
+ 'size': info.file_size,
59
+ 'compressed_size': info.compress_size,
60
+ }
61
+ for info in zf.infolist()
62
+ if not info.is_dir()
63
+ ]
@@ -0,0 +1,122 @@
1
+ """
2
+ Validate the internal structure of Office Open XML files.
3
+
4
+ Checks that required parts exist, relationships are consistent,
5
+ and the ZIP structure is well-formed. Does NOT validate against
6
+ full ISO/IEC 29500 schemas (that would require lxml + full schema set).
7
+ """
8
+
9
+ import os
10
+ import zipfile
11
+
12
+ REQUIRED_PARTS_XLSX = [
13
+ '[Content_Types].xml',
14
+ '_rels/.rels',
15
+ 'xl/workbook.xml',
16
+ 'xl/_rels/workbook.xml.rels',
17
+ 'xl/styles.xml',
18
+ ]
19
+
20
+ REQUIRED_PARTS_DOCX = [
21
+ '[Content_Types].xml',
22
+ '_rels/.rels',
23
+ 'word/document.xml',
24
+ 'word/_rels/document.xml.rels',
25
+ 'word/styles.xml',
26
+ ]
27
+
28
+ REQUIRED_PARTS_PPTX = [
29
+ '[Content_Types].xml',
30
+ '_rels/.rels',
31
+ 'ppt/presentation.xml',
32
+ 'ppt/_rels/presentation.xml.rels',
33
+ ]
34
+
35
+ FILE_TYPE_MAP = {
36
+ '.xlsx': ('xlsx', REQUIRED_PARTS_XLSX),
37
+ '.xlsm': ('xlsx', REQUIRED_PARTS_XLSX),
38
+ '.docx': ('docx', REQUIRED_PARTS_DOCX),
39
+ '.pptx': ('pptx', REQUIRED_PARTS_PPTX),
40
+ }
41
+
42
+
43
+ def validate(filepath: str) -> dict:
44
+ """
45
+ Validate an Office file's internal structure.
46
+
47
+ Returns a dict with:
48
+ - valid (bool): Whether the file passes validation
49
+ - file_type (str): Detected type (xlsx/docx/pptx)
50
+ - parts_count (int): Number of parts in the archive
51
+ - missing_parts (list): Required parts that are missing
52
+ - warnings (list): Non-critical issues
53
+ - errors (list): Critical issues
54
+ """
55
+ abs_path = os.path.abspath(filepath)
56
+ ext = os.path.splitext(abs_path)[1].lower()
57
+
58
+ result = {
59
+ 'valid': True,
60
+ 'file_type': None,
61
+ 'parts_count': 0,
62
+ 'missing_parts': [],
63
+ 'warnings': [],
64
+ 'errors': [],
65
+ }
66
+
67
+ if not os.path.isfile(abs_path):
68
+ result['valid'] = False
69
+ result['errors'].append(f'File not found: {abs_path}')
70
+ return result
71
+
72
+ if ext not in FILE_TYPE_MAP:
73
+ result['valid'] = False
74
+ result['errors'].append(f'Unsupported extension: {ext}')
75
+ return result
76
+
77
+ file_type, required_parts = FILE_TYPE_MAP[ext]
78
+ result['file_type'] = file_type
79
+
80
+ try:
81
+ with zipfile.ZipFile(abs_path, 'r') as zf:
82
+ names = set(zf.namelist())
83
+ result['parts_count'] = len(names)
84
+
85
+ for part in required_parts:
86
+ if part not in names:
87
+ result['missing_parts'].append(part)
88
+
89
+ corrupt = zf.testzip()
90
+ if corrupt:
91
+ result['errors'].append(f'Corrupt entry: {corrupt}')
92
+
93
+ except zipfile.BadZipFile:
94
+ result['valid'] = False
95
+ result['errors'].append('Not a valid ZIP file')
96
+ return result
97
+ except Exception as e:
98
+ result['valid'] = False
99
+ result['errors'].append(f'Error reading file: {str(e)}')
100
+ return result
101
+
102
+ if result['missing_parts']:
103
+ result['valid'] = False
104
+ result['errors'].append(
105
+ f"Missing {len(result['missing_parts'])} required part(s)"
106
+ )
107
+
108
+ for name in names:
109
+ if name.startswith('..') or '/../' in name:
110
+ result['valid'] = False
111
+ result['errors'].append(f'Path traversal detected: {name}')
112
+
113
+ if result['parts_count'] == 0:
114
+ result['valid'] = False
115
+ result['errors'].append('Archive is empty')
116
+
117
+ if result['parts_count'] > 10000:
118
+ result['warnings'].append(
119
+ f'Unusually large archive: {result["parts_count"]} entries'
120
+ )
121
+
122
+ return result
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Recalculate all formulas in an Excel file using LibreOffice and scan
4
+ for formula errors.
5
+
6
+ Usage:
7
+ python recalc.py <excel_file> [timeout_seconds]
8
+
9
+ Returns JSON to stdout with recalculation status and any errors found.
10
+ Requires LibreOffice installed and openpyxl for error scanning.
11
+
12
+ Exit codes:
13
+ 0 Recalculated successfully (may still have formula errors)
14
+ 1 Recalculation failed
15
+ """
16
+
17
+ import json
18
+ import os
19
+ import sys
20
+ import time
21
+
22
+ SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
23
+ sys.path.insert(0, SCRIPT_DIR)
24
+
25
+ from office.soffice import find_soffice, run_soffice_recalc
26
+
27
+
28
+ ERROR_VALUES = frozenset({
29
+ '#REF!', '#DIV/0!', '#VALUE!', '#N/A', '#NAME?',
30
+ '#NULL!', '#NUM!', '#GETTING_DATA', '#SPILL!', '#CALC!',
31
+ })
32
+
33
+
34
+ def scan_errors_fast(filepath: str) -> dict:
35
+ """Optimized error scanning — loads formula workbook only once."""
36
+ try:
37
+ from openpyxl import load_workbook
38
+ except ImportError:
39
+ return {
40
+ 'status': 'error',
41
+ 'message': 'openpyxl not installed — cannot scan for formula errors',
42
+ }
43
+
44
+ wb_data = load_workbook(filepath, data_only=True)
45
+ wb_formula = load_workbook(filepath)
46
+
47
+ errors = {}
48
+ total_formulas = 0
49
+ sheets_processed = []
50
+
51
+ for sheet_name in wb_data.sheetnames:
52
+ ws_data = wb_data[sheet_name]
53
+ ws_form = wb_formula[sheet_name]
54
+ sheets_processed.append(sheet_name)
55
+
56
+ for row_data, row_form in zip(ws_data.iter_rows(), ws_form.iter_rows()):
57
+ for cell_data, cell_form in zip(row_data, row_form):
58
+ if isinstance(cell_form.value, str) and cell_form.value.startswith('='):
59
+ total_formulas += 1
60
+
61
+ if isinstance(cell_data.value, str) and cell_data.value in ERROR_VALUES:
62
+ err_type = cell_data.value
63
+ if err_type not in errors:
64
+ errors[err_type] = {'count': 0, 'locations': []}
65
+ errors[err_type]['count'] += 1
66
+ loc = f"{sheet_name}!{cell_data.coordinate}"
67
+ if len(errors[err_type]['locations']) < 20:
68
+ errors[err_type]['locations'].append(loc)
69
+
70
+ wb_data.close()
71
+ wb_formula.close()
72
+
73
+ total_errors = sum(e['count'] for e in errors.values())
74
+
75
+ result = {
76
+ 'total_formulas': total_formulas,
77
+ 'total_errors': total_errors,
78
+ 'sheets_processed': sheets_processed,
79
+ }
80
+
81
+ if total_errors > 0:
82
+ result['status'] = 'errors_found'
83
+ result['error_summary'] = errors
84
+ else:
85
+ result['status'] = 'success'
86
+
87
+ return result
88
+
89
+
90
+ def main():
91
+ if len(sys.argv) < 2:
92
+ print(json.dumps({
93
+ 'status': 'error',
94
+ 'message': 'Usage: python recalc.py <excel_file> [timeout_seconds]',
95
+ }))
96
+ sys.exit(1)
97
+
98
+ filepath = os.path.abspath(sys.argv[1])
99
+ timeout = int(sys.argv[2]) if len(sys.argv) > 2 else 60
100
+
101
+ if not os.path.isfile(filepath):
102
+ print(json.dumps({
103
+ 'status': 'error',
104
+ 'message': f'File not found: {filepath}',
105
+ }))
106
+ sys.exit(1)
107
+
108
+ soffice_path = find_soffice()
109
+ if not soffice_path:
110
+ print(json.dumps({
111
+ 'status': 'error',
112
+ 'message': (
113
+ 'LibreOffice not found. Install it with: '
114
+ 'sudo apt install libreoffice-calc (Linux) or '
115
+ 'brew install --cask libreoffice (macOS)'
116
+ ),
117
+ }))
118
+ sys.exit(1)
119
+
120
+ start = time.time()
121
+
122
+ success, message = run_soffice_recalc(soffice_path, filepath, timeout)
123
+
124
+ elapsed = round(time.time() - start, 2)
125
+
126
+ if not success:
127
+ print(json.dumps({
128
+ 'status': 'error',
129
+ 'message': message,
130
+ 'elapsed_seconds': elapsed,
131
+ }))
132
+ sys.exit(1)
133
+
134
+ result = scan_errors_fast(filepath)
135
+ result['elapsed_seconds'] = elapsed
136
+ result['recalc_engine'] = 'LibreOffice'
137
+
138
+ print(json.dumps(result, indent=2))
139
+ sys.exit(0)
140
+
141
+
142
+ if __name__ == '__main__':
143
+ main()