mdinterface 0.1.1 → 0.2.1

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/README.md CHANGED
@@ -48,10 +48,18 @@ CDN, no first-load internet requirement.
48
48
  node server.js path/to/doc.md
49
49
  # prints a URL like http://localhost:7777/?t=… — open THAT (it carries a session token)
50
50
 
51
+ Or start with **no file** — you get an empty canvas and a file browser; pick a `.md` and the
52
+ session begins. The folder of that first document becomes Claude's working directory for the
53
+ rest of the session, so launch from (or pick within) the project you want Claude to work in:
54
+
55
+ node server.js
56
+ # empty canvas → Browse → pick a doc; Claude starts in that doc's folder
57
+
51
58
  Options:
52
59
 
53
60
  --port 8000 # different port
54
61
  --cmd "claude --continue" # custom launch command (or set MDINTERFACE_CMD)
62
+ --help # usage and exit
55
63
 
56
64
  ## Use
57
65
 
package/mcp-server.js CHANGED
@@ -213,7 +213,7 @@ function handle(msg) {
213
213
  result(id, {
214
214
  protocolVersion: params?.protocolVersion || "2025-06-18",
215
215
  capabilities: { tools: {} },
216
- serverInfo: { name: "mdinterface", version: "0.1.0" },
216
+ serverInfo: { name: "mdinterface", version: require("./package.json").version },
217
217
  });
218
218
  } else if (method === "tools/list") {
219
219
  result(id, { tools: TOOLS });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mdinterface",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "A live markdown canvas wired to Claude Code. Highlight a passage to set both the context and the scope, ask, and Claude edits exactly that and leaves the rest alone.",
5
5
  "license": "MIT",
6
6
  "author": "Kevin Sundstrom",
@@ -18,6 +18,7 @@
18
18
  },
19
19
  "scripts": {
20
20
  "start": "bash start.sh",
21
+ "mdinterface": "node server.js",
21
22
  "lint": "biome check .",
22
23
  "format": "biome format --write .",
23
24
  "typecheck": "tsc --noEmit",
@@ -51,9 +52,11 @@
51
52
  ],
52
53
  "dependencies": {
53
54
  "express": "^4.19.0",
54
- "node-pty": "^1.0.0",
55
55
  "ws": "^8.17.0"
56
56
  },
