html2pptx-local-mcp 1.1.19 → 1.1.20

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.
@@ -186,7 +186,7 @@ npx skills add https://html2pptx.app --list
186
186
  npx skills add https://html2pptx.app
187
187
 
188
188
  # Claude Code users: run this one line
189
- npx -y -p html2pptx-local-mcp@latest html2pptx-install-mcp claude`;
189
+ npx --yes --package html2pptx-local-mcp@latest html2pptx-install-mcp claude`;
190
190
 
191
191
  const SKILL_INSTALL_COMMAND_JA = `# 使うエージェントのコマンドを選択
192
192
  # Claude Code
@@ -208,7 +208,7 @@ npx skills add https://html2pptx.app --list
208
208
  npx skills add https://html2pptx.app
209
209
 
210
210
  # Claude Codeユーザーはこの1行でOK
211
- npx -y -p html2pptx-local-mcp@latest html2pptx-install-mcp claude`;
211
+ npx --yes --package html2pptx-local-mcp@latest html2pptx-install-mcp claude`;
212
212
 
213
213
  const SKILL_INVOCATION_EXAMPLE = `# 例: Claude Code でスライド作成 → PPTX出力
214
214
 
@@ -244,7 +244,7 @@ const SKILL_AVAILABLE_LIST = [
244
244
  ];
245
245
 
246
246
  const MCP_INSTALL_EXAMPLE = `# Claude Codeユーザーはこの1行でOK
247
- npx -y -p html2pptx-local-mcp@latest html2pptx-install-mcp claude`;
247
+ npx --yes --package html2pptx-local-mcp@latest html2pptx-install-mcp claude`;
248
248
 
249
249
  const MCP_CONFIG_EXAMPLE = `// 方法2: 設定ファイルに手動追加
250
250
 
@@ -256,7 +256,7 @@ const MCP_CONFIG_EXAMPLE = `// 方法2: 設定ファイルに手動追加
256
256
  "mcpServers": {
257
257
  "html2pptx": {
258
258
  "command": "npx",
259
- "args": ["-y", "-p", "html2pptx-local-mcp@latest", "html2pptx-mcp"],
259
+ "args": ["--yes", "--package", "html2pptx-local-mcp@latest", "html2pptx-mcp"],
260
260
  "env": {
261
261
  "PPTX_STUDIO_API_KEY": "sk_live_xxxx",
262
262
  "PPTX_STUDIO_BASE_URL": "http://127.0.0.1:<app-port>"
@@ -858,9 +858,9 @@ echo 'HTML2PPTX_API_KEY=sk_live_xxxx' >> .env`,
858
858
  icon: 'https://cdn.jsdelivr.net/gh/glincker/thesvg@main/public/icons/claude/default.svg',
859
859
  recommended: true,
860
860
  steps: [
861
- { title: 'Claude Code users: run this one line', body: 'This registers remote export as `html2pptx`, then writes local edit-slide as `html2pptx-local` directly into Claude Code user config.', code: `npx -y -p html2pptx-local-mcp@latest html2pptx-install-mcp claude` },
861
+ { title: 'Claude Code users: run this one line', body: 'This registers remote export as `html2pptx`, then writes local edit-slide as `html2pptx-local` directly into Claude Code user config.', code: `npx --yes --package html2pptx-local-mcp@latest html2pptx-install-mcp claude` },
862
862
  { title: 'Manual remote setup', body: 'If you only need hosted export tools, add the remote MCP server directly.', code: `claude mcp add --scope user --transport http html2pptx https://html2pptx.app/mcp` },
863
- { title: 'Compatible local edit-slide setup', body: 'If Claude Code stdio registration is unreliable, use the installer because it writes the local server entry directly.', code: `npx -y -p html2pptx-local-mcp@latest html2pptx-install-mcp claude` },
863
+ { title: 'Compatible local edit-slide setup', body: 'If Claude Code stdio registration is unreliable, use the installer because it writes the local server entry directly.', code: `npx --yes --package html2pptx-local-mcp@latest html2pptx-install-mcp claude` },
864
864
  ],
865
865
  tip: 'The installer registers the remote MCP with user scope and writes the local stdio MCP directly to Claude Code user config for better compatibility.',
866
866
  },
