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 +8 -0
- package/mcp-server.js +1 -1
- package/package.json +2 -1
- package/public/index.html +18 -1
- package/server.js +96 -35
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: "
|
|
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.
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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(
|
|
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
|
-
:
|
|
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
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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 =
|
|
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 (
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
|
|
755
|
-
|
|
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: ${
|
|
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}"`);
|