57
+ "optionalDependencies": {
58
+ "node-pty": "^1.0.0"
59
+ },
57
60
  "devDependencies": {
58
61
  "@biomejs/biome": "^2.0.0",
59
62
  "@types/express": "^4.17.21",
package/public/index.html CHANGED
@@ -387,7 +387,11 @@ function connect() {
387
387
  if (!editingEl) render(msg.content);
388
388
  }
389
389
  if (msg.type === "history") undoBtn.disabled = !msg.canUndo;
390
+ if (msg.type === "nodoc") showNoDoc();
390
391
  if (msg.type === "opened") {
392
+ // Leaving the no-document state: the server is starting Claude now, so reset the
393
+ // terminal for its fresh output and dismiss the picker.
394
+ if (nodoc) { nodoc = false; closeBrowser(); term.reset(); }
391
395
  setDocMeta(msg.path, msg.name);
392
396
  filepicker.classList.remove("error");
393
397
  // New document — reset the diff baseline so the whole thing isn't flagged as
@@ -401,6 +405,19 @@ function showMissing() {
401
405
  lastSrc = ""; prevBlocks = []; prevEls = []; flashedEls = []; notionLink.hidden = true;
402
406
  docEl.innerHTML = '<p style="color:var(--ink-soft);font-style:italic">The document is no longer on disk — waiting for it to return…</p>';
403
407
  }
408
+ // No file was given at launch. Show a hint + the file browser; Claude doesn't start until a
409
+ // document is picked (its folder becomes the working directory). Set by the server's "nodoc".
410
+ let nodoc = false;
411
+ function showNoDoc() {
412
+ nodoc = true;
413
+ lastSrc = ""; prevBlocks = []; prevEls = []; flashedEls = []; notionLink.hidden = true;
414
+ docEl.innerHTML = '<p style="color:var(--ink-soft);font-style:italic">No document open — pick a <code>.md</code> file to start. The folder you choose becomes Claude’s working directory.</p>';
415
+ document.title = "mdinterface — pick a document";
416
+ term.reset();
417
+ term.writeln("\x1b[2m No Claude session yet — pick a document to begin.\x1b[0m");
418
+ showHeader();
419
+ openBrowser(); // pop the file browser at the launch directory
420
+ }
404
421
  connect();
405
422
 
406
423
  // ---------- block rendering + change flash ----------
@@ -794,7 +811,7 @@ restartBtn.addEventListener("click", () => {
794
811
  const header = document.querySelector("header");
795
812
  let headerHide;
796
813
  function showHeader() { clearTimeout(headerHide); header.classList.add("show"); }
797
- function hideHeaderSoon(delay) { clearTimeout(headerHide); headerHide = setTimeout(() => header.classList.remove("show"), delay); }
814
+ function hideHeaderSoon(delay) { if (nodoc) return; clearTimeout(headerHide); headerHide = setTimeout(() => header.classList.remove("show"), delay); }
798
815
  document.addEventListener("mousemove", (e) => { if (e.clientY <= 6) showHeader(); });
799
816
  header.addEventListener("mouseenter", showHeader);
800
817
  header.addEventListener("mouseleave", () => hideHeaderSoon(300));
package/server.js CHANGED
@@ -16,21 +16,48 @@ const path = require("node:path");
16
16
  const os = require("node:os");
17
17
  const crypto = require("node:crypto");
18
18
  const { WebSocketServer } = require("ws");
19
- const pty = require("node-pty");
19
+ // node-pty powers the embedded terminal pane. It's an OPTIONAL native dependency (see
20
+ // optionalDependencies in package.json): if it didn't install or build, the rendered canvas,
21
+ // the file watcher, and the selection bridge all still come up — only the in-window terminal
22
+ // is disabled, and you run `claude` in your own terminal instead (the hooks still feed it the
23
+ // selection off disk). The specific reason is surfaced to the user at spawn time.
24
+ let pty = null;
25
+ try {
26
+ pty = require("node-pty");
27
+ } catch {}
20
28
 
21
29
  // ---------- args ----------
22
30
  const args = process.argv.slice(2);
23
- const fileArg = args.find((a) => !a.startsWith("--"));
24
- if (!fileArg) {
25
- console.error("Usage: mdinterface <file.md> [--port 7777] [--cmd claude]");
26
- process.exit(1);
31
+ if (args.includes("--help") || args.includes("-h")) {
32
+ console.log(
33
+ "mdinterface a live markdown canvas wired to Claude Code.\n\n" +
34
+ "Usage:\n" +
35
+ " mdinterface [file.md] [--port 7777] [--cmd claude]\n\n" +
36
+ "Run with a file to open it directly, or with no file to start on an empty\n" +
37
+ "canvas and pick one in the file browser. The folder of the first document\n" +
38
+ "opened becomes Claude's working directory for the rest of the session.\n\n" +
39
+ "Options:\n" +
40
+ " --port <n> Port to listen on (default 7777)\n" +
41
+ ' --cmd <cmd> Command run in the terminal pane (default "claude";\n' +
42
+ " or set MDINTERFACE_CMD)\n" +
43
+ " -h, --help Show this help and exit\n\n" +
44
+ "The printed URL carries a per-launch token — open that exact URL."
45
+ );
46
+ process.exit(0);
27
47
  }
28
- let DOC = path.resolve(fileArg); // reassignable: the toolbar file picker can switch docs
29
- if (!fs.existsSync(DOC)) {
48
+ // The file is the first bare argument but skip values that belong to a value-taking flag
49
+ // (`--port 7777`, `--cmd claude`), or with no file they'd be misread as the filename.
50
+ const VALUE_FLAGS = new Set(["--port", "--cmd"]);
51
+ const fileArg = args.find((a, i) => !a.startsWith("--") && !VALUE_FLAGS.has(args[i - 1]));
52
+ // The file is optional: with none, mdinterface opens an empty canvas and the file browser,
53
+ // and initializes the session (hooks, MCP, watcher, Claude) when you pick the first document.
54
+ // reassignable: the toolbar file picker can switch documents.
55
+ let DOC = fileArg ? path.resolve(fileArg) : null;
56
+ if (DOC && !fs.existsSync(DOC)) {
30
57
  console.error(`File not found: ${DOC}`);
31
58
  process.exit(1);
32
59
  }
33
- if (fs.statSync(DOC).isDirectory()) {
60
+ if (DOC && fs.statSync(DOC).isDirectory()) {
34
61
  console.error(
35
62
  `${DOC} is a directory — point me at a markdown file, e.g.:\n node server.js ${path.join(fileArg, "doc.md")}`
36
63
  );
@@ -66,7 +93,11 @@ app.use(express.static(path.join(__dirname, "public")));
66
93
  app.get("/doc", (req, res) => {
67
94
  if (req.query.t !== TOKEN) return res.status(403).end();
68
95
  // path is returned (for the file picker) but only to a token-holding client.
69
- res.json({ name: path.basename(DOC), path: DOC, content: read() });
96
+ res.json(
97
+ DOC
98
+ ? { name: path.basename(DOC), path: DOC, content: read() }
99
+ : { name: null, path: null, content: "" }
100
+ );
70
101
  });
71
102
  // Directory listing for the file browser: folders (to navigate) + markdown files. Token
72
103
  // gated. Lists any dir the user can read — same reach the PTY already has.
@@ -75,7 +106,9 @@ app.get("/ls", (req, res) => {
75
106
  const dir =
76
107
  typeof req.query.dir === "string" && req.query.dir
77
108
  ? path.resolve(req.query.dir)
78
- : path.dirname(DOC);
109
+ : DOC
110
+ ? path.dirname(DOC)
111
+ : process.cwd(); // no doc yet → browse from where mdinterface was launched
79
112
  let ents;
80
113
  try {
81
114
  ents = fs.readdirSync(dir, { withFileTypes: true });
@@ -122,14 +155,22 @@ function read() {
122
155
  // The canvas never types into Claude. Instead the current selection is mirrored to a
123
156
  // file, and a UserPromptSubmit hook injects that file as context on the user's next
124
157
  // message — so Claude is ambiently aware of what's selected, with zero prompt noise.
125
- const CLAUDE_DIR = path.join(path.dirname(DOC), ".claude");
126
- const SEL_FILE = path.join(CLAUDE_DIR, "mdinterface-selection.txt");
158
+ // Folder-derived paths. When launched with no file, DOC is null and these stay null until
159
+ // the first document is opened (openDoc → initDocSession), which anchors them to its folder.
160
+ let CLAUDE_DIR = null;
161
+ let SEL_FILE = null;
127
162
 
128
163
  // ---------- runtime file: how the separate MCP process reaches this server ----------
129
164
  // canvas_edit writes the open document directly; canvas_open POSTs back to this server. The
130
165
  // MCP process reads port/token/doc from this file before each call, so edits follow the
131
166
  // toolbar file picker. Edits always apply immediately — undo is the Undo button (or git).
132
- const RUNTIME_FILE = path.join(CLAUDE_DIR, "mdinterface-runtime.json");
167
+ let RUNTIME_FILE = null;
168
+ // Anchor the support-file paths to the open document's folder. Called once the doc is known.
169
+ function wireDocPaths() {
170
+ CLAUDE_DIR = path.join(path.dirname(DOC), ".claude");
171
+ SEL_FILE = path.join(CLAUDE_DIR, "mdinterface-selection.txt");
172
+ RUNTIME_FILE = path.join(CLAUDE_DIR, "mdinterface-runtime.json");
173
+ }
133
174
  function writeRuntime() {
134
175
  try {
135
176
  fs.mkdirSync(CLAUDE_DIR, { recursive: true });
@@ -341,18 +382,25 @@ function installMcpServer() {
341
382
  }
342
383
  }
343
384
 
344
- installHooks();
345
- installMcpServer();
346
- writeRuntime(); // mode + callback info for the MCP server
347
- writeSelection("", []); // start clean
385
+ // Per-document session setup: anchor the support files + hooks to the doc's folder, register
386
+ // the MCP server, and seed the runtime/selection files. Runs at boot when a file was given on
387
+ // the command line, or on the first openDoc when launched with no file.
388
+ function initDocSession() {
389
+ wireDocPaths();
390
+ installHooks();
391
+ installMcpServer();
392
+ writeRuntime(); // mode + callback info for the MCP server
393
+ writeSelection("", []); // start clean
394
+ }
395
+ if (DOC) initDocSession();
348
396
 
349
397
  for (const sig of ["exit", "SIGINT", "SIGTERM"]) {
350
398
  process.on(sig, () => {
351
399
  try {
352
- fs.writeFileSync(SEL_FILE, "");
400
+ if (SEL_FILE) fs.writeFileSync(SEL_FILE, "");
353
401
  } catch {}
354
402
  try {
355
- fs.unlinkSync(RUNTIME_FILE);
403
+ if (RUNTIME_FILE) fs.unlinkSync(RUNTIME_FILE);
356
404
  } catch {} // gone → MCP server defaults back to auto
357
405
  if (sig !== "exit") process.exit(0);
358
406
  });
@@ -405,13 +453,65 @@ function onPtyData(d) {
405
453
  flushTerm();
406
454
  }, TERM_FLUSH_MS);
407
455
  }
456
+ // pnpm, Yarn PnP, CI caches, and Docker COPY frequently strip the +x bit from node-pty's
457
+ // prebuilt spawn-helper, which makes pty.spawn die — the single most common runtime failure.
458
+ // Restore it in-process, since a postinstall script can't help the people who hit this most
459
+ // (--ignore-scripts / pnpm users disable postinstall). Returns true if it actually fixed a bit.
460
+ function ensureSpawnHelperExecutable() {
461
+ let fixed = false;
462
+ try {
463
+ const root = path.dirname(require.resolve("node-pty/package.json")); // works wherever it's hoisted
464
+ for (const base of [path.join(root, "prebuilds"), path.join(root, "build", "Release")]) {
465
+ let ents;
466
+ try {
467
+ ents = fs.readdirSync(base, { withFileTypes: true });
468
+ } catch {
469
+ continue;
470
+ }
471
+ const files = [];
472
+ for (const ent of ents) {
473
+ if (ent.isDirectory()) files.push(path.join(base, ent.name, "spawn-helper"));
474
+ else if (ent.name === "spawn-helper") files.push(path.join(base, ent.name));
475
+ }
476
+ for (const f of files) {
477
+ try {
478
+ if (!(fs.statSync(f).mode & 0o111)) {
479
+ fs.chmodSync(f, 0o755);
480
+ fixed = true;
481
+ }
482
+ } catch {}
483
+ }
484
+ }
485
+ } catch {}
486
+ return fixed;
487
+ }
488
+
489
+ // Turn a spawn failure into a specific, actionable message instead of a generic one.
490
+ function diagnosePtyFailure(e) {
491
+ const msg = e?.message || String(e);
492
+ let hint;
493
+ if (/NODE_MODULE_VERSION|compiled against|different Node/i.test(msg))
494
+ hint = "node-pty was built for a different Node version — rebuild it:\n npm rebuild node-pty";
495
+ else if (/spawn-helper|EACCES|ENOENT|permission/i.test(msg))
496
+ hint =
497
+ "node-pty's spawn-helper is missing or not executable:\n" +
498
+ " chmod +x node_modules/node-pty/prebuilds/*/spawn-helper\n npm rebuild node-pty";
499
+ else hint = "Reinstall the native module:\n npm rebuild node-pty";
500
+ return (
501
+ `Embedded terminal could not start (${msg}).\n${hint}\n` +
502
+ `The canvas and selection bridge still work — run \`${CLAUDE_CMD}\` in your own terminal beside this window.`
503
+ );
504
+ }
505
+
408
506
  function spawnPty(cols = 100, rows = 32) {
507
+ if (!pty) return null; // optional dependency absent — startShell shows the fallback message
508
+ ensureSpawnHelperExecutable(); // proactively fix a stripped +x bit before we spawn
409
509
  const env = { ...process.env, TERM: "xterm-256color", COLORTERM: "truecolor" };
410
510
  const cwd = path.dirname(DOC);
411
511
  const sh = process.env.SHELL || (os.platform() === "win32" ? "powershell.exe" : "/bin/bash");
412
512
  const opts = { name: "xterm-256color", cols, rows, cwd, env };
413
- // Launch through the user's interactive login shell so PATH, rc files,
414
- // and aliases apply — this is how `claude` is normally found.
513
+ // Launch through the user's interactive login shell so PATH, rc files, and aliases apply —
514
+ // this is how `claude` is normally found.
415
515
  try {
416
516
  return pty.spawn(sh, ["-ilc", CLAUDE_CMD], opts);
417
517
  } catch (e) {
@@ -419,18 +519,18 @@ function spawnPty(cols = 100, rows = 32) {
419
519
  `Could not start "${CLAUDE_CMD}" via ${sh} (${e.message}); opening a plain shell.`
420
520
  );
421
521
  }
422
- try {
423
- return pty.spawn(sh, [], opts);
424
- } catch (e) {
425
- console.error(
426
- `PTY could not start at all (${e.message}).\n` +
427
- `node-pty's prebuilt spawn-helper may have lost its executable bit. Restore it:\n` +
428
- ` chmod +x node_modules/node-pty/prebuilds/*/spawn-helper\n` +
429
- `If there's no prebuilt binary for your platform, build it instead:\n` +
430
- ` npm rebuild node-pty`
431
- );
432
- return null;
522
+ // Plain-shell fallback. If even this fails, a stripped +x bit on spawn-helper is the usual
523
+ // cause: self-heal it and retry once before giving up with a diagnosis.
524
+ for (let attempt = 0; attempt < 2; attempt++) {
525
+ try {
526
+ return pty.spawn(sh, [], opts);
527
+ } catch (e) {
528
+ if (attempt === 0 && ensureSpawnHelperExecutable()) continue; // fixed a bit → retry
529
+ console.error(diagnosePtyFailure(e));
530
+ return null;
531
+ }
433
532
  }
533
+ return null;
434
534
  }
435
535
 
436
536
  // Start the PTY and wire BOTH its data and exit handlers (the earlier version reattached
@@ -445,7 +545,13 @@ let rapidExits = 0;
445
545
  function startShell() {
446
546
  shell = spawnPty(ptyCols, ptyRows); // respawn at the current size, not the 100x32 default
447
547
  if (!shell) {
448
- broadcast({ type: "term", data: "Terminal unavailable see server console for the fix.\r\n" });
548
+ // Two cases, both non-fatal: node-pty isn't installed at all, or it is but the spawn
549
+ // failed (details already in the server console via diagnosePtyFailure). Either way the
550
+ // canvas + selection bridge work, so point the user at running claude in their own terminal.
551
+ const data = pty
552
+ ? "\r\nEmbedded terminal could not start (see the server console). The canvas and selection still work — run claude in your own terminal beside this window and it gets the same context.\r\n"
553
+ : "\r\nEmbedded terminal disabled: node-pty isn't installed. The canvas and selection still work — run claude in your own terminal beside this window (it gets the same context via the hooks). To enable the in-window terminal: npm i node-pty\r\n";
554
+ broadcast({ type: "term", data });
449
555
  return;
450
556
  }
451
557
  shellSpawnAt = Date.now();
@@ -479,17 +585,24 @@ const clients = new Set();
479
585
  wss.on("connection", (ws) => {
480
586
  clients.add(ws);
481
587
  let reconnect = false;
482
- if (!shell) {
483
- termBuffer = "";
484
- rapidExits = 0; // a fresh manual connect (e.g. reload) earns a clean slate of retries
485
- startShell();
486
- } else if (termBuffer) {
487
- // Reconnect (e.g. page reload): replay the current screen for instant content, and
488
- // flag for a forced repaint once this client's real size lands (see resize handler).
489
- ws.send(JSON.stringify({ type: "term", data: termBuffer }));
490
- reconnect = true;
588
+ if (DOC) {
589
+ if (!shell) {
590
+ termBuffer = "";
591
+ rapidExits = 0; // a fresh manual connect (e.g. reload) earns a clean slate of retries
592
+ startShell();
593
+ } else if (termBuffer) {
594
+ // Reconnect (e.g. page reload): replay the current screen for instant content, and
595
+ // flag for a forced repaint once this client's real size lands (see resize handler).
596
+ ws.send(JSON.stringify({ type: "term", data: termBuffer }));
597
+ reconnect = true;
598
+ }
599
+ ws.send(JSON.stringify({ type: "doc", content: read(), missing: !fs.existsSync(DOC) }));
600
+ } else {
601
+ // No document yet (launched with no file): don't spawn Claude — its working directory is
602
+ // unknown until the first pick. Tell the client to show the picker instead of a terminal.
603
+ // The message handler below still runs, so the client's "open" pick is honored.
604
+ ws.send(JSON.stringify({ type: "nodoc" }));
491
605
  }
492
- ws.send(JSON.stringify({ type: "doc", content: read(), missing: !fs.existsSync(DOC) }));
493
606
  ws.send(JSON.stringify({ type: "history", canUndo: history.length > 0 }));
494
607
 
495
608
  ws.on("message", (raw) => {
@@ -582,7 +695,7 @@ function broadcast(obj) {
582
695
  // and which would otherwise silence a file-level watch. A slow polling watch stays on
583
696
  // as a safety net; the `last` check keeps either path from double-broadcasting.
584
697
  let last = read();
585
- let lastMissing = !fs.existsSync(DOC);
698
+ let lastMissing = Boolean(DOC) && !fs.existsSync(DOC); // no doc yet → not "missing", just unset
586
699
  let updateTimer = null;
587
700
  // Undo history: prior document contents, newest last. Every observed change (from Claude,
588
701
  // the canvas, or an external editor) pushes the previous version, so the doc-side Undo
@@ -634,7 +747,7 @@ function startWatcher() {
634
747
  }
635
748
  fs.watchFile(DOC, { interval: 1000 }, maybeUpdate); // fallback safety net
636
749
  }
637
- startWatcher();
750
+ if (DOC) startWatcher(); // with no file yet, the watcher starts when the first doc is opened
638
751
 
639
752
  // Switch the active document at runtime (toolbar file picker) WITHOUT restarting the
640
753
  // Claude session. Only the content document changes; the support files (selection,
@@ -680,18 +793,32 @@ function openDoc(rawPath) {
680
793
  };
681
794
  if (resolved === DOC) return { error: "That document is already open." };
682
795
 
796
+ const firstDoc = DOC === null; // launched with no file → this pick initializes the session
683
797
  DOC = resolved;
684
798
  history.length = 0;
685
799
  historyBytes = 0;
686
800
  last = read();
687
801
  lastMissing = !fs.existsSync(DOC);
688
- writeRuntime(); // runtime.doc → new path, so canvas_edit edits the right file
689
- writeSelection("", []); // clear the (now-irrelevant) selection
802
+ if (firstDoc) {
803
+ // Anchor support files + hooks to this folder and register the MCP server, exactly as a
804
+ // file-on-launch start does at boot. The session's working directory is fixed from here.
805
+ initDocSession();
806
+ } else {
807
+ writeRuntime(); // runtime.doc → new path, so canvas_edit edits the right file
808
+ writeSelection("", []); // clear the (now-irrelevant) selection
809
+ }
690
810
  startWatcher(); // watch the new file/folder
691
811
 
692
812
  broadcast({ type: "opened", path: DOC, name: path.basename(DOC) });
693
813
  broadcast({ type: "doc", content: read(), missing: lastMissing });
694
814
  broadcastHistory();
815
+ // First document: now that the working directory is known, start Claude. Connected clients
816
+ // receive its output via broadcast. (Switching docs later preserves the session — no respawn.)
817
+ if (firstDoc && !shell) {
818
+ termBuffer = "";
819
+ rapidExits = 0;
820
+ startShell();
821
+ }
695
822
  return { ok: true }; // no shell.kill() — the Claude session is preserved
696
823
  }
697
824
 
@@ -707,7 +834,7 @@ function onServerError(e) {
707
834
  ` mdinterface ${JSON.stringify(path.basename(DOC))} --port 8001\n`
708
835
  );
709
836
  } else {
710
- console.error(`\n mdinterface could not start: ${(e && e.message) || e}\n`);
837
+ console.error(`\n mdinterface could not start: ${e?.message || e}\n`);
711
838
  }
712
839
  process.exit(1);
713
840
  }
@@ -716,7 +843,7 @@ wss.on("error", onServerError);
716
843
 
717
844
  server.listen(PORT, "127.0.0.1", () => {
718
845
  const url = `http://localhost:${PORT}/?t=${TOKEN}`;
719
- console.log(`mdinterface ▸ ${path.basename(DOC)} ▸ ${url}`);
846
+ console.log(`mdinterface ▸ ${DOC ? path.basename(DOC) : "(pick a document)"} ▸ ${url}`);
720
847
  const opener =
721
848
  os.platform() === "darwin" ? "open" : os.platform() === "win32" ? "start" : "xdg-open";
722
849
  require("node:child_process").exec(`${opener} "${url}"`);