@mkterswingman/5mghost-wonder 0.0.2 → 0.0.3

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.
@@ -5,7 +5,9 @@
5
5
  // - auth: mkterswingman auth JWT/PAT
6
6
  // - wecom-cookie: WeCom cookies.json presence + live validity probe
7
7
  // - pandoc: CLI on PATH (used by use-skill for docx/pptx text)
8
- // - soffice: CLI on PATH + real executable (broken symlinks are rejected)
8
+ // - soffice: CLI on PATH + real executable (optional only the visual-
9
+ // layout xlsx render and docx/pptx PDF conversion need it; the default
10
+ // JSON read path does not shell out to soffice)
9
11
  // - docx-skill: docx SKILL.md present in plugin cache or user skills dir
10
12
  // - pptx-skill: pptx SKILL.md present in plugin cache or user skills dir
11
13
  // - cache: local export cache directory state (informational)
@@ -41,12 +43,13 @@ function findOnPath(binName) {
41
43
  }
42
44
  return null;
43
45
  }
44
- function checkExecutable(binName, installHints) {
46
+ function checkExecutable(binName, installHints, optional = false) {
45
47
  const found = findOnPath(binName);
46
48
  if (!found) {
47
49
  return {
48
50
  label: binName,
49
51
  ok: false,
52
+ optional,
50
53
  hint: installHints[process.platform] ?? installHints["default"] ?? `install ${binName}`,
51
54
  };
52
55
  }
@@ -60,6 +63,7 @@ function checkExecutable(binName, installHints) {
60
63
  return {
61
64
  label: binName,
62
65
  ok: false,
66
+ optional,
63
67
  hint: `${binName} is a broken symlink at ${found}. Reinstall: ${installHints[process.platform] ?? installHints["default"]}`,
64
68
  detail: found,
65
69
  };
@@ -73,6 +77,7 @@ function checkExecutable(binName, installHints) {
73
77
  return {
74
78
  label: binName,
75
79
  ok: false,
80
+ optional,
76
81
  hint: `${binName} is on PATH (${found}) but failed to execute. Reinstall: ${installHints[process.platform] ?? installHints["default"]}`,
77
82
  detail: `realpath=${realTarget}`,
78
83
  };
@@ -151,13 +156,18 @@ export async function runCheckCommand(_argv, context) {
151
156
  win32: "winget install pandoc",
152
157
  default: "https://pandoc.org/installing.html",
153
158
  }));
154
- // ── LibreOffice soffice ─────────────────────────────────────────────────
159
+ // ── LibreOffice soffice (optional) ──────────────────────────────────────
160
+ // The default JSON read path (`wonder read <url>` / `--tab`) never touches
161
+ // soffice. It is only needed for (a) the optional xlsx visual-layout render
162
+ // step and (b) docx/pptx → PDF conversion inside the AI-side use skill.
163
+ // Mark as optional so a missing install does not make `wonder check` exit 1.
155
164
  items.push(checkExecutable("soffice", {
156
165
  darwin: "brew install --cask libreoffice",
157
166
  linux: "sudo apt install -y libreoffice (or: sudo dnf install -y libreoffice)",
158
167
  win32: "winget install LibreOffice.LibreOffice",
159
168
  default: "https://www.libreoffice.org/download",
160
- }));
169
+ },
170
+ /* optional */ true));
161
171
  // ── docx / pptx skill files ─────────────────────────────────────────────
162
172
  // Phase 5: intentionally NOT checked here.
163
173
  //
@@ -1,14 +1,87 @@
1
1
  // src/commands/uninstall.ts
2
- // Runs `npm uninstall -g @mkterswingman/5mghost-wonder`.
3
- // NpmExecutor is injectable for unit testing.
2
+ // Uninstall flow:
3
+ // 1. Remove installed wonder skills from every detected AI client via the
4
+ // same manifest used at install time. Receipt gating in `removeSkills`
5
+ // only deletes directories this package owns.
6
+ // 2. Run `npm uninstall -g @mkterswingman/5mghost-wonder`.
7
+ // 3. Print the location of the local data dir (cookies + export cache)
8
+ // and the exact command to remove it. We do NOT delete data
9
+ // automatically — cookies are still reusable if the user reinstalls.
10
+ import { resolveWonderPaths } from "../platform/paths.js";
4
11
  import { defaultNpmExecutor } from "../platform/npm.js";