@@ -871,7 +871,7 @@ echo 'HTML2PPTX_API_KEY=sk_live_xxxx' >> .env`,
871
871
  steps: [
872
872
  { title: 'Run the CLI command (recommended)', body: 'Add the hosted remote MCP server. OAuth authentication will start automatically.', code: `codex mcp add html2pptx --url https://html2pptx.app/mcp` },
873
873
  { title: 'Manual remote setup', body: 'If you only need hosted export tools, add the remote MCP server directly.', code: `codex mcp add html2pptx --url https://html2pptx.app/mcp` },
874
- { title: 'Optional local stdio MCP for edit-slide', body: 'Use this only when Codex needs to launch local edit-slide for a `.html` file. It runs from the published package, so no repository checkout is required.', code: `codex mcp add html2pptx-local -- npx -y -p html2pptx-local-mcp@latest html2pptx-mcp` },
874
+ { title: 'Optional local stdio MCP for edit-slide', body: 'Use this only when Codex needs to launch local edit-slide for a `.html` file. It runs from the published package, so no repository checkout is required.', code: `codex mcp add html2pptx-local -- npx --yes --package html2pptx-local-mcp@latest html2pptx-mcp` },
875
875
  { title: 'Manual setup via codex.json', body: 'Alternatively, create or edit codex.json in your project root.', code: `{\n "mcpServers": {\n "html2pptx": {\n "type": "url",\n "url": "https://html2pptx.app/mcp"\n }\n }\n}` },
876
876
  ],
877
877
  },
@@ -1702,9 +1702,9 @@ echo 'HTML2PPTX_API_KEY=sk_live_xxxx' >> .env`,
1702
1702
  icon: 'https://cdn.jsdelivr.net/gh/glincker/thesvg@main/public/icons/claude/default.svg',
1703
1703
  recommended: true,
1704
1704
  steps: [
1705
- { title: 'Claude Codeユーザーはこの1行でOK', body: '`html2pptx` として remote export を登録し、`html2pptx-local` として local edit-slide を Claude Code の user config に直接書き込みます。', code: `npx -y -p html2pptx-local-mcp@latest html2pptx-install-mcp claude` },
1705
+ { title: 'Claude Codeユーザーはこの1行でOK', body: '`html2pptx` として remote export を登録し、`html2pptx-local` として local edit-slide を Claude Code の user config に直接書き込みます。', code: `npx --yes --package html2pptx-local-mcp@latest html2pptx-install-mcp claude` },
1706
1706
  { title: 'remote だけ手動セットアップ', body: 'PPTX出力などのホスト側ツールだけ必要な場合はこちらを使います。', code: `claude mcp add --scope user --transport http html2pptx https://html2pptx.app/mcp` },
1707
- { title: 'local edit-slide 互換セットアップ', body: 'Claude Code の stdio 登録が不安定な場合も、このインストーラーが local server 設定を直接書き込みます。', code: `npx -y -p html2pptx-local-mcp@latest html2pptx-install-mcp claude` },
1707
+ { title: 'local edit-slide 互換セットアップ', body: 'Claude Code の stdio 登録が不安定な場合も、このインストーラーが local server 設定を直接書き込みます。', code: `npx --yes --package html2pptx-local-mcp@latest html2pptx-install-mcp claude` },
1708
1708
  ],
1709
1709
  tip: 'このインストーラーは remote MCP を user scope で登録し、互換性のため local stdio MCP は Claude Code の user config に直接書き込みます。',
1710
1710
  },
@@ -1715,7 +1715,7 @@ echo 'HTML2PPTX_API_KEY=sk_live_xxxx' >> .env`,
1715
1715
  steps: [
1716
1716
  { title: 'CLIコマンドで追加(推奨)', body: 'hosted remote MCP server を追加します。OAuth認証が自動的に開始されます。', code: `codex mcp add html2pptx --url https://html2pptx.app/mcp` },
1717
1717
  { title: 'remote だけ手動セットアップ', body: 'PPTX出力などのホスト側ツールだけ必要な場合はこちらを使います。', code: `codex mcp add html2pptx --url https://html2pptx.app/mcp` },
