mdinterface 0.2.0 → 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.2.0",
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",
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
@@ -28,17 +28,36 @@ try {
28
28
 
29
29
  // ---------- args ----------
30
30
  const args = process.argv.slice(2);
31
- const fileArg = args.find((a) => !a.startsWith("--"));
32
- if (!fileArg) {
33
- console.error("Usage: mdinterface <file.md> [--port 7777] [--cmd claude]");
34
- 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);
35
47
  }
36
- let DOC = path.resolve(fileArg); // reassignable: the toolbar file picker can switch docs
37
- 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)) {
38
57
  console.error(`File not found: ${DOC}`);
39
58
  process.exit(1);
40
59
  }
41
- if (fs.statSync(DOC).isDirectory()) {
60
+ if (DOC && fs.statSync(DOC).isDirectory()) {
42
61
  console.error(
43
62
  `${DOC} is a directory — point me at a markdown file, e.g.:\n node server.js ${path.join(fileArg, "doc.md")}`
44
63
  );
@@ -74,7 +93,11 @@ app.use(express.static(path.join(__dirname, "public")));
74
93
  app.get("/doc", (req, res) => {
75
94
  if (req.query.t !== TOKEN) return res.status(403).end();
76
95
  // path is returned (for the file picker) but only to a token-holding client.
77
- 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
+ );
78
101
  });
79
102
  // Directory listing for the file browser: folders (to navigate) + markdown files. Token
80
103
  // gated. Lists any dir the user can read — same reach the PTY already has.
@@ -83,7 +106,9 @@ app.get("/ls", (req, res) => {
83
106
  const dir =
84
107
  typeof req.query.dir === "string" && req.query.dir
85
108
  ? path.resolve(req.query.dir)
86
- : path.dirname(DOC);
109
+ : DOC
110
+ ? path.dirname(DOC)
111
+ : process.cwd(); // no doc yet → browse from where mdinterface was launched
87
112
  let ents;
88
113
  try {
89
114
  ents = fs.readdirSync(dir, { withFileTypes: true });
@@ -130,14 +155,22 @@ function read() {
130
155
  // The canvas never types into Claude. Instead the current selection is mirrored to a
131
156
  // file, and a UserPromptSubmit hook injects that file as context on the user's next
132
157
  // message — so Claude is ambiently aware of what's selected, with zero prompt noise.
133
- const CLAUDE_DIR = path.join(path.dirname(DOC), ".claude");
134
- 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;
135
162
 
136
163
  // ---------- runtime file: how the separate MCP process reaches this server ----------
137
164
  // canvas_edit writes the open document directly; canvas_open POSTs back to this server. The
138
165
  // MCP process reads port/token/doc from this file before each call, so edits follow the
139
166
  // toolbar file picker. Edits always apply immediately — undo is the Undo button (or git).
140
- 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
+ }
141
174
  function writeRuntime() {
142
175
  try {
143
176
  fs.mkdirSync(CLAUDE_DIR, { recursive: true });
@@ -349,18 +382,25 @@ function installMcpServer() {
349
382
  }
350
383
  }
351
384
 
352
- installHooks();
353
- installMcpServer();
354
- writeRuntime(); // mode + callback info for the MCP server
355
- 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();
356
396
 
357
397
  for (const sig of ["exit", "SIGINT", "SIGTERM"]) {
358
398
  process.on(sig, () => {
359
399
  try {
360
- fs.writeFileSync(SEL_FILE, "");
400
+ if (SEL_FILE) fs.writeFileSync(SEL_FILE, "");
361
401
  } catch {}
362
402
  try {
363
- fs.unlinkSync(RUNTIME_FILE);
403
+ if (RUNTIME_FILE) fs.unlinkSync(RUNTIME_FILE);
364
404
  } catch {} // gone → MCP server defaults back to auto
365
405
  if (sig !== "exit") process.exit(0);
366
406
  });
@@ -448,7 +488,7 @@ function ensureSpawnHelperExecutable() {
448
488
 
449
489
  // Turn a spawn failure into a specific, actionable message instead of a generic one.
450
490
  function diagnosePtyFailure(e) {
451
- const msg = (e && e.message) || String(e);
491
+ const msg = e?.message || String(e);
452
492
  let hint;
453
493
  if (/NODE_MODULE_VERSION|compiled against|different Node/i.test(msg))
454
494
  hint = "node-pty was built for a different Node version — rebuild it:\n npm rebuild node-pty";
@@ -545,17 +585,24 @@ const clients = new Set();
545
585
  wss.on("connection", (ws) => {
546
586
  clients.add(ws);
547
587
  let reconnect = false;
548
- if (!shell) {
549
- termBuffer = "";
550
- rapidExits = 0; // a fresh manual connect (e.g. reload) earns a clean slate of retries
551
- startShell();
552
- } else if (termBuffer) {
553
- // Reconnect (e.g. page reload): replay the current screen for instant content, and
554
- // flag for a forced repaint once this client's real size lands (see resize handler).
555
- ws.send(JSON.stringify({ type: "term", data: termBuffer }));
556
- 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" }));
557
605
  }
558
- ws.send(JSON.stringify({ type: "doc", content: read(), missing: !fs.existsSync(DOC) }));
559
606
  ws.send(JSON.stringify({ type: "history", canUndo: history.length > 0 }));
560
607
 
561
608
  ws.on("message", (raw) => {
@@ -648,7 +695,7 @@ function broadcast(obj) {
648
695
  // and which would otherwise silence a file-level watch. A slow polling watch stays on
649
696
  // as a safety net; the `last` check keeps either path from double-broadcasting.
650
697
  let last = read();
651
- let lastMissing = !fs.existsSync(DOC);
698
+ let lastMissing = Boolean(DOC) && !fs.existsSync(DOC); // no doc yet → not "missing", just unset
652
699
  let updateTimer = null;
653
700
  // Undo history: prior document contents, newest last. Every observed change (from Claude,
654
701
  // the canvas, or an external editor) pushes the previous version, so the doc-side Undo
@@ -700,7 +747,7 @@ function startWatcher() {
700
747
  }
701
748
  fs.watchFile(DOC, { interval: 1000 }, maybeUpdate); // fallback safety net
702
749
  }
703
- startWatcher();
750
+ if (DOC) startWatcher(); // with no file yet, the watcher starts when the first doc is opened
704
751
 
705
752
  // Switch the active document at runtime (toolbar file picker) WITHOUT restarting the
706
753
  // Claude session. Only the content document changes; the support files (selection,
@@ -746,18 +793,32 @@ function openDoc(rawPath) {
746
793
  };
747
794
  if (resolved === DOC) return { error: "That document is already open." };
748
795
 
796
+ const firstDoc = DOC === null; // launched with no file → this pick initializes the session
749
797
  DOC = resolved;
750
798
  history.length = 0;
751
799
  historyBytes = 0;
752
800
  last = read();
753
801
  lastMissing = !fs.existsSync(DOC);
754
- writeRuntime(); // runtime.doc → new path, so canvas_edit edits the right file
755
- 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
+ }
756
810
  startWatcher(); // watch the new file/folder
757
811
 
758
812
  broadcast({ type: "opened", path: DOC, name: path.basename(DOC) });
759
813
  broadcast({ type: "doc", content: read(), missing: lastMissing });
760
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
+ }
761
822
  return { ok: true }; // no shell.kill() — the Claude session is preserved
762
823
  }
763
824
 
@@ -773,7 +834,7 @@ function onServerError(e) {
773
834
  ` mdinterface ${JSON.stringify(path.basename(DOC))} --port 8001\n`
774
835
  );
775
836
  } else {
776
- console.error(`\n mdinterface could not start: ${(e && e.message) || e}\n`);
837
+ console.error(`\n mdinterface could not start: ${e?.message || e}\n`);
777
838
  }
778
839
  process.exit(1);
779
840
  }
@@ -782,7 +843,7 @@ wss.on("error", onServerError);
782
843
 
783
844
  server.listen(PORT, "127.0.0.1", () => {
784
845
  const url = `http://localhost:${PORT}/?t=${TOKEN}`;
785
- console.log(`mdinterface ▸ ${path.basename(DOC)} ▸ ${url}`);
846
+ console.log(`mdinterface ▸ ${DOC ? path.basename(DOC) : "(pick a document)"} ▸ ${url}`);
786
847
  const opener =
787
848
  os.platform() === "darwin" ? "open" : os.platform() === "win32" ? "start" : "xdg-open";
788
849
  require("node:child_process").exec(`${opener} "${url}"`);