12
+ import { fileURLToPath } from "node:url";
13
+ import { dirname, resolve } from "node:path";
5
14
  export async function runUninstallCommand(_argv, context, executor = defaultNpmExecutor) {
6
15
  context.io.stdout("Uninstalling 5mghost-wonder...");
16
+ // Step 1 — best-effort skill removal. Failures here do not block npm.
17
+ await removeInstalledSkills(context);
18
+ // Step 2 — npm uninstall.
7
19
  const result = executor(["uninstall", "-g", "@mkterswingman/5mghost-wonder"]);
8
20
  if (result.exitCode !== 0) {
9
21
  context.io.stderr(`Uninstall failed:\n${result.stderr}`);
10
22
  return { exitCode: 1 };
11
23
  }
24
+ // Step 3 — tell the user about residual data.
25
+ const paths = resolveWonderPaths({ homeDir: context.homeDir });
12
26
  context.io.stdout("Uninstalled successfully.");
27
+ context.io.stdout("");
28
+ context.io.stdout(`Local data kept at: ${paths.wonderDir}\n` +
29
+ ` (cookies + export cache; reused if you reinstall)\n` +
30
+ ` To remove manually: rm -rf ${paths.wonderDir}`);
13
31
  return { exitCode: 0 };
14
32
  }