1718
- { title: 'edit-slide 用 local stdio MCP(任意)', body: 'Codex がローカル `.html` を edit-slide で開く必要がある場合だけ追加します。公開パッケージから起動するため、リポジトリの checkout は不要です。', code: `codex mcp add html2pptx-local -- npx -y -p html2pptx-local-mcp@latest html2pptx-mcp` },
1718
+ { title: 'edit-slide 用 local stdio MCP(任意)', body: 'Codex がローカル `.html` を edit-slide で開く必要がある場合だけ追加します。公開パッケージから起動するため、リポジトリの checkout は不要です。', code: `codex mcp add html2pptx-local -- npx --yes --package html2pptx-local-mcp@latest html2pptx-mcp` },
1719
1719
  { title: 'codex.json で手動セットアップ', body: 'コマンドが使えない場合は、プロジェクトルートの codex.json に以下を追加してください。', code: `{\n "mcpServers": {\n "html2pptx": {\n "type": "url",\n "url": "https://html2pptx.app/mcp"\n }\n }\n}` },
1720
1720
  ],
1721
1721
  },
@@ -2,14 +2,34 @@ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
2
  import { spawn } from "node:child_process";
3
3
  import { createServer } from "node:http";
4
4
  import { platform } from "node:os";
5
- import { mkdir, readFile, realpath, stat, writeFile, } from "node:fs/promises";
5
+ import { mkdir, readdir, readFile, realpath, stat, writeFile, } from "node:fs/promises";
6
6
  import { dirname, extname, join, relative, resolve, sep, } from "node:path";
7
7
  import * as p from "@clack/prompts";
8
8
  import pc from "picocolors";
9
9
  const AUTO_PORT = 0;
10
10
  const MAX_WRITE_BYTES = 5 * 1024 * 1024;
11
+ const MAX_ASSET_BYTES = 8 * 1024 * 1024;
11
12
  const ALLOWED_EXTENSIONS = [".html", ".htm"];
12
13
  const ALLOWED_EXT = new Set(ALLOWED_EXTENSIONS);
