dsh-office-kit 0.4.0 → 0.4.2
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.
- package/assets/skills/excel-live-control/SKILL.md +5 -0
- package/assets/skills/excel-live-control/container_tools/live_excel.py +9 -0
- package/assets/skills/office-documents/tasks/verify_render.md +1 -1
- package/assets/skills/office-documents/troubleshooting/libreoffice_headless.md +7 -5
- package/assets/skills/office-presentations/SKILL.md +3 -3
- package/assets/skills/office-spreadsheets/SKILL.md +1 -1
- package/assets/skills/shared/common-workflow.md +2 -2
- package/dist/index.js +141 -46
- package/package.json +1 -1
- package/postinstall.mjs +58 -17
- package/python/office_cli.py +71 -7
|
@@ -4,6 +4,11 @@ description: Drive a workbook that is already open in Excel or WPS Spreadsheets
|
|
|
4
4
|
|
|
5
5
|
# Live spreadsheets (Excel / WPS)
|
|
6
6
|
|
|
7
|
+
> Windows only. This skill drives an already-open Excel or WPS Spreadsheets window
|
|
8
|
+
> through COM using Windows PowerShell 5.1 (`Marshal.GetActiveObject`, which
|
|
9
|
+
> PowerShell 7 dropped). On macOS and Linux every command exits `3` with
|
|
10
|
+
> `stage: "powershell"` and a remedy; use the file-based `xlsx` verbs there instead.
|
|
11
|
+
|
|
7
12
|
## What "live" means here
|
|
8
13
|
|
|
9
14
|
The workbook is open in a running Excel or WPS Spreadsheets window, and the user is
|
|
@@ -605,6 +605,15 @@ def main() -> int:
|
|
|
605
605
|
except ToolError as error:
|
|
606
606
|
return emit(failure_payload(op, error), 2)
|
|
607
607
|
|
|
608
|
+
# macOS and Linux ship pwsh but no COM, and `shutil.which('powershell')` can find
|
|
609
|
+
# that binary; fail on the platform instead of surfacing a cryptic attach error.
|
|
610
|
+
if sys.platform != "win32":
|
|
611
|
+
return emit(failure_payload(op, ToolError(
|
|
612
|
+
"Excel/WPS live control drives the application through Windows PowerShell 5.1 "
|
|
613
|
+
"COM, which only exists on Windows",
|
|
614
|
+
"powershell",
|
|
615
|
+
)), 3)
|
|
616
|
+
|
|
608
617
|
request = {
|
|
609
618
|
"op": op,
|
|
610
619
|
"launch_hidden": bool(getattr(args, "launch_hidden", False)),
|
|
@@ -32,7 +32,7 @@ The runtime reports `renderers.can_render.docx: false` because the DSH LibreOffi
|
|
|
32
32
|
In that state:
|
|
33
33
|
|
|
34
34
|
- Verify structure with `docx read`, `docx_kit.py parts|text|xml` and the audit scripts.
|
|
35
|
-
- Tell the user plainly that layout was not visually verified, and that the fix is
|
|
35
|
+
- Tell the user plainly that layout was not visually verified, and that the fix is a harness install that ships the kit (the plugin records its path in `config.json` when it loads), not installing Word/LibreOffice.
|
|
36
36
|
- Do not claim a file "looks good" from XML evidence alone.
|
|
37
37
|
|
|
38
38
|
## After any script edit
|
|
@@ -12,17 +12,19 @@ Look at `renderers.kit` and `renderers.can_render`. `can_render.docx: false` mea
|
|
|
12
12
|
|
|
13
13
|
Rendering goes through the LibreOffice kit that DeepSeek Harness ships (`@deepseek-ai/libreoffice-kit`). On macOS and Windows it uses the kit's native engine; on Linux it uses the WASM engine. A separately installed Microsoft Office, WPS Office, or system LibreOffice is **not** used and does not make rendering work on its own.
|
|
14
14
|
|
|
15
|
-
The plugin
|
|
15
|
+
The plugin records the kit's location when the harness loads it, and the helper reads that record before anything else:
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
1. `DSH_LIBREOFFICE_KIT_CLI` — an explicit override, also handy for a hand-run helper.
|
|
18
|
+
2. `libreofficeKit` in `~/.dsh/office/config.json`. The host half writes this at load, resolving the kit from the harness entry point (`process.argv[1]`, e.g. `<dsh>/node_modules/@deepseek-ai/dsh/lib/bin.js`). This is what makes a profile install work, where the kit lives in the harness tree and not next to the plugin. Configs that pre-0.5 versions wrote in the OS config directory are still read as fallbacks.
|
|
19
|
+
3. Walking up from the plugin directory through `node_modules/@deepseek-ai/libreoffice-kit/lib/cli.js` — covers a checkout or a plugin-local dependency.
|
|
20
|
+
|
|
21
|
+
`DSH_OFFICE_CONFIG` points the helper at a different config file; `DSH_NODE` overrides the node binary if `node` is not on `PATH`.
|
|
20
22
|
|
|
21
23
|
## Common failures
|
|
22
24
|
|
|
23
25
|
| Message | Cause | Fix |
|
|
24
26
|
|---|---|---|
|
|
25
|
-
| `the LibreOffice renderer is not available` | Kit not found
|
|
27
|
+
| `the LibreOffice renderer is not available` | Kit not found | Load the plugin through the harness (it records the path), or set `DSH_LIBREOFFICE_KIT_CLI` to the kit's `lib/cli.js` |
|
|
26
28
|
| `cannot start the LibreOffice kit` / `EEXIST` | Interrupted install, or a stale staging directory | Re-run the plugin postinstall; delete leftover `.kit-render-*` directories |
|
|
27
29
|
| `Unsupported LibreOfficeKit host` | Unsupported platform/arch (the kit ships Windows x64/arm64, macOS x64/arm64, Linux via WASM) | Render on a supported host |
|
|
28
30
|
| `warning: N font(s) used by this file are not installed` | Font substitution — the render succeeded but that text may reflow or show boxes | Install the font or substitute it deliberately, then re-render |
|
|
@@ -150,9 +150,9 @@ Guidance: [authoring workflow](references/authoring-workflow.md) ·
|
|
|
150
150
|
## Genuine gaps
|
|
151
151
|
|
|
152
152
|
- The DSH LibreOffice kit missing (`renderers.can_render.pptx: false`) → no true
|
|
153
|
-
render; use `--engine draft` and say the check was geometric.
|
|
154
|
-
|
|
155
|
-
a standalone Office/LibreOffice install does not.
|
|
153
|
+
render; use `--engine draft` and say the check was geometric. A harness install
|
|
154
|
+
that ships the kit fixes it (the plugin records its path in `config.json` when it
|
|
155
|
+
loads); a standalone Office/LibreOffice install does not.
|
|
156
156
|
- Fonts the user's machine lacks are substituted by any renderer; the pack
|
|
157
157
|
cannot install fonts.
|
|
158
158
|
- Google Slides import fidelity is out of our control; see
|
|
@@ -10,7 +10,7 @@ A workbook is cells: each one holds a value, a format, and possibly a formula *s
|
|
|
10
10
|
| --- | --- | --- |
|
|
11
11
|
| Build a new workbook | Cells written top-left at A1; `=`-prefixed strings become formulas; styles are per-cell objects; sheets get names, widths, freeze panes, merges | `xlsx create` spec, `tasks/create-workbook.md` |
|
|
12
12
|
| Change cells in an existing file | Only the cells you name change; a re-save can drop objects openpyxl does not model (pivots, foreign charts) | `container_tools/set_cells.py` and friends, `tasks/edit-workbook.md` |
|
|
13
|
-
| The workbook is open in Excel/WPS and the user wants it changed live | The target is the window, not the file on disk: unsaved user edits are in play and a COM write is not transactional | `../excel-live-control/SKILL.md` |
|
|
13
|
+
| The workbook is open in Excel/WPS and the user wants it changed live | The target is the window, not the file on disk: unsaved user edits are in play and a COM write is not transactional. **Windows only** — on macOS/Linux the live tool exits 3 with a remedy; edit the file instead | `../excel-live-control/SKILL.md` |
|
|
14
14
|
| Inventory a file you did not write | Iterate sheets, used range, formulas vs values, merged ranges, conditional formats, defined names, charts | `container_tools/inspect_workbook.py` |
|
|
15
15
|
| Compare two versions | Cell-by-cell walk with formula-aware, format-aware, structure-aware levels | `container_tools/diff_workbooks.py`, `tasks/diff-workbooks.md` |
|
|
16
16
|
| Prove the numbers are real | Recalculate in Excel, then check every formula cell has a value, no error, no dangling sheet | `xlsx recalc` + `container_tools/recalc_gate.py`, `tasks/recalc-and-check.md` |
|
|
@@ -125,10 +125,10 @@ a blank, clipped or miscoloured raster is invisible inside the host file.
|
|
|
125
125
|
| --- | --- | --- |
|
|
126
126
|
| No `## Office runtime` JSON; skill says *No working Python runtime is available* | The plugin cannot install or find a compatible interpreter | Do not run helper commands, do not write a document by hand. Give the user the remedy command from the skill body, ask them to run it, then call `office_status` again. |
|
|
127
127
|
| `office_status` shows `cli.exists: false`, or `python`/`cli` file not found | The bundled helper or its install is incomplete | Report the missing path. Do not substitute another tool that produces a different file. |
|
|
128
|
-
| `render` fails: `the LibreOffice renderer is not available` | The kit that DSH ships with was not found; create/read/edit still work but docx/pptx/xlsx cannot be rasterised | Do not claim you inspected it. Say the file was built and validated structurally (read verb), but the renderer is unavailable on this host so you could not check its appearance. The kit ships with DeepSeek Harness
|
|
128
|
+
| `render` fails: `the LibreOffice renderer is not available` | The kit that DSH ships with was not found; create/read/edit still work but docx/pptx/xlsx cannot be rasterised | Do not claim you inspected it. Say the file was built and validated structurally (read verb), but the renderer is unavailable on this host so you could not check its appearance. The kit ships with DeepSeek Harness and the plugin records its path in `config.json` when it loads, so this means the harness install lacks the kit or `DSH_LIBREOFFICE_KIT_CLI` points nowhere; a standalone LibreOffice/Office/WPS install does not help. `pdf render` still works. If the user needs visual confidence now, offer to rebuild from a spec or to present the unverified file explicitly labelled as such. |
|
|
129
129
|
| Annotation region does not land on anything, or the payload says `unmapped: true` | The drawn region cannot be tied to an object in the file | Do not silently shift the region or guess a cell/shape. Say what you could identify (preview page, text, resolved cells) and which item you could not locate, then ask the user for the text or cell. |
|
|
130
130
|
| `{"ok": false, "error": "..."}` and non-zero exit | The spec was rejected (unknown key, bad type, missing file) | Read the error text, fix the spec key it names, re-run the same command. Never strip a failing block to make the command pass — that silently deletes user content. |
|
|
131
|
-
| `xlsx recalc` fails because the LibreOffice kit is missing | openpyxl cannot evaluate formulas, and the kit is the only calculation engine this plugin uses | Report formula cells as uncalculated; do not invent computed values. Fix the renderer first (
|
|
131
|
+
| `xlsx recalc` fails because the LibreOffice kit is missing | openpyxl cannot evaluate formulas, and the kit is the only calculation engine this plugin uses | Report formula cells as uncalculated; do not invent computed values. Fix the renderer first (see the row above) and re-run. |
|
|
132
132
|
| The helper has no verb for what the requirement needs | The common path is short; the capability is not limited to it | Take the pack script route, or write a short snippet against `python-docx` / `openpyxl` / `python-pptx` / `pypdf` / `PyMuPDF` / `reportlab`, run it with `<python>`, then `read` the file back and render. Never refuse a job because a verb is missing. |
|
|
133
133
|
| `read` returns an empty structure for a file the user supplied | Unsupported or corrupt input | Say what failed and stop; do not overwrite the user's file with an empty one. |
|
|
134
134
|
|
package/dist/index.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
1
2
|
import { execFile } from "node:child_process";
|
|
2
|
-
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
3
4
|
import { readFile } from "node:fs/promises";
|
|
4
5
|
import { dirname, join } from "node:path";
|
|
5
6
|
import process from "node:process";
|
|
6
|
-
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { promisify } from "node:util";
|
|
8
8
|
import { BUNDLED_SKILL_RANK } from "@deepseek-ai/dsh-skill";
|
|
9
9
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
10
11
|
import { homedir } from "node:os";
|
|
11
12
|
//#region src/features.ts
|
|
12
13
|
/** The four entries, in menu order. */
|
|
@@ -48,6 +49,33 @@ const OFFICE_FEATURES = [
|
|
|
48
49
|
extensions: [".xlsx"]
|
|
49
50
|
}
|
|
50
51
|
];
|
|
52
|
+
//#endregion
|
|
53
|
+
//#region src/kit.ts
|
|
54
|
+
/** Package root of the installed plugin (dist/ during a build, src/ in a checkout). */
|
|
55
|
+
const PACKAGE_ROOT$1 = fileURLToPath(new URL("..", import.meta.url));
|
|
56
|
+
const KIT_CLI = "@deepseek-ai/libreoffice-kit/cli";
|
|
57
|
+
/**
|
|
58
|
+
* Locate the LibreOffice kit CLI that DeepSeek Harness ships.
|
|
59
|
+
*
|
|
60
|
+
* DSH installs third-party plugins into a profile (`~/.dsh/profiles/<name>/node_modules`)
|
|
61
|
+
* while the kit lives in the harness dependency tree, so resolving relative to this
|
|
62
|
+
* package cannot find it. Resolving from the harness entry point (`process.argv[1]`,
|
|
63
|
+
* e.g. `<dsh>/node_modules/@deepseek-ai/dsh/lib/bin.js`) walks the tree the harness
|
|
64
|
+
* itself loaded from, and resolving from this package covers a checkout or a
|
|
65
|
+
* plugin-local install. `DSH_LIBREOFFICE_KIT_CLI` overrides both.
|
|
66
|
+
*/
|
|
67
|
+
function resolveKitCli(entry = process.argv[1]) {
|
|
68
|
+
const override = process.env.DSH_LIBREOFFICE_KIT_CLI;
|
|
69
|
+
if (override != null && existsSync(override)) return override;
|
|
70
|
+
for (const anchor of [entry, join(PACKAGE_ROOT$1, "package.json")]) {
|
|
71
|
+
if (anchor == null) continue;
|
|
72
|
+
try {
|
|
73
|
+
const cli = createRequire(anchor).resolve(KIT_CLI);
|
|
74
|
+
if (existsSync(cli)) return cli;
|
|
75
|
+
} catch {}
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
51
79
|
/** Python packages the helper CLI needs. */
|
|
52
80
|
const PYTHON_PACKAGES = [
|
|
53
81
|
{
|
|
@@ -91,34 +119,41 @@ const PYTHON_PACKAGES = [
|
|
|
91
119
|
package: "mathml2omml"
|
|
92
120
|
}
|
|
93
121
|
];
|
|
94
|
-
const PACKAGE_ROOT
|
|
95
|
-
const POSTINSTALL_SCRIPT = join(PACKAGE_ROOT
|
|
122
|
+
const PACKAGE_ROOT = fileURLToPath(new URL("..", import.meta.url));
|
|
123
|
+
const POSTINSTALL_SCRIPT = join(PACKAGE_ROOT, "postinstall.mjs");
|
|
96
124
|
/** Upper bound for one self-heal run; the remedy command remains available afterwards. */
|
|
97
125
|
const POSTINSTALL_TIMEOUT = 540 * 1e3;
|
|
98
126
|
/** Failed probes are retried after this delay instead of re-running pip on every status call. */
|
|
99
127
|
const FAILURE_RETRY_DELAY = 300 * 1e3;
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
return
|
|
128
|
+
/** Where every version of this plugin keeps its runtime, next to DSH's own data. */
|
|
129
|
+
function officeHome() {
|
|
130
|
+
return join(homedir(), ".dsh", "office");
|
|
103
131
|
}
|
|
104
132
|
function legacyDataDir() {
|
|
105
133
|
const home = homedir();
|
|
106
134
|
return process.platform === "win32" ? join(process.env.LOCALAPPDATA ?? join(home, "AppData", "Local"), "dsh-office-kit") : process.platform === "darwin" ? join(home, "Library", "Application Support", "dsh-office-kit") : join(process.env.XDG_DATA_HOME ?? join(home, ".local", "share"), "dsh-office-kit");
|
|
107
135
|
}
|
|
108
|
-
/**
|
|
109
|
-
|
|
110
|
-
|
|
136
|
+
/**
|
|
137
|
+
* Config files older versions wrote, newest first.
|
|
138
|
+
*
|
|
139
|
+
* The pre-0.5 config stayed in the OS config directory (`%APPDATA%\dsh-office-kit`,
|
|
140
|
+
* `$XDG_CONFIG_HOME/dsh-office-kit`, `~/Library/Application Support/dsh-office-kit`).
|
|
141
|
+
* Reading them keeps an upgraded install from rebuilding or mis-reporting its venv.
|
|
142
|
+
*/
|
|
143
|
+
function legacyConfigFiles() {
|
|
144
|
+
const home = homedir();
|
|
145
|
+
return [.../* @__PURE__ */ new Set([process.platform === "win32" ? join(process.env.APPDATA ?? join(home, "AppData", "Roaming"), "dsh-office-kit", "config.json") : process.platform === "darwin" ? join(home, "Library", "Application Support", "dsh-office-kit", "config.json") : join(process.env.XDG_CONFIG_HOME ?? join(home, ".config"), "dsh-office-kit", "config.json"), join(process.env.APPDATA ?? join(home, "AppData", "Roaming"), "dsh-office-kit", "config.json")])];
|
|
111
146
|
}
|
|
112
147
|
/** Resolve per-OS config/data directories and bundled resources. */
|
|
113
148
|
function officePaths() {
|
|
114
|
-
const runtimeDir = join(
|
|
149
|
+
const runtimeDir = join(officeHome(), "runtime");
|
|
115
150
|
return {
|
|
116
|
-
configFile: join(
|
|
117
|
-
|
|
151
|
+
configFile: join(officeHome(), "config.json"),
|
|
152
|
+
legacyConfigFiles: legacyConfigFiles(),
|
|
118
153
|
venv: join(runtimeDir, "venv"),
|
|
119
154
|
legacyVenv: join(legacyDataDir(), "venv"),
|
|
120
|
-
cli: join(PACKAGE_ROOT
|
|
121
|
-
skillsDir: join(PACKAGE_ROOT
|
|
155
|
+
cli: join(PACKAGE_ROOT, "python", "office_cli.py"),
|
|
156
|
+
skillsDir: join(PACKAGE_ROOT, "assets", "skills")
|
|
122
157
|
};
|
|
123
158
|
}
|
|
124
159
|
/** One-liner the user can run to repair the runtime without pnpm. */
|
|
@@ -216,10 +251,11 @@ function failedInspection(path, error) {
|
|
|
216
251
|
}
|
|
217
252
|
/**
|
|
218
253
|
* Read the interpreter path postinstall recorded; malformed files are ignored.
|
|
219
|
-
* The legacy
|
|
254
|
+
* The legacy locations are read after the canonical one so an upgrade does not
|
|
255
|
+
* orphan a ready venv.
|
|
220
256
|
*/
|
|
221
|
-
function savedPython(configFile,
|
|
222
|
-
const files =
|
|
257
|
+
function savedPython(configFile, legacyConfigFiles = []) {
|
|
258
|
+
const files = [configFile, ...legacyConfigFiles];
|
|
223
259
|
for (const file of files) {
|
|
224
260
|
if (!isFile(file)) continue;
|
|
225
261
|
try {
|
|
@@ -228,6 +264,61 @@ function savedPython(configFile, legacyConfigFile) {
|
|
|
228
264
|
} catch {}
|
|
229
265
|
}
|
|
230
266
|
}
|
|
267
|
+
/**
|
|
268
|
+
* Read the plugin config, falling back to files older versions wrote.
|
|
269
|
+
*
|
|
270
|
+
* The canonical file wins key by key, so a legacy record contributes only what
|
|
271
|
+
* the new file has not written yet (an upgrade usually still needs its venv).
|
|
272
|
+
*/
|
|
273
|
+
function loadConfig(configFile) {
|
|
274
|
+
const legacy = officePaths().legacyConfigFiles;
|
|
275
|
+
const merged = {};
|
|
276
|
+
for (const file of [...legacy].reverse()) try {
|
|
277
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
278
|
+
if (parsed != null && typeof parsed === "object" && !Array.isArray(parsed)) Object.assign(merged, parsed);
|
|
279
|
+
} catch {}
|
|
280
|
+
try {
|
|
281
|
+
const parsed = JSON.parse(readFileSync(configFile, "utf8"));
|
|
282
|
+
if (parsed != null && typeof parsed === "object" && !Array.isArray(parsed)) Object.assign(merged, parsed);
|
|
283
|
+
} catch {}
|
|
284
|
+
return merged;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Merge one key into the plugin's config without disturbing the rest.
|
|
288
|
+
*
|
|
289
|
+
* Versions that predate the current layout left stale absolute paths behind, so
|
|
290
|
+
* the record is refreshed whenever the host resolves something different.
|
|
291
|
+
*/
|
|
292
|
+
function saveRecord(configFile, key, value) {
|
|
293
|
+
const config = loadConfig(configFile);
|
|
294
|
+
if (JSON.stringify(config[key]) === JSON.stringify(value) && isFile(configFile)) return;
|
|
295
|
+
const temporary = `${configFile}.${process.pid}.tmp`;
|
|
296
|
+
try {
|
|
297
|
+
mkdirSync(dirname(configFile), { recursive: true });
|
|
298
|
+
writeFileSync(temporary, `${JSON.stringify({
|
|
299
|
+
...config,
|
|
300
|
+
[key]: value
|
|
301
|
+
}, null, 2)}\n`, "utf8");
|
|
302
|
+
renameSync(temporary, configFile);
|
|
303
|
+
} catch {}
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Record the renderer the harness loaded so every later helper call finds it.
|
|
307
|
+
*
|
|
308
|
+
* A profile install keeps the plugin in `~/.dsh/profiles/<name>/node_modules`
|
|
309
|
+
* and the kit in the harness tree, so no walk-up from the plugin can reach it.
|
|
310
|
+
* The helper reads this record first; a read-only config must not stop loading.
|
|
311
|
+
*/
|
|
312
|
+
function saveKitPath(configFile, node, cli) {
|
|
313
|
+
saveRecord(configFile, "libreofficeKit", {
|
|
314
|
+
node,
|
|
315
|
+
cli
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
/** Record the interpreter this plugin settled on so the next probe can trust it. */
|
|
319
|
+
function savePythonPath(configFile, python) {
|
|
320
|
+
saveRecord(configFile, "python", python);
|
|
321
|
+
}
|
|
231
322
|
/** The interpreter inside a virtual environment, which differs per platform. */
|
|
232
323
|
function venvPython(venv) {
|
|
233
324
|
return join(venv, process.platform === "win32" ? "Scripts/python.exe" : "bin/python");
|
|
@@ -238,14 +329,10 @@ function candidates(configFile) {
|
|
|
238
329
|
const found = [];
|
|
239
330
|
const env = process.env.DSH_OFFICE_PYTHON;
|
|
240
331
|
if (env != null && env.length > 0) found.push(env);
|
|
241
|
-
|
|
332
|
+
found.push(venvPython(paths.venv));
|
|
333
|
+
const saved = savedPython(configFile, paths.legacyConfigFiles);
|
|
242
334
|
if (saved != null) found.push(saved);
|
|
243
|
-
found.push(venvPython(paths.
|
|
244
|
-
const marker = join(PACKAGE_ROOT$1, ".python-path");
|
|
245
|
-
if (isFile(marker)) {
|
|
246
|
-
const text = readFileSync(marker, "utf8").trim();
|
|
247
|
-
if (text.length > 0) found.push(text);
|
|
248
|
-
}
|
|
335
|
+
found.push(venvPython(paths.legacyVenv));
|
|
249
336
|
for (const name of ["python3", "python"]) found.push(name);
|
|
250
337
|
if (process.platform === "win32") {
|
|
251
338
|
for (const version of [
|
|
@@ -316,9 +403,20 @@ async function inspectRuntime(configFile = officePaths().configFile) {
|
|
|
316
403
|
if (expanded !== candidate && !isFile(expanded)) continue;
|
|
317
404
|
const inspection = await inspectPython(expanded);
|
|
318
405
|
seen.push(inspection);
|
|
319
|
-
if (inspection.compatible && inspection.missing.length === 0) return
|
|
406
|
+
if (inspection.compatible && inspection.missing.length === 0) return settledRuntime(seen, configFile);
|
|
320
407
|
}
|
|
321
|
-
return
|
|
408
|
+
return settledRuntime(seen, configFile);
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Freeze the probe result and keep config.json honest about which interpreter won.
|
|
412
|
+
*
|
|
413
|
+
* A venv migrated out of the legacy data directory leaves the old absolute path
|
|
414
|
+
* recorded; rewriting it stops the next launch from probing a dead path first.
|
|
415
|
+
*/
|
|
416
|
+
function settledRuntime(seen, configFile) {
|
|
417
|
+
const status = selectRuntime(seen);
|
|
418
|
+
if (status.state === "ok" && status.python != null && status.python.path !== savedPython(configFile)) savePythonPath(configFile, status.python.path);
|
|
419
|
+
return status;
|
|
322
420
|
}
|
|
323
421
|
/** Run postinstall.mjs as a child process; resolve with an error summary or undefined. */
|
|
324
422
|
function runPostinstall() {
|
|
@@ -405,24 +503,10 @@ function parseSkill(raw, path) {
|
|
|
405
503
|
content: raw.slice(match[0].length).trim()
|
|
406
504
|
};
|
|
407
505
|
}
|
|
408
|
-
const KIT_MISSING = "the DSH LibreOffice kit (@deepseek-ai/libreoffice-kit) was not found:
|
|
409
|
-
const PACKAGE_ROOT = fileURLToPath(new URL("..", import.meta.url));
|
|
506
|
+
const KIT_MISSING = "the DSH LibreOffice kit (@deepseek-ai/libreoffice-kit) was not found: the plugin records where the harness loaded it from in dsh-office-kit's config.json, and DSH_LIBREOFFICE_KIT_CLI overrides that for a plugin running outside the harness";
|
|
410
507
|
const runKit = promisify(execFile);
|
|
411
|
-
/** Locate the kit the same way python/office_cli.py does, so both halves agree. */
|
|
412
|
-
function findKitCli() {
|
|
413
|
-
const override = process.env.DSH_LIBREOFFICE_KIT_CLI;
|
|
414
|
-
if (override != null && existsSync(override)) return override;
|
|
415
|
-
let directory = PACKAGE_ROOT;
|
|
416
|
-
for (;;) {
|
|
417
|
-
const candidate = join(directory, "node_modules", "@deepseek-ai", "libreoffice-kit", "lib", "cli.js");
|
|
418
|
-
if (existsSync(candidate)) return candidate;
|
|
419
|
-
const parent = dirname(directory);
|
|
420
|
-
if (parent === directory) return null;
|
|
421
|
-
directory = parent;
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
508
|
async function kitFacts() {
|
|
425
|
-
const cli =
|
|
509
|
+
const cli = resolveKitCli();
|
|
426
510
|
if (cli == null) return {
|
|
427
511
|
available: false,
|
|
428
512
|
node: null,
|
|
@@ -462,7 +546,7 @@ async function kitFacts() {
|
|
|
462
546
|
}
|
|
463
547
|
function pluginVersion() {
|
|
464
548
|
try {
|
|
465
|
-
const parsed = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8"));
|
|
549
|
+
const parsed = JSON.parse(readFileSync(join(PACKAGE_ROOT$1, "package.json"), "utf8"));
|
|
466
550
|
return typeof parsed.version === "string" ? parsed.version : "unknown";
|
|
467
551
|
} catch {
|
|
468
552
|
return "unknown";
|
|
@@ -480,6 +564,8 @@ function runtimeReason(runtime) {
|
|
|
480
564
|
function apply(ctx) {
|
|
481
565
|
const paths = officePaths();
|
|
482
566
|
const candidates = [];
|
|
567
|
+
const kitCli = resolveKitCli();
|
|
568
|
+
if (kitCli != null) saveKitPath(paths.configFile, process.execPath, kitCli);
|
|
483
569
|
for (const feature of OFFICE_FEATURES) {
|
|
484
570
|
const directory = join(paths.skillsDir, feature.skill);
|
|
485
571
|
const path = join(directory, "SKILL.md");
|
|
@@ -523,12 +609,17 @@ function apply(ctx) {
|
|
|
523
609
|
}
|
|
524
610
|
const parsed = parseSkill(raw, path);
|
|
525
611
|
const runtime = await ensureRuntime(paths.configFile);
|
|
526
|
-
const
|
|
612
|
+
const kitCli = resolveKitCli();
|
|
613
|
+
const pythonLine = runtime.state === "ok" && runtime.python != null ? `\n\n## Office runtime\n\nUse these verified absolute paths for every helper command. Quote paths that contain spaces. \`skill\` is this skill's own directory: every script it names lives at \`<skill>/scripts/...\`; \`shared\` holds the cross-format workflow notes; the helper finds \`renderer\` on its own, so never pass it.\n\n\`\`\`json\n${JSON.stringify({
|
|
527
614
|
python: runtime.python.path,
|
|
528
615
|
cli: paths.cli,
|
|
529
616
|
skill: dirname(path).split("\\").join("/"),
|
|
530
617
|
shared: join(paths.skillsDir, "shared").split("\\").join("/"),
|
|
531
|
-
version: runtime.python.version
|
|
618
|
+
version: runtime.python.version,
|
|
619
|
+
...kitCli == null ? {} : { renderer: {
|
|
620
|
+
kind: "libreoffice-kit",
|
|
621
|
+
cli: kitCli
|
|
622
|
+
} }
|
|
532
623
|
}, null, 2)}\n\`\`\`` : `\n\n## Office runtime\n\nNo working Python runtime is available: ${runtimeReason(runtime)}. Do not run helper commands or claim success. Tell the user to run \`${runtime.remedy}\`, then call \`office_status\` again.`;
|
|
533
624
|
const { rank: _rank, locator: _locator, ...summary } = candidate;
|
|
534
625
|
return {
|
|
@@ -579,6 +670,10 @@ function apply(ctx) {
|
|
|
579
670
|
path: paths.cli,
|
|
580
671
|
exists: cliExists()
|
|
581
672
|
},
|
|
673
|
+
config: {
|
|
674
|
+
path: paths.configFile,
|
|
675
|
+
exists: existsSync(paths.configFile)
|
|
676
|
+
},
|
|
582
677
|
packages: runtime.packages,
|
|
583
678
|
renderers: await kitFacts(),
|
|
584
679
|
skills: candidates.map((candidate) => candidate.name)
|
package/package.json
CHANGED
package/postinstall.mjs
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { spawnSync } from 'node:child_process'
|
|
19
|
-
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
20
20
|
import { homedir, tmpdir } from 'node:os'
|
|
21
21
|
import { dirname, join } from 'node:path'
|
|
22
22
|
import process from 'node:process'
|
|
@@ -35,21 +35,22 @@ const LEGACY_DATA_DIR = IS_WINDOWS
|
|
|
35
35
|
: join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'dsh-office-kit')
|
|
36
36
|
export const LEGACY_VENV_DIR = join(LEGACY_DATA_DIR, 'venv')
|
|
37
37
|
const LEGACY_VENV_PYTHON = join(LEGACY_VENV_DIR, IS_WINDOWS ? 'Scripts/python.exe' : 'bin/python')
|
|
38
|
-
// Must mirror `
|
|
39
|
-
// every runtime probe, so a
|
|
40
|
-
export const CONFIG_DIR =
|
|
41
|
-
? join(process.env.APPDATA || join(homedir(), 'AppData', 'Roaming'), 'dsh-office-kit')
|
|
42
|
-
: process.platform === 'darwin'
|
|
43
|
-
? join(homedir(), 'Library', 'Application Support', 'dsh-office-kit')
|
|
44
|
-
: join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'dsh-office-kit')
|
|
38
|
+
// Must mirror `officePaths()` in src/python.ts: the host half reads this file on
|
|
39
|
+
// every runtime probe, so a mismatch here makes a ready venv look missing.
|
|
40
|
+
export const CONFIG_DIR = join(homedir(), '.dsh', 'office')
|
|
45
41
|
export const CONFIG_FILE = join(CONFIG_DIR, 'config.json')
|
|
46
42
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
43
|
+
* Config files older versions wrote in the OS config directory. Read as
|
|
44
|
+
* fallbacks so an upgraded install is not rebuilt or mis-reported.
|
|
49
45
|
*/
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
46
|
+
const LEGACY_CONFIG_FILES = [...new Set([
|
|
47
|
+
IS_WINDOWS
|
|
48
|
+
? join(process.env.APPDATA || join(homedir(), 'AppData', 'Roaming'), 'dsh-office-kit', 'config.json')
|
|
49
|
+
: process.platform === 'darwin'
|
|
50
|
+
? join(homedir(), 'Library', 'Application Support', 'dsh-office-kit', 'config.json')
|
|
51
|
+
: join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'dsh-office-kit', 'config.json'),
|
|
52
|
+
join(process.env.APPDATA || join(homedir(), 'AppData', 'Roaming'), 'dsh-office-kit', 'config.json'),
|
|
53
|
+
])]
|
|
53
54
|
const REQUIREMENTS = join(PACKAGE_ROOT, 'python', 'requirements.txt')
|
|
54
55
|
const LOG_PREFIX = '[dsh-office-kit]'
|
|
55
56
|
|
|
@@ -230,7 +231,37 @@ function installPython() {
|
|
|
230
231
|
return true
|
|
231
232
|
}
|
|
232
233
|
|
|
234
|
+
/**
|
|
235
|
+
* Move an upgraded install's venv out of the pre-0.4 data directory.
|
|
236
|
+
*
|
|
237
|
+
* Reusing the legacy venv in place kept the old layout alive forever, so the
|
|
238
|
+
* canonical path was only ever reached by fresh installs. A rename is instant
|
|
239
|
+
* and offline; if it fails (another volume, a locked interpreter) the legacy
|
|
240
|
+
* venv stays usable and the next install retries.
|
|
241
|
+
*/
|
|
242
|
+
export function migrateLegacyVenv(legacy = LEGACY_VENV_DIR, target = VENV_DIR) {
|
|
243
|
+
const interpreter = venvPythonIn(legacy)
|
|
244
|
+
if (existsSync(target) || !existsSync(interpreter)) {
|
|
245
|
+
return false
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
249
|
+
renameSync(legacy, target)
|
|
250
|
+
log(`moved the virtual environment from ${legacy} to ${target}`)
|
|
251
|
+
return true
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
warn(`could not move the legacy virtual environment: ${error instanceof Error ? error.message : String(error)}`)
|
|
255
|
+
return false
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function venvPythonIn(directory) {
|
|
260
|
+
return join(directory, IS_WINDOWS ? 'Scripts/python.exe' : 'bin/python')
|
|
261
|
+
}
|
|
262
|
+
|
|
233
263
|
function ensureVenv(base) {
|
|
264
|
+
migrateLegacyVenv()
|
|
234
265
|
const existing = workingVenv()
|
|
235
266
|
if (existing) {
|
|
236
267
|
if (existing.directory === LEGACY_VENV_DIR) {
|
|
@@ -284,18 +315,20 @@ function verifyImports(python) {
|
|
|
284
315
|
}
|
|
285
316
|
|
|
286
317
|
function readConfig() {
|
|
287
|
-
|
|
318
|
+
// Oldest first so the canonical file and the newest legacy record win key by key.
|
|
319
|
+
const merged = {}
|
|
320
|
+
for (const file of [...LEGACY_CONFIG_FILES].reverse().concat(CONFIG_FILE)) {
|
|
288
321
|
if (!existsSync(file)) {
|
|
289
322
|
continue
|
|
290
323
|
}
|
|
291
324
|
try {
|
|
292
|
-
|
|
325
|
+
Object.assign(merged, JSON.parse(readFileSync(file, 'utf8')))
|
|
293
326
|
}
|
|
294
327
|
catch {
|
|
295
|
-
// A malformed file must not hide a usable
|
|
328
|
+
// A malformed file must not hide a usable one.
|
|
296
329
|
}
|
|
297
330
|
}
|
|
298
|
-
return
|
|
331
|
+
return merged
|
|
299
332
|
}
|
|
300
333
|
|
|
301
334
|
function writeConfig(config) {
|
|
@@ -314,6 +347,14 @@ function writeReadyConfig(existingConfig, python, basePython, report) {
|
|
|
314
347
|
updatedAt: new Date().toISOString(),
|
|
315
348
|
imports: report,
|
|
316
349
|
})
|
|
350
|
+
// The canonical file now holds everything the legacy files had; a pre-0.5 file
|
|
351
|
+
// that stays behind only makes "where is the config" ambiguous.
|
|
352
|
+
for (const file of LEGACY_CONFIG_FILES) {
|
|
353
|
+
try {
|
|
354
|
+
rmSync(file, { force: true })
|
|
355
|
+
}
|
|
356
|
+
catch {}
|
|
357
|
+
}
|
|
317
358
|
}
|
|
318
359
|
|
|
319
360
|
function main() {
|
package/python/office_cli.py
CHANGED
|
@@ -93,22 +93,86 @@ KIT_RELATIVE = Path("node_modules") / "@deepseek-ai" / "libreoffice-kit" / "lib"
|
|
|
93
93
|
#: Actionable explanation used whenever the kit cannot be located.
|
|
94
94
|
KIT_MISSING = (
|
|
95
95
|
"the LibreOffice renderer is not available: the DSH LibreOffice kit was not found. "
|
|
96
|
-
"
|
|
97
|
-
"
|
|
96
|
+
"The plugin records the path in dsh-office-kit's config.json when the harness loads it; "
|
|
97
|
+
"for a hand-run helper, set DSH_LIBREOFFICE_KIT_CLI to <kit>/lib/cli.js of "
|
|
98
|
+
"@deepseek-ai/libreoffice-kit"
|
|
98
99
|
)
|
|
99
100
|
|
|
100
101
|
|
|
102
|
+
def office_home() -> Path:
|
|
103
|
+
"""Everything this plugin owns lives under `~/.dsh/office` (mirrors src/python.ts)."""
|
|
104
|
+
return Path.home() / ".dsh" / "office"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def config_file() -> Path:
|
|
108
|
+
"""The config.json the host half and postinstall write."""
|
|
109
|
+
override = os.environ.get("DSH_OFFICE_CONFIG")
|
|
110
|
+
return Path(override).expanduser() if override else office_home() / "config.json"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def legacy_config_files() -> list[Path]:
|
|
114
|
+
"""Config files older versions wrote in the OS config directory, newest first."""
|
|
115
|
+
if sys.platform == "darwin":
|
|
116
|
+
primary = Path.home() / "Library" / "Application Support" / "dsh-office-kit" / "config.json"
|
|
117
|
+
elif sys.platform == "win32":
|
|
118
|
+
primary = Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming") / "dsh-office-kit" / "config.json"
|
|
119
|
+
else:
|
|
120
|
+
primary = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "dsh-office-kit" / "config.json"
|
|
121
|
+
roaming = Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming") / "dsh-office-kit" / "config.json"
|
|
122
|
+
return list(dict.fromkeys([primary, roaming]))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def read_config() -> dict[str, Any]:
|
|
126
|
+
"""Merged config: the canonical file wins key by key over older locations."""
|
|
127
|
+
merged: dict[str, Any] = {}
|
|
128
|
+
for file in [*reversed(legacy_config_files()), config_file()]:
|
|
129
|
+
try:
|
|
130
|
+
parsed = json.loads(file.read_text("utf-8"))
|
|
131
|
+
except (OSError, ValueError):
|
|
132
|
+
continue
|
|
133
|
+
if isinstance(parsed, dict):
|
|
134
|
+
merged.update(parsed)
|
|
135
|
+
return merged
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def saved_kit() -> tuple[str, str] | None:
|
|
139
|
+
"""Return (node, kit CLI) as recorded by the host half, or None.
|
|
140
|
+
|
|
141
|
+
This is how a profile install works: the kit lives in the harness tree, which
|
|
142
|
+
no walk-up from the plugin directory can reach, so the host half looks it up
|
|
143
|
+
while it loads and stores the result here for every later helper call.
|
|
144
|
+
"""
|
|
145
|
+
kit = read_config().get("libreofficeKit")
|
|
146
|
+
if not isinstance(kit, dict):
|
|
147
|
+
return None
|
|
148
|
+
cli = kit.get("cli")
|
|
149
|
+
if not isinstance(cli, str) or not Path(cli).is_file():
|
|
150
|
+
return None
|
|
151
|
+
node = kit.get("node")
|
|
152
|
+
if not isinstance(node, str) or not node:
|
|
153
|
+
node = os.environ.get("DSH_NODE") or shutil.which("node")
|
|
154
|
+
if not node:
|
|
155
|
+
return None
|
|
156
|
+
return node, cli
|
|
157
|
+
|
|
158
|
+
|
|
101
159
|
def find_kit() -> tuple[str, str] | None:
|
|
102
160
|
"""Return (node, kit CLI) for the bundled renderer, or None when it is absent.
|
|
103
161
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
162
|
+
Order: an explicit `DSH_LIBREOFFICE_KIT_CLI`, then the path the host half
|
|
163
|
+
recorded in config.json (the profile-install case), then a walk up from this
|
|
164
|
+
file, which covers a checkout or a plugin-local dependency.
|
|
107
165
|
"""
|
|
108
|
-
candidates: list[Path] = []
|
|
109
166
|
override = os.environ.get("DSH_LIBREOFFICE_KIT_CLI")
|
|
110
167
|
if override:
|
|
111
|
-
|
|
168
|
+
cli = Path(override).expanduser()
|
|
169
|
+
if cli.is_file():
|
|
170
|
+
node = os.environ.get("DSH_NODE") or shutil.which("node")
|
|
171
|
+
return (node, str(cli)) if node else None
|
|
172
|
+
recorded = saved_kit()
|
|
173
|
+
if recorded is not None:
|
|
174
|
+
return recorded
|
|
175
|
+
candidates: list[Path] = []
|
|
112
176
|
for root in (PACKAGE_ROOT, *PACKAGE_ROOT.parents):
|
|
113
177
|
candidates.append(root / KIT_RELATIVE)
|
|
114
178
|
cli = next((candidate for candidate in candidates if candidate.is_file()), None)
|