mdinterface 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kevin Sundstrom
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,131 @@
1
+ # mdinterface
2
+
3
+ [![CI](https://github.com/kevinsundstrom/mdinterface/actions/workflows/ci.yml/badge.svg)](https://github.com/kevinsundstrom/mdinterface/actions/workflows/ci.yml)
4
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
5
+ ![Node](https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg)
6
+
7
+ <!-- TODO: drop a demo GIF here (select text → ask → the block flashes and re-renders). It's a
8
+ visual tool; a 10-second capture sells it faster than any paragraph. e.g. docs/demo.gif -->
9
+
10
+ mdinterface makes editing with Claude precise. A rendered markdown canvas sits beside a live
11
+ Claude session, bridged by the file on disk. The passage you highlight sets both what Claude
12
+ sees and what it is allowed to change, so edits stay scoped to what you pointed at instead of
13
+ sprawling across the document. Ask for a small fix or a full rewrite of that part, and watch
14
+ just that block change and re-render.
15
+
16
+ The two panes never talk to each other directly. The file on disk is the interface,
17
+ and Claude's awareness is wired through its hooks, not by typing into its prompt:
18
+
19
+
20
+ full doc ──SessionStart hook──▶ in Claude's context from the start
21
+ canvas selection ──file + UserPromptSubmit hook──▶ rides along with each message
22
+ Claude Code ──edits──────────────▶ the file on disk
23
+ file watcher ──content────────────▶ canvas re-render
24
+
25
+ ## Setup
26
+
27
+ cd mdinterface
28
+ npm install # express, ws, node-pty (ships a native helper)
29
+
30
+ Requires Node 18+ and the `claude` CLI on your PATH.
31
+
32
+ If the terminal pane says **"Terminal unavailable,"** `node-pty`'s prebuilt helper lost
33
+ its executable bit (a common install hiccup). Restore it:
34
+
35
+ chmod +x node_modules/node-pty/prebuilds/*/spawn-helper
36
+
37
+ If there's no prebuilt binary for your platform, build it instead (needs Xcode Command
38
+ Line Tools / build-essential):
39
+
40
+ npm rebuild node-pty
41
+
42
+ The rendering and terminal libraries (`marked`, `xterm` and its addons, `DOMPurify`) are
43
+ **bundled in `public/vendor/`** and served locally, so mdinterface works fully offline — no
44
+ CDN, no first-load internet requirement.
45
+
46
+ ## Run
47
+
48
+ node server.js path/to/doc.md
49
+ # prints a URL like http://localhost:7777/?t=… — open THAT (it carries a session token)
50
+
51
+ Options:
52
+
53
+ --port 8000 # different port
54
+ --cmd "claude --continue" # custom launch command (or set MDINTERFACE_CMD)
55
+
56
+ ## Use
57
+
58
+ - **Select** any text in the rendered doc and it becomes *ambient context* — nothing is
59
+ typed into the prompt. The selection is mirrored to `.claude/mdinterface-selection.txt`
60
+ next to the doc, and a `UserPromptSubmit` hook (auto-installed into
61
+ `.claude/settings.local.json`) silently attaches it to your next message. Give a normal
62
+ instruction — "tighten this up", "explain this" — and Claude already knows what "this"
63
+ is. Clear the selection and it stops.
64
+ - **Edits apply immediately** — Claude changes the file and the canvas re-renders. To take
65
+ one back, use the **Undo** button (rolls back the last change) or ask Claude to revert it;
66
+ in a git repo, every change is also one `git diff` away.
67
+ - The right pane is the genuine CLI: `/commands`, plan mode, `claude --resume`,
68
+ everything works, because it *is* claude running in a PTY.
69
+ - When Claude (or anything else — your editor, git checkout) changes the file,
70
+ only what changed flashes green and updates. Scroll position is preserved.
71
+
72
+ ## Notes
73
+
74
+ - If `claude` isn't found, it falls back to your shell so you can debug.
75
+ - Undo = git. Run it in a repo and every accepted change is one `git diff` away.
76
+
77
+ ## Security
78
+
79
+ mdinterface runs a live shell/Claude session over a local WebSocket, so it is locked down to
80
+ this machine only:
81
+
82
+ - The server binds to **127.0.0.1** (loopback), never reachable from the network.
83
+ - Every request carries a **per-launch token** (in the URL); the WebSocket also checks the
84
+ `Origin` and `Host` headers, so a website you visit can't connect to it.
85
+ - Rendered markdown HTML is **sanitized** (DOMPurify) before display.
86
+
87
+ Still your responsibility:
88
+
89
+ - **Only open documents you trust.** The whole file is fed into Claude's context and an
90
+ auto-approved `canvas_edit` tool can write it, so a malicious document is a
91
+ prompt-injection vector.
92
+ - **Single-user machines only.** The launch token is written to
93
+ `.claude/mdinterface-runtime.json` (mode `600`) so the helper process can reach the server.
94
+ Anyone who can read your files can read that token and drive the session — fine on your
95
+ own machine, not on a shared host.
96
+ - mdinterface **writes into the document's folder**: `.claude/settings.local.json`
97
+ (hooks + MCP pre-approval), `.mcp.json`, `.claude/mdinterface-selection.txt`, and
98
+ `.claude/mdinterface-runtime.json`. The shipped `.gitignore` covers these for mdinterface's
99
+ own repo; when you point it at a doc in **another** repo, add them to that repo's
100
+ `.gitignore`.
101
+
102
+ ### Threat model — how you'd actually get owned
103
+
104
+ There is essentially **one door, and it's a document you didn't write.** A remote attacker
105
+ can't reach the server (loopback bind + per-launch token + WebSocket `Origin`/`Host` checks
106
+ mean a website you visit or someone on your network can't connect — they don't have the token
107
+ and can't read it cross-origin). So the only way in is to get a file *to* you and have you
108
+ open it in the canvas — a contributor's PR, a shared `.md`, a downloaded doc.
109
+
110
+ When you open it, the **whole file is fed into Claude's context**, so any instructions hidden
111
+ in it (an HTML comment, white-on-white text, or just confident prose) are read as if you'd
112
+ typed them. From there:
113
+
114
+ - **The dangerous escalation is `Bash`, and `Bash` is not pre-approved** — it hits the normal
115
+ permission prompt. So a poisoned doc degrades to social engineering: it tries to get Claude
116
+ to propose a benign-looking command (`npm install && ./setup.sh`) that you approve on
117
+ autopilot. **Approving a command you didn't ask for, with a foreign doc open, is the whole
118
+ ballgame** — that's RCE as you. Don't.
119
+ - **Never run the pane in bypass-permissions / "YOLO" mode with an untrusted doc open.** That
120
+ auto-approves `Bash`, turning the above into a true zero-click RCE on file open.
121
+ - **Zero-click, no approval needed (bounded):** an injection can still use whatever is already
122
+ auto-approved — e.g. a `WebFetch(domain:…)` grant becomes a silent exfiltration channel
123
+ (doc/selection text smuggled in a URL), and `canvas_edit` can silently tamper with the doc
124
+ you're about to publish. **Prune auto-approve grants you aren't using.**
125
+
126
+ What an attacker **cannot** do, by construction: reach you over the network, escape
127
+ `canvas_edit`/`canvas_open` into `.claude/` to plant a hook (path-scoped: realpath +
128
+ extension allow-list + `.claude` refusal), steal the token via XSS (sanitized, with a
129
+ plain-text fallback), or read the `600` token file as another user. The boundary that's
130
+ *yours* to hold is the first line: **treat any document you didn't write as untrusted input,
131
+ and don't approve commands for it.**
package/access.js ADDED
@@ -0,0 +1,60 @@
1
+ /* Access control for mdinterface. The server drives a live shell/Claude PTY, so it must be
2
+ * reachable only from this machine's loopback, only by clients holding the per-launch
3
+ * token, and (for WebSockets) only from the mdinterface page itself — not a random site you
4
+ * visit (WebSockets bypass same-origin) and not via DNS rebinding.
5
+ *
6
+ * Exported as a factory so it can be unit-tested without starting the server.
7
+ *
8
+ * @param {number} PORT the loopback port the server listens on
9
+ * @param {string} TOKEN the per-launch secret required on every request
10
+ */
11
+ module.exports = function makeAccess(PORT, TOKEN) {
12
+ const ALLOWED_HOSTS = new Set([`localhost:${PORT}`, `127.0.0.1:${PORT}`, `[::1]:${PORT}`]);
13
+
14
+ /**
15
+ * Pull the `?t=` token out of a request URL (resolved against `host`).
16
+ * @param {string} reqUrl
17
+ * @param {string} [host]
18
+ * @returns {string | null}
19
+ */
20
+ function tokenOf(reqUrl, host) {
21
+ try {
22
+ return new URL(reqUrl, `http://${host || "localhost"}`).searchParams.get("t");
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ /**
29
+ * True iff the WebSocket Origin is a loopback origin on our port (or absent — non-browser).
30
+ * Exact host equality, so `127.0.0.1.attacker.com` can't slip past a substring check.
31
+ * @param {string} [origin]
32
+ * @returns {boolean}
33
+ */
34
+ function originAllowed(origin) {
35
+ if (!origin) return true; // non-browser clients omit Origin; the token still gates them
36
+ try {
37
+ const u = new URL(origin);
38
+ const loopback =
39
+ u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname === "[::1]";
40
+ return loopback && (u.port === String(PORT) || (!u.port && String(PORT) === "80"));
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Gate a WebSocket upgrade: exact Host (anti-rebinding) + loopback Origin + valid token.
48
+ * @param {{ url?: string, headers: { host?: string, origin?: string } }} req
49
+ * @returns {boolean}
50
+ */
51
+ function wsAllowed(req) {
52
+ return (
53
+ ALLOWED_HOSTS.has(req.headers.host) && // DNS-rebinding guard
54
+ originAllowed(req.headers.origin) && // cross-site WebSocket guard
55
+ tokenOf(req.url, req.headers.host) === TOKEN
56
+ ); // per-launch secret token
57
+ }
58
+
59
+ return { ALLOWED_HOSTS, tokenOf, originAllowed, wsAllowed };
60
+ };
package/mcp-server.js ADDED
@@ -0,0 +1,269 @@
1
+ #!/usr/bin/env node
2
+ /* mdinterface MCP server — exposes `canvas_edit` so Claude can edit the canvas document
3
+ * WITHOUT the built-in Edit/Write tools' mandatory prior Read. The document is already
4
+ * in Claude's context (via the SessionStart hook), so it can supply old/new text
5
+ * directly. This process just writes the file; the running mdinterface server's file
6
+ * watcher broadcasts the change, so the canvas re-renders instantly.
7
+ *
8
+ * Transport: newline-delimited JSON-RPC 2.0 over stdio (the MCP stdio transport).
9
+ * Only stdout carries protocol messages — never log to stdout; use stderr.
10
+ */
11
+ const fs = require("node:fs");
12
+ const path = require("node:path");
13
+
14
+ const DOC = process.argv[2] ? path.resolve(process.argv[2]) : null;
15
+ const RUNTIME_FILE = DOC
16
+ ? path.join(path.dirname(DOC), ".claude", "mdinterface-runtime.json")
17
+ : null;
18
+
19
+ // How to reach the mdinterface server (port/token) and which document is open (doc), written by
20
+ // the server. Used by canvas_open and to follow file switches; absent ⇒ empty (editing still
21
+ // works, since canvas_edit writes the file directly).
22
+ function runtime() {
23
+ try {
24
+ return JSON.parse(fs.readFileSync(RUNTIME_FILE, "utf8"));
25
+ } catch {
26
+ return {};
27
+ }
28
+ }
29
+
30
+ // Switch the canvas to a different document via the server (so it broadcasts + re-points
31
+ // the watcher). The file must already exist.
32
+ async function canvasOpen(args) {
33
+ const p = args?.path;
34
+ if (typeof p !== "string" || !p.trim()) throw new Error("path is required.");
35
+ const rt = runtime();
36
+ if (!rt.port || !rt.token) throw new Error("The mdinterface server can't be reached.");
37
+ let r;
38
+ try {
39
+ r = await fetch(`http://127.0.0.1:${rt.port}/open?t=${rt.token}`, {
40
+ method: "POST",
41
+ headers: { "content-type": "application/json" },
42
+ body: JSON.stringify({ path: p }),
43
+ });
44
+ } catch {
45
+ throw new Error("The mdinterface server can't be reached.");
46
+ }
47
+ const j = await r.json().catch(() => ({}));
48
+ if (j.error) throw new Error(j.error);
49
+ return `Opened ${path.basename(p)} in the canvas.`;
50
+ }
51
+
52
+ function send(msg) {
53
+ process.stdout.write(`${JSON.stringify(msg)}\n`);
54
+ }
55
+ function result(id, res) {
56
+ send({ jsonrpc: "2.0", id, result: res });
57
+ }
58
+ function rpcError(id, code, message) {
59
+ send({ jsonrpc: "2.0", id, error: { code, message } });
60
+ }
61
+
62
+ const TOOLS = [
63
+ {
64
+ name: "canvas_edit",
65
+ description:
66
+ "Edit the markdown document shown in the mdinterface canvas by replacing an exact " +
67
+ "string. Use this INSTEAD of the built-in Edit/Write tools for the canvas document: " +
68
+ "it needs no prior Read (the document is already in your context) and the canvas " +
69
+ "updates instantly. `old_string` must match the file exactly and be unique unless " +
70
+ "`replace_all` is true. To delete text, pass an empty `new_string`.",
71
+ inputSchema: {
72
+ type: "object",
73
+ properties: {
74
+ old_string: {
75
+ type: "string",
76
+ description: "Exact text to replace, verbatim from the document.",
77
+ },
78
+ new_string: { type: "string", description: "Replacement text (empty string to delete)." },
79
+ replace_all: { type: "boolean", description: "Replace every occurrence (default false)." },
80
+ },
81
+ required: ["old_string", "new_string"],
82
+ },
83
+ },
84
+ {
85
+ name: "canvas_open",
86
+ description:
87
+ "Switch the mdinterface canvas to a different document so the user sees and edits it. " +
88
+ "Pass an absolute path to a local .md file (the file must already exist — write it " +
89
+ "first if needed). Use this to open a draft you just created, e.g. one pulled from " +
90
+ "Notion. Does not restart the session.",
91
+ inputSchema: {
92
+ type: "object",
93
+ properties: {
94
+ path: { type: "string", description: "Absolute path to the .md file to open." },
95
+ },
96
+ required: ["path"],
97
+ },
98
+ },
99
+ ];
100
+
101
+ // A regex that matches `str` literally EXCEPT runs of whitespace match any whitespace,
102
+ // so an edit succeeds even if the model got indentation / line-wrapping slightly off.
103
+ /**
104
+ * @param {string} str
105
+ * @param {string} flags
106
+ * @returns {RegExp}
107
+ */
108
+ function wsTolerantRegex(str, flags) {
109
+ const escaped = str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
110
+ return new RegExp(escaped.replace(/\s+/g, "\\s+"), flags);
111
+ }
112
+
113
+ // Line numbers of up to 5 exact matches, for a "not unique" hint.
114
+ function matchLines(content, str) {
115
+ const lines = [];
116
+ let idx = content.indexOf(str);
117
+ while (idx !== -1 && lines.length < 5) {
118
+ lines.push(content.slice(0, idx).split("\n").length);
119
+ idx = content.indexOf(str, idx + str.length);
120
+ }
121
+ return lines.length ? ` (lines ${lines.join(", ")})` : "";
122
+ }
123
+
124
+ // When nothing matches, point the model at the closest text so it fixes its next attempt
125
+ // in one try instead of guessing — the main cause of repeated failed edits.
126
+ function nearbyHint(content, oldStr) {
127
+ const norm = oldStr.replace(/\s+/g, " ").trim();
128
+ for (let len = Math.min(norm.length, 50); len >= 10; len -= 5) {
129
+ let m;
130
+ try {
131
+ m = wsTolerantRegex(norm.slice(0, len), "").exec(content);
132
+ } catch {
133
+ continue;
134
+ }
135
+ if (m) {
136
+ const line = content.slice(0, m.index).split("\n").length;
137
+ const text = (content.split("\n")[line - 1] || "").trim();
138
+ return ` Closest text is at line ${line}: «${text.slice(0, 90)}».`;
139
+ }
140
+ }
141
+ return "";
142
+ }
143
+
144
+ // Match (exact, then whitespace-tolerant) and apply — or, with dryRun, just validate and
145
+ // return the message that WOULD result without writing (used by the unit tests).
146
+ /**
147
+ * @param {string} doc absolute path to the document to edit
148
+ * @param {string} old_string exact text to replace
149
+ * @param {string} new_string replacement text ("" to delete)
150
+ * @param {boolean} [replace_all] replace every occurrence instead of requiring uniqueness
151
+ * @param {boolean} [dryRun] validate only — do not write the file
152
+ * @returns {string} a human-readable result message
153
+ */
154
+ function applyEdit(doc, old_string, new_string, replace_all, dryRun) {
155
+ const content = fs.readFileSync(doc, "utf8");
156
+ const base = path.basename(doc);
157
+
158
+ const exact = content.split(old_string).length - 1;
159
+ if (exact >= 1) {
160
+ if (exact > 1 && !replace_all)
161
+ throw new Error(
162
+ `old_string matches ${exact} places${matchLines(content, old_string)} — add surrounding context to make it unique, or set replace_all: true.`
163
+ );
164
+ if (!dryRun)
165
+ fs.writeFileSync(
166
+ doc,
167
+ replace_all
168
+ ? content.split(old_string).join(new_string)
169
+ : content.replace(old_string, () => new_string)
170
+ );
171
+ return replace_all ? `Replaced ${exact} occurrence(s) in ${base}.` : `Edited ${base}.`;
172
+ }
173
+
174
+ const re = wsTolerantRegex(old_string, "g");
175
+ const fuzzy = (content.match(re) || []).length;
176
+ if (fuzzy >= 1) {
177
+ if (fuzzy > 1 && !replace_all)
178
+ throw new Error(
179
+ `old_string matches ${fuzzy} places (ignoring whitespace) — add surrounding context, or set replace_all: true.`
180
+ );
181
+ if (!dryRun)
182
+ fs.writeFileSync(
183
+ doc,
184
+ content.replace(re, () => new_string)
185
+ ); // global re; fuzzy===1 unless replace_all
186
+ return `Edited ${base} (matched ignoring whitespace differences).`;
187
+ }
188
+
189
+ throw new Error(
190
+ `old_string not found in ${base}.${nearbyHint(content, old_string)} ` +
191
+ `Copy the text verbatim from the document, including punctuation such as em dashes (—) and arrows.`
192
+ );
193
+ }
194
+
195
+ async function canvasEdit(args) {
196
+ const { old_string, new_string, replace_all } = args || {};
197
+ if (typeof old_string !== "string" || typeof new_string !== "string")
198
+ throw new Error("old_string and new_string are required strings.");
199
+ if (!old_string) throw new Error("old_string must not be empty.");
200
+ if (old_string === new_string) throw new Error("old_string and new_string are identical.");
201
+
202
+ const rt = runtime();
203
+ // The currently-open document — read from the runtime file so canvas_edit follows the
204
+ // file picker, falling back to the doc this server was launched with.
205
+ const doc = rt.doc ? path.resolve(rt.doc) : DOC;
206
+ if (!doc) throw new Error("No document is open.");
207
+ return applyEdit(doc, old_string, new_string, replace_all, false);
208
+ }
209
+
210
+ function handle(msg) {
211
+ const { id, method, params } = msg;
212
+ if (method === "initialize") {
213
+ result(id, {
214
+ protocolVersion: params?.protocolVersion || "2025-06-18",
215
+ capabilities: { tools: {} },
216
+ serverInfo: { name: "mdinterface", version: "0.1.0" },
217
+ });
218
+ } else if (method === "tools/list") {
219
+ result(id, { tools: TOOLS });
220
+ } else if (method === "tools/call") {
221
+ const name = params?.name;
222
+ const tool = name === "canvas_edit" ? canvasEdit : name === "canvas_open" ? canvasOpen : null;
223
+ if (!tool) return rpcError(id, -32602, `Unknown tool: ${name}`);
224
+ // Tools are async; surface failures as isError results
225
+ // so the model can react and retry.
226
+ tool(params.arguments)
227
+ .then((text) => result(id, { content: [{ type: "text", text }] }))
228
+ .catch((e) =>
229
+ result(id, { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true })
230
+ );
231
+ } else if (method === "ping") {
232
+ result(id, {});
233
+ } else if (id !== undefined) {
234
+ rpcError(id, -32601, `Method not found: ${method}`);
235
+ }
236
+ // notifications (no id), e.g. notifications/initialized — nothing to return
237
+ }
238
+
239
+ // Run the stdio JSON-RPC loop only when invoked directly. When this file is `require`d
240
+ // (e.g. by the unit tests) the loop stays dormant and only the pure helpers are exposed.
241
+ if (require.main === module) {
242
+ let buf = "";
243
+ process.stdin.setEncoding("utf8");
244
+ process.stdin.on("data", (chunk) => {
245
+ buf += chunk;
246
+ while (true) {
247
+ const nl = buf.indexOf("\n");
248
+ if (nl === -1) break;
249
+ const line = buf.slice(0, nl).trim();
250
+ buf = buf.slice(nl + 1);
251
+ if (!line) continue;
252
+ let msg;
253
+ try {
254
+ msg = JSON.parse(line);
255
+ } catch {
256
+ continue;
257
+ }
258
+ try {
259
+ handle(msg);
260
+ } catch (e) {
261
+ if (msg && msg.id !== undefined) rpcError(msg.id, -32603, e.message);
262
+ }
263
+ }
264
+ });
265
+ process.stdin.on("end", () => process.exit(0));
266
+ }
267
+
268
+ // Exposed for unit tests; importing this module never starts the stdio server (see guard above).
269
+ module.exports = { applyEdit, wsTolerantRegex, matchLines, nearbyHint };
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "mdinterface",
3
+ "version": "0.1.1",
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
+ "license": "MIT",
6
+ "author": "Kevin Sundstrom",
7
+ "type": "commonjs",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/kevinsundstrom/mdinterface.git"
11
+ },
12
+ "homepage": "https://github.com/kevinsundstrom/mdinterface#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/kevinsundstrom/mdinterface/issues"
15
+ },
16
+ "bin": {
17
+ "mdinterface": "server.js"
18
+ },
19
+ "scripts": {
20
+ "start": "bash start.sh",
21
+ "lint": "biome check .",
22
+ "format": "biome format --write .",
23
+ "typecheck": "tsc --noEmit",
24
+ "test": "node --test",
25
+ "prepublishOnly": "npm run lint && npm run typecheck && npm test"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "keywords": [
31
+ "claude",
32
+ "claude-code",
33
+ "markdown",
34
+ "canvas",
35
+ "live-preview",
36
+ "editor",
37
+ "terminal",
38
+ "mcp"
39
+ ],
40
+ "engines": {
41
+ "node": ">=18"
42
+ },
43
+ "files": [
44
+ "server.js",
45
+ "mcp-server.js",
46
+ "access.js",
47
+ "start.sh",
48
+ "public/",
49
+ "README.md",
50
+ "LICENSE"
51
+ ],
52
+ "dependencies": {
53
+ "express": "^4.19.0",
54
+ "node-pty": "^1.0.0",
55
+ "ws": "^8.17.0"
56
+ },
57
+ "devDependencies": {
58
+ "@biomejs/biome": "^2.0.0",
59
+ "@types/express": "^4.17.21",
60
+ "@types/node": "^20.14.0",
61
+ "@types/ws": "^8.5.10",
62
+ "typescript": "^5.5.0"
63
+ }
64
+ }