33
+ async function removeInstalledSkills(context) {
34
+ let removeSkills;
35
+ let listDetectedAgents;
36
+ try {
37
+ ({ removeSkills, listDetectedAgents } = await import("@mkterswingman/5mghost-agent-skills"));
38
+ }
39
+ catch {
40
+ context.io.stdout(" (agent-skills SDK unavailable — skipping skill removal)");
41
+ return;
42
+ }
43
+ const manifestPath = findManifestPath();
44
+ if (!manifestPath) {
45
+ context.io.stdout(" (skills.manifest.json not found — skipping skill removal)");
46
+ return;
47
+ }
48
+ let agents;
49
+ try {
50
+ agents = listDetectedAgents();
51
+ }
52
+ catch {
53
+ context.io.stdout(" (could not detect AI clients — skipping skill removal)");
54
+ return;
55
+ }
56
+ if (!agents || agents.length === 0) {
57
+ context.io.stdout(" (no AI clients detected — skipping skill removal)");
58
+ return;
59
+ }
60
+ try {
61
+ const summary = removeSkills({ manifestPath, detectedAgents: agents });
62
+ const removed = summary.results.filter((r) => r.status === "removed");
63
+ if (removed.length > 0) {
64
+ const names = removed.map((r) => `${r.skill}@${r.agent}`).join(", ");
65
+ context.io.stdout(` removed skills: ${names}`);
66
+ }
67
+ else {
68
+ context.io.stdout(" no wonder-owned skills found in detected AI clients");
69
+ }
70
+ }
71
+ catch (err) {
72
+ context.io.stdout(` (skill removal failed: ${String(err)})`);
73
+ }
74
+ }
75
+ /**
76
+ * Resolve the package's `skills.manifest.json` from the compiled CLI.
77
+ * `dist/commands/uninstall.js` lives two levels below the package root.
78
+ */
79
+ function findManifestPath() {
80
+ try {
81
+ const here = dirname(fileURLToPath(import.meta.url));
82
+ return resolve(here, "..", "..", "skills.manifest.json");
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ }
@@ -7,7 +7,7 @@
7
7
  //
8
8
  // All three document types (sheet/doc/slide) share this same flow.
9
9
  // URL parsing is in ./url.ts; cookie management is in ./cookies.ts (P1-03).
10
- import { writeFileSync, mkdirSync } from "fs";
10
+ import { writeFileSync, mkdirSync, existsSync } from "fs";
11
11
  import { join } from "path";
12
12
  /** Error thrown by exportWecomDoc() */
13
13
  export class ExportError extends Error {
@@ -202,7 +202,8 @@ async function downloadExportedFile(fileUrl, fileName, saveDir) {
202
202
  catch (err) {
203
203
  throw new ExportError("write_error", `Failed to create save directory "${saveDir}": ${String(err)}`, err);
204
204
  }
205
- const filePath = join(saveDir, fileName);
205
+ const safeName = sanitizeFilename(fileName);
206
+ const filePath = pickNonClobberingPath(saveDir, safeName);
206
207
  try {
207
208
  writeFileSync(filePath, Buffer.from(buffer));
208
209
  }
@@ -211,6 +212,43 @@ async function downloadExportedFile(fileUrl, fileName, saveDir) {
211
212
  }
212
213
  return { filePath, fileSizeBytes: buffer.byteLength };
213
214
  }
215
+ /**
216
+ * Strip any directory components and disallow control characters. The upstream
217
+ * `fileName` is untrusted: a `../../etc/passwd` or `foo/bar.xlsx` value would
218
+ * otherwise escape `saveDir` via `path.join`. We keep it minimal — basename
219
+ * only, and drop control characters — rather than a full allow-list that
220
+ * would mangle Chinese titles.
221
+ */
222
+ function sanitizeFilename(fileName) {
223
+ // Strip leading path segments; handles both POSIX and win32 separators.
224
+ let name = fileName.split(/[\\/]/).pop() ?? "";
225
+ // Drop control chars (0x00-0x1F, 0x7F).
226
+ // eslint-disable-next-line no-control-regex
227
+ name = name.replace(/[\x00-\x1f\x7f]/g, "");
228
+ name = name.trim();
229
+ if (name === "" || name === "." || name === "..") {
230
+ return "wecom-download";
231
+ }
232
+ return name;
233
+ }
234
+ /**
235
+ * Return `join(saveDir, name)` unless that exists, in which case append
236
+ * ` (2)`, ` (3)`, … before the extension. Never overwrites existing files.
237
+ */
238
+ function pickNonClobberingPath(saveDir, name) {
239
+ const base = join(saveDir, name);
240
+ if (!existsSync(base))
241
+ return base;
242
+ const dotIdx = name.lastIndexOf(".");
243
+ const stem = dotIdx > 0 ? name.slice(0, dotIdx) : name;
244
+ const ext = dotIdx > 0 ? name.slice(dotIdx) : "";
245
+ for (let i = 2; i < 1000; i++) {
246
+ const candidate = join(saveDir, `${stem} (${i})${ext}`);
247
+ if (!existsSync(candidate))
248
+ return candidate;
249
+ }
250
+ return join(saveDir, `${stem} (${Date.now()})${ext}`);
251
+ }
214
252
  // ---------------------------------------------------------------------------
215
253
  // Public API
216
254
  // ---------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mkterswingman/5mghost-wonder",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "description": "企微文档读取 CLI — WeCom document reader",
5
5
  "type": "module",
6
6
  "engines": {
@@ -25,7 +25,7 @@
25
25
  "scripts": {
26
26
  "build": "rm -rf dist && tsc && chmod +x dist/cli.js",
27
27
  "typecheck": "tsc --noEmit",
28
- "test": "node dist/wecom/url.test.js",
28
+ "test": "node dist/wecom/url.test.js && node --test tests/sheet-parity.test.mjs && node --test tests/export-sanitize.test.mjs",
29
29
  "smoke": "npm run build && node dist/cli.js help > /dev/null",
30
30
  "postinstall": "node scripts/postinstall.mjs"
31
31
  },
@@ -106,6 +106,28 @@ Read("/Users/<you>/Downloads/5mghost-wonder/media/image3.png")
106
106
 
107
107
  **Note:** Images are full-resolution originals (up to several MB each). Only load images the user specifically asks about.
108
108
 
109
+ ### Viewing visual layout (optional)
110
+
111
+ Use when the cell JSON alone can't answer the question because the sheet's meaning comes from **visual structure** — not from the cell values themselves. Typical signals:
112
+
113
+ - Gantt chart (date columns × task rows, coloured blocks across cell ranges)
114
+ - Calendar (week grid with merged day cells or coloured categories)
115
+ - Status board / roadmap (colour-coded cells indicating stage, owner, priority)
116
+ - Large merge-to-cell ratio in the JSON (`merges.length` is a non-trivial fraction of `cells.length`)
117
+ - User explicitly asks about "how it looks", "颜色", "排版", "这个图表", "这张表的结构"
118
+
119
+ Do **not** run render for plain data tables, lookup sheets, or when the user just wants a value. The render costs ~30 s and ~10+ MB of PDF per file.
120
+
121
+ Render the whole xlsx (one PDF page per tab, preserves layout, merges, fills, borders):
122
+
123
+ ```bash
124
+ soffice --headless \
125
+ --convert-to 'pdf:calc_pdf_Export:{"SinglePageSheets":{"type":"boolean","value":"true"}}' \
126
+ --outdir /tmp/ <path-to-downloaded-xlsx>
127
+ ```
128
+
129
+ Then use the Read tool on the generated PDF. Page N corresponds to the Nth tab in workbook order (same as `tabs[]` in the metadata output).
130
+
109
131
  ---
110
132
 
111
133
  ## docx Workflow (`doc/w3_`, `doc/e2_`)
@@ -221,6 +243,7 @@ for slide in prs.slides:
221
243
  | pptx slice crash | `prs.slides[:N]` → `AttributeError: 'list' object has no attribute 'rId'` | Use `for slide in prs.slides` |
222
244
  | Cookie expiry | Cookie valid for 7–30 days | Run `wonder wecom cookie` to refresh |
223
245
  | xlsx images are full-size | Original images can be up to 6 MB each | Only read images when user specifically needs them |
246
+ | xlsx visual layout needs soffice | Gantt/calendar/coloured boards lose meaning in JSON alone | Run the optional soffice render step in the xlsx section; CLI does not auto-render |
224
247
  | smartpage unsupported | Export API returns 0% progress forever | Manual browser export |
225
248
 
226
249
  ---