14
+ const ASSET_IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".avif"];
15
+ const ASSET_IMAGE_EXT = new Set(ASSET_IMAGE_EXTENSIONS);
16
+ const ASSET_CONTENT_TYPES = {
17
+ ".png": "image/png",
18
+ ".jpg": "image/jpeg",
19
+ ".jpeg": "image/jpeg",
20
+ ".gif": "image/gif",
21
+ ".webp": "image/webp",
22
+ ".svg": "image/svg+xml",
23
+ ".avif": "image/avif",
24
+ };
25
+ const ASSET_CONTENT_TYPE_EXT = {
26
+ "image/png": ".png",
27
+ "image/jpeg": ".jpg",
28
+ "image/gif": ".gif",
29
+ "image/webp": ".webp",
30
+ "image/svg+xml": ".svg",
31
+ "image/avif": ".avif",
32
+ };
13
33
  const DISALLOWED_TOP_DIRECTORIES = [
14
34
  "public",
15
35
  ".next",
@@ -32,6 +52,9 @@ const EDITOR_BASE_URL_EXAMPLE = "http://localhost:<port>";
32
52
  function sha256(content) {
33
53
  return createHash("sha256").update(content).digest("hex");
34
54
  }
55
+ function sha256Buffer(buf) {
56
+ return createHash("sha256").update(buf).digest("hex");
57
+ }
35
58
  function generateSessionToken() {
36
59
  return randomBytes(32).toString("base64url");
37
60
  }
@@ -278,13 +301,13 @@ function validateWriteRequest(req, ctx, reqUrl) {
278
301
  return (req.headers["x-edit-slide-local"] === "1" ||
279
302
  req.headers["x-open-slide-local"] === "1");
280
303
  }
281
- async function readBody(req) {
304
+ async function readBody(req, maxBytes = MAX_WRITE_BYTES) {
282
305
  const chunks = [];
283
306
  let total = 0;
284
307
  for await (const chunk of req) {
285
308
  const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
286
309
  total += buf.length;
287
- if (total > MAX_WRITE_BYTES + 1024 * 1024) {
310
+ if (total > maxBytes + 1024 * 1024) {
288
311
  throw new Error("request body too large");
289
312
  }
290
313
  chunks.push(buf);
@@ -427,6 +450,182 @@ async function handlePost(ctx, req, res) {
427
450
  sendJson(res, 400, { error: error?.message || "write failed" });
428
451
  }
429
452
  }
453
+ async function safeAssetPath(ctx, rel) {
454
+ if (typeof rel !== "string" || !rel) {
455
+ throw new Error("missing path");
456
+ }
457
+ const normalized = rel.replace(/^\/+/, "");
458
+ const abs = resolve(ctx.root, normalized);
459
+ if (abs !== ctx.root && !abs.startsWith(ctx.root + sep)) {
460
+ throw new Error("path escape");
461
+ }
462
+ const ext = extname(abs).toLowerCase();
463
+ if (!ASSET_IMAGE_EXT.has(ext)) {
464
+ throw new Error("only image files are allowed");
465
+ }
466
+ const real = await resolveReal(abs);
467
+ if (real !== ctx.root && !real.startsWith(ctx.root + sep)) {
468
+ throw new Error("path escape via symlink");
469
+ }
470
+ for (const candidate of [relative(ctx.root, abs), relative(ctx.root, real)]) {
471
+ const first = candidate.split(sep)[0];
472
+ if (DISALLOWED_TOP_DIRS.has(first)) {
473
+ throw new Error(`assets under ${first}/ are not allowed`);
474
+ }
475
+ }
476
+ return real;
477
+ }
478
+ function assetExt(name, contentType) {
479
+ const fromName = extname(String(name || "")).toLowerCase();
480
+ if (ASSET_IMAGE_EXT.has(fromName))
481
+ return fromName;
482
+ return ASSET_CONTENT_TYPE_EXT[String(contentType || "").toLowerCase()] || "";
483
+ }
484
+ function assetSlug(name) {
485
+ const base = String(name || "image").replace(/\.[^.]+$/, "");
486
+ const slug = base
487
+ .normalize("NFKD")
488
+ .replace(/[^\w.-]+/g, "-")
489
+ .replace(/^[-.]+|[-.]+$/g, "")
490
+ .toLowerCase();
491
+ return slug || "image";
492
+ }
493
+ function assetDirRel(scope, htmlDirRel) {
494
+ if (scope === "global")
495
+ return "assets";
496
+ return htmlDirRel ? join(htmlDirRel, "assets") : "assets";
497
+ }
498
+ async function readBytesOrNull(abs) {
499
+ try {
500
+ return await readFile(abs);
501
+ }
502
+ catch (error) {
503
+ if (error?.code === "ENOENT")
504
+ return null;
505
+ throw error;
506
+ }
507
+ }
508
+ function sendBinary(res, status, contentType, buf) {
509
+ res.statusCode = status;
510
+ res.setHeader("content-type", contentType);
511
+ res.setHeader("cache-control", "no-store");
512
+ res.setHeader("x-content-type-options", "nosniff");
513
+ res.setHeader("content-security-policy", "sandbox; default-src 'none'; style-src 'unsafe-inline'");
514
+ res.end(buf);
515
+ }
516
+ async function handleAssetGet(ctx, req, reqUrl, res) {
517
+ if (!validateBridgeRequest(req, ctx, reqUrl)) {
518
+ sendJson(res, 403, { error: "forbidden" });
519
+ return;
520
+ }
521
+ const file = reqUrl.searchParams.get("file") || "";
522
+ const htmlDirRel = file ? dirname(file) : "";
523
+ if (reqUrl.searchParams.get("list") === "1") {
524
+ const scope = reqUrl.searchParams.get("scope") === "global" ? "global" : "project";
525
+ const dirRel = assetDirRel(scope, htmlDirRel);
526
+ const absDir = resolve(ctx.root, dirRel);
527
+ const htmlDirAbs = resolve(ctx.root, htmlDirRel || ".");
528
+ let assets = [];
529
+ try {
530
+ if (absDir === ctx.root || absDir.startsWith(ctx.root + sep)) {
531
+ const names = await readdir(absDir);
532
+ assets = names
533
+ .filter((name) => ASSET_IMAGE_EXT.has(extname(name).toLowerCase()))
534
+ .map((name) => ({
535
+ name,
536
+ src: toPosixPath(relative(htmlDirAbs, join(absDir, name))),
537
+ }));
538
+ }
539
+ }
540
+ catch {
541
+ assets = [];
542
+ }
543
+ sendJson(res, 200, { assets, policy: buildPolicy(ctx) });
544
+ return;
545
+ }
546
+ const src = reqUrl.searchParams.get("src") || "";
547
+ if (!src) {
548
+ sendJson(res, 400, { error: "missing src" });
549
+ return;
550
+ }
551
+ try {
552
+ const abs = await safeAssetPath(ctx, join(htmlDirRel, src));
553
+ const buf = await readFile(abs);
554
+ const ext = extname(abs).toLowerCase();
555
+ sendBinary(res, 200, ASSET_CONTENT_TYPES[ext] || "application/octet-stream", buf);
556
+ }
557
+ catch (error) {
558
+ const message = error?.message || "read failed";
559
+ const status = message.includes("ENOENT") ? 404 : 400;
560
+ sendJson(res, status, { error: message });
561
+ }
562
+ }
563
+ async function handleAssetPost(ctx, req, res) {
564
+ const reqUrl = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
565
+ if (!validateWriteRequest(req, ctx, reqUrl)) {
566
+ sendJson(res, 403, { error: "forbidden" });
567
+ return;
568
+ }
569
+ let body;
570
+ try {
571
+ body = JSON.parse(await readBody(req, MAX_ASSET_BYTES * 2));
572
+ }
573
+ catch (error) {
574
+ sendJson(res, 400, { error: error?.message || "invalid json" });
575
+ return;
576
+ }
577
+ const { file, scope: rawScope, name, contentType, dataBase64 } = body || {};
578
+ if (typeof dataBase64 !== "string" || !dataBase64) {
579
+ sendJson(res, 400, { error: "missing image data" });
580
+ return;
581
+ }
582
+ const buf = Buffer.from(dataBase64, "base64");
583
+ if (!buf.length) {
584
+ sendJson(res, 400, { error: "empty image data" });
585
+ return;
586
+ }
587
+ if (buf.length > MAX_ASSET_BYTES) {
588
+ sendJson(res, 413, { error: "image too large (>8MB)" });
589
+ return;
590
+ }
591
+ const ext = assetExt(name, contentType);
592
+ if (!ASSET_IMAGE_EXT.has(ext)) {
593
+ sendJson(res, 400, { error: "unsupported image type" });
594
+ return;
595
+ }
596
+ const scope = rawScope === "global" ? "global" : "project";
597
+ const htmlDirRel = typeof file === "string" && file ? dirname(file) : "";
598
+ const htmlDirAbs = resolve(ctx.root, htmlDirRel || ".");
599
+ const dirRel = assetDirRel(scope, htmlDirRel);
600
+ const slug = assetSlug(name);
601
+ try {
602
+ let fileName = `${slug}${ext}`;
603
+ let absTarget = await safeAssetPath(ctx, join(dirRel, fileName));
604
+ const existing = await readBytesOrNull(absTarget);
605
+ if (existing && !existing.equals(buf)) {
606
+ fileName = `${slug}-${sha256Buffer(buf).slice(0, 8)}${ext}`;
607
+ absTarget = await safeAssetPath(ctx, join(dirRel, fileName));
608
+ }
609
+ const alreadyIdentical = Boolean(existing && existing.equals(buf));
610
+ if (!alreadyIdentical) {
611
+ await mkdir(dirname(absTarget), { recursive: true });
612
+ await writeFile(absTarget, buf);
613
+ }
614
+ sendJson(res, 200, {
615
+ ok: true,
616
+ scope,
617
+ name: fileName,
618
+ path: toPosixPath(relative(ctx.root, absTarget)),
619
+ src: toPosixPath(relative(htmlDirAbs, absTarget)),
620
+ bytes: buf.length,
621
+ reused: alreadyIdentical,
622
+ policy: buildPolicy(ctx),
623
+ });
624
+ }
625
+ catch (error) {
626
+ sendJson(res, 400, { error: error?.message || "asset write failed" });
627
+ }
628
+ }
430
629
  function createBridgeServer(ctx) {
431
630
  return createServer(async (req, res) => {
432
631
  if (!applyCors(req, res, ctx.editorOrigin))
@@ -445,6 +644,18 @@ function createBridgeServer(ctx) {
445
644
  sendJson(res, 200, { ok: true, service: "html2pptx edit bridge", root: ctx.root });
446
645
  return;
447
646
  }
647
+ if (reqUrl.pathname === "/api/edit-slide/asset") {
648
+ if (req.method === "GET") {
649
+ await handleAssetGet(ctx, req, reqUrl, res);
650
+ return;
651
+ }
652
+ if (req.method === "POST") {
653
+ await handleAssetPost(ctx, req, res);
654
+ return;
655
+ }
656
+ sendJson(res, 405, { error: "method not allowed" });
657
+ return;
658
+ }
448
659
  if (reqUrl.pathname !== "/api/edit-slide/file") {
449
660
  sendJson(res, 404, { error: "not found" });
450
661
  return;
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "html2pptx-local-mcp",
3
- "version": "1.1.19",
3
+ "version": "1.1.20",
4
4
  "type": "module",
5
5
  "description": "Local stdio MCP server for opening html2pptx slide HTML in the local edit-slide editor.",
6
6
  "bin": {
7
7
  "html2pptx-mcp": "./mcp/pptx-studio-mcp-server.mjs",
8
- "html2pptx-install-mcp": "./scripts/install-mcp.mjs"
8
+ "html2pptx-install-mcp": "./scripts/install-mcp.mjs",
9
+ "html2pptx-comments": "./scripts/extract-html2pptx-comments.mjs"
9
10
  },
10
11
  "files": [
11
12
  "app/docs/content.js",
@@ -16,6 +17,7 @@
16
17
  "lib/server/template-html-policy.mjs",
17
18
  "mcp/pptx-studio-mcp-server.mjs",
18
19
  "scripts/install-mcp.mjs",
20
+ "scripts/extract-html2pptx-comments.mjs",
19
21
  "src/animation-injector.js",
20
22
  "src/animation-renderers.js"
21
23
  ],
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFile } from 'node:fs/promises';
4
+ import { extname, resolve } from 'node:path';
5
+ import { JSDOM } from 'jsdom';
6
+
7
+ const ALLOWED_EXTENSIONS = new Set(['.html', '.htm']);
8
+
9
+ function usage() {
10
+ return [
11
+ 'Usage: html2pptx-comments <file.html> [--json|--format markdown|--format json]',
12
+ '',
13
+ 'Extracts edit-slide element comments saved as data-html2pptx-comment attributes.',
14
+ ].join('\n');
15
+ }
16
+
17
+ function parseArgs(argv) {
18
+ const args = argv.slice(2);
19
+ let file = '';
20
+ let format = 'markdown';
21
+ for (let i = 0; i < args.length; i += 1) {
22
+ const arg = args[i];
23
+ if (arg === '--help' || arg === '-h') {
24
+ return { help: true, file, format };
25
+ }
26
+ if (arg === '--json') {
27
+ format = 'json';
28
+ continue;
29
+ }
30
+ if (arg === '--format') {
31
+ const value = args[i + 1];
32
+ if (!['json', 'markdown'].includes(value)) {
33
+ throw new Error('--format must be "json" or "markdown".');
34
+ }
35
+ format = value;
36
+ i += 1;
37
+ continue;
38
+ }
39
+ if (arg.startsWith('-')) {
40
+ throw new Error(`Unknown option: ${arg}`);
41
+ }
42
+ if (file) {
43
+ throw new Error(`Unexpected extra file argument: ${arg}`);
44
+ }
45
+ file = arg;
46
+ }
47
+ return { help: false, file, format };
48
+ }
49
+
50
+ function cssEscape(value) {
51
+ return String(value).replace(/[^a-zA-Z0-9_-]/g, (char) => `\\${char}`);
52
+ }
53
+
54
+ function shortText(value, max = 80) {
55
+ const text = String(value || '').trim().replace(/\s+/g, ' ');
56
+ return text.length > max ? `${text.slice(0, max - 1)}...` : text;
57
+ }
58
+
59
+ function elementLabel(element) {
60
+ const tag = element.tagName.toLowerCase();
61
+ const named =
62
+ element.getAttribute('data-layer-name') ||
63
+ element.getAttribute('data-name') ||
64
+ element.getAttribute('aria-label') ||
65
+ element.getAttribute('alt') ||
66
+ shortText(element.textContent, 48);
67
+ return named ? `${tag}: ${named}` : tag;
68
+ }
69
+
70
+ function cssPath(element, root) {
71
+ if (!element || element === root) return '';
72
+ const parts = [];
73
+ let node = element;
74
+ while (node && node !== root && node.nodeType === 1) {
75
+ const tag = node.tagName.toLowerCase();
76
+ const id = node.getAttribute('id');
77
+ if (id) {
78
+ parts.unshift(`${tag}#${cssEscape(id)}`);
79
+ break;
80
+ }
81
+ const className = node.getAttribute('class');
82
+ const classes = className ? className.split(/\s+/).filter(Boolean).slice(0, 2).map(cssEscape) : [];
83
+ const classPart = classes.length ? `.${classes.join('.')}` : '';
84
+ const siblings = Array.from(node.parentElement?.children || []).filter((child) => child.tagName === node.tagName);
85
+ const indexPart = siblings.length > 1 ? `:nth-of-type(${siblings.indexOf(node) + 1})` : '';
86
+ parts.unshift(`${tag}${classPart}${indexPart}`);
87
+ node = node.parentElement;
88
+ }
89
+ return parts.join(' > ');
90
+ }
91
+
92
+ function extractComments(html, filePath) {
93
+ const dom = new JSDOM(html);
94
+ const document = dom.window.document;
95
+ const slides = Array.from(document.querySelectorAll('section.slide'));
96
+ const commented = Array.from(document.querySelectorAll('[data-html2pptx-comment]'));
97
+ return commented
98
+ .map((element, index) => {
99
+ const comment = shortText(element.getAttribute('data-html2pptx-comment'), 2000);
100
+ if (!comment) return null;
101
+ const slideElement = element.closest('section.slide');
102
+ const slideIndex = slideElement ? slides.indexOf(slideElement) : -1;
103
+ const root = slideElement || document.body;
104
+ const selector = element.getAttribute('data-html2pptx-comment-selector') || cssPath(element, root) || '__root__';
105
+ return {
106
+ index: index + 1,
107
+ file: filePath,
108
+ slide: slideIndex >= 0 ? slideIndex + 1 : null,
109
+ id: element.getAttribute('data-html2pptx-comment-id') || null,
110
+ selector,
111
+ tag: element.tagName.toLowerCase(),
112
+ label: elementLabel(element),
113
+ comment,
114
+ updatedAt: element.getAttribute('data-html2pptx-comment-updated-at') || null,
115
+ };
116
+ })
117
+ .filter(Boolean);
118
+ }
119
+
120
+ function formatMarkdown(filePath, comments) {
121
+ if (!comments.length) {
122
+ return `No html2pptx comments found in ${filePath}.`;
123
+ }
124
+ const lines = [
125
+ `# html2pptx comments`,
126
+ '',
127
+ `File: \`${filePath}\``,
128
+ `Count: ${comments.length}`,
129
+ '',
130
+ ];
131
+ for (const item of comments) {
132
+ lines.push(
133
+ `## ${item.index}. ${item.slide ? `Slide ${item.slide}` : 'Document'} / ${item.label}`,
134
+ '',
135
+ `- selector: \`${item.selector}\``,
136
+ item.id ? `- id: \`${item.id}\`` : null,
137
+ item.updatedAt ? `- updated: ${item.updatedAt}` : null,
138
+ '',
139
+ item.comment,
140
+ '',
141
+ );
142
+ }
143
+ return lines.filter((line) => line !== null).join('\n');
144
+ }
145
+
146
+ async function main() {
147
+ const { help, file, format } = parseArgs(process.argv);
148
+ if (help) {
149
+ console.log(usage());
150
+ return;
151
+ }
152
+ if (!file) {
153
+ throw new Error(`Missing file.\n\n${usage()}`);
154
+ }
155
+ const filePath = resolve(file);
156
+ const ext = extname(filePath).toLowerCase();
157
+ if (!ALLOWED_EXTENSIONS.has(ext)) {
158
+ throw new Error('Only .html and .htm files are supported.');
159
+ }
160
+ const html = await readFile(filePath, 'utf8');
161
+ const comments = extractComments(html, filePath);
162
+ if (format === 'json') {
163
+ console.log(JSON.stringify({ file: filePath, count: comments.length, comments }, null, 2));
164
+ } else {
165
+ console.log(formatMarkdown(filePath, comments));
166
+ }
167
+ }
168
+
169
+ main().catch((error) => {
170
+ console.error(error?.message || String(error));
171
+ process.exitCode = 1;
172
+ });
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { spawnSync } from 'node:child_process';
4
4
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
5
- import { join } from 'node:path';
5
+ import { dirname, join } from 'node:path';
6
6
 
7
7
  const REMOTE_SERVER_NAME = 'html2pptx';
8
8
  const LOCAL_SERVER_NAME = 'html2pptx-local';
@@ -13,6 +13,7 @@ const args = process.argv.slice(2);
13
13
  const dryRun = args.includes('--dry-run');
14
14
  const help = args.includes('--help') || args.includes('-h');
15
15
  const client = resolveClient(args);
16
+ const localCommand = buildLocalMcpCommand();
16
17
 
17
18
  if (help) {
18
19
  printHelp();
@@ -29,7 +30,7 @@ const steps = buildSteps(client);
29
30
 
30
31
  console.log(`Installing html2pptx MCP for ${client}.`);
31
32
  console.log(`Remote server: ${REMOTE_SERVER_NAME} -> ${REMOTE_MCP_URL}`);
32
- console.log(`Local server: ${LOCAL_SERVER_NAME} -> npx -y -p ${LOCAL_PACKAGE_SPEC} html2pptx-mcp`);
33
+ console.log(`Local server: ${LOCAL_SERVER_NAME} -> ${renderCommand(localCommand.command, localCommand.args)}`);
33
34
  console.log('');
34
35
 
35
36
  for (const step of steps) {
@@ -89,11 +90,8 @@ function buildSteps(targetClient) {
89
90
  'add',
90
91
  LOCAL_SERVER_NAME,
91
92
  '--',
92
- 'npx',
93
- '-y',
94
- '-p',
95
- LOCAL_PACKAGE_SPEC,
96
- 'html2pptx-mcp',
93
+ localCommand.command,
94
+ ...localCommand.args,
97
95
  ],
98
96
  },
99
97
  ];
@@ -103,8 +101,8 @@ function writeClaudeLocalMcpConfig() {
103
101
  const configPath = join(process.env.HOME || process.cwd(), '.claude.json');
104
102
  const serverConfig = {
105
103
  type: 'stdio',
106
- command: 'npx',
107
- args: ['-y', '-p', LOCAL_PACKAGE_SPEC, 'html2pptx-mcp'],
104
+ command: localCommand.command,
105
+ args: localCommand.args,
108
106
  env: {},
109
107
  };
110
108
 
@@ -135,6 +133,27 @@ function writeClaudeLocalMcpConfig() {
135
133
  console.log(`Registered local MCP server ${LOCAL_SERVER_NAME} in Claude user config.`);
136
134
  }
137
135
 
136
+ export function buildLocalMcpCommand(env = process.env) {
137
+ const npmExecPath = env.npm_execpath || env.npm_execPath;
138
+ const npxCli = npmExecPath ? join(dirname(npmExecPath), 'npx-cli.js') : '';
139
+
140
+ if (npxCli && existsSync(npxCli)) {
141
+ return {
142
+ command: process.execPath,
143
+ args: [npxCli, '--yes', '--package', LOCAL_PACKAGE_SPEC, 'html2pptx-mcp'],
144
+ };
145
+ }
146
+
147
+ return {
148
+ command: 'npx',
149
+ args: ['--yes', '--package', LOCAL_PACKAGE_SPEC, 'html2pptx-mcp'],
150
+ };
151
+ }
152
+
153
+ function renderCommand(command, args) {
154
+ return [command, ...args].join(' ');
155
+ }
156
+
138
157
  function runStep(step) {
139
158
  const rendered = [step.command, ...step.args].join(' ');
140
159
  console.log(`> ${rendered}`);
@@ -179,8 +198,8 @@ Usage:
179
198
  html2pptx-install-mcp --client codex
180
199
 
181
200
  Examples:
182
- npx -y -p html2pptx-local-mcp@latest html2pptx-install-mcp claude
183
- npx -y -p html2pptx-local-mcp@latest html2pptx-install-mcp codex
201
+ npx --yes --package html2pptx-local-mcp@latest html2pptx-install-mcp claude
202
+ npx --yes --package html2pptx-local-mcp@latest html2pptx-install-mcp codex
184
203
 
185
204
  Optional:
186
205
  HTML2PPTX_LOCAL_MCP_PACKAGE_SPEC=<package-or-tarball> html2pptx-install-mcp claude