md-collab-editor 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,3 @@
1
+ """MD Collaborative Editor."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .server import main
2
+
3
+ main()
@@ -0,0 +1,406 @@
1
+ #!/usr/bin/env python3
2
+ """MD Collaborative Editor: a local GitHub-style markdown editor with Claude in the loop.
3
+
4
+ Serves the editor UI, reads and writes .md files under a root folder, pushes
5
+ on-disk changes to the browser (so edits made by Claude Code in a terminal show
6
+ up live), and answers "ask Claude" requests by running `claude -p` headless.
7
+
8
+ md-editor # edit the current folder
9
+ md-editor ~/notes # edit a folder
10
+ md-editor ~/proj/README.md # edit one file (its folder becomes the root)
11
+ """
12
+
13
+ import argparse
14
+ import json
15
+ import os
16
+ import shutil
17
+ import subprocess
18
+ import tempfile
19
+ import threading
20
+ import time
21
+ import webbrowser
22
+ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
23
+ from pathlib import Path
24
+ from urllib.parse import parse_qs, urlparse
25
+
26
+ HERE = Path(__file__).resolve().parent
27
+ STATIC = HERE / "static"
28
+ SKILLS_DIR = Path.home() / ".claude" / "skills"
29
+ MD_EXT = (".md", ".markdown")
30
+ SKIP_DIRS = {"node_modules", "__pycache__", ".git", ".venv", "venv"}
31
+
32
+ ROOT: Path = Path.cwd()
33
+ CLAUDE_BIN = shutil.which("claude") or "claude"
34
+ CHROME_BIN = next((b for b in (os.environ.get("MDEDIT_CHROME"), "google-chrome", "google-chrome-stable",
35
+ "chromium", "chromium-browser") if b and shutil.which(b)), None)
36
+
37
+ PDF_TEMPLATE = """<!doctype html>
38
+ <html lang="en"><head><meta charset="utf-8"><title>{title}</title>
39
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.5.1/github-markdown-light.min.css">
40
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css">
41
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">
42
+ <style>
43
+ @page {{ size: A4; margin: 16mm 15mm 18mm; }}
44
+ body {{ margin: 0; background: #fff; }}
45
+ .markdown-body {{ font-size: 11pt; max-width: none; padding: 0; }}
46
+ .markdown-body .math-block {{ margin-bottom: 16px; }}
47
+ .markdown-body .mermaid-box {{ text-align: center; margin-bottom: 16px; }}
48
+ .markdown-body .mermaid-box svg {{ max-width: 100%; height: auto; }}
49
+ .markdown-body .mermaid-src, .markdown-body .anchor {{ display: none; }}
50
+ .markdown-body .task-list-item {{ list-style: none; }}
51
+ .markdown-body .task-list-item input {{ margin: 0 0.2em 0.25em -1.4em; vertical-align: middle; }}
52
+ .markdown-body pre, .markdown-body table, .markdown-body img, .markdown-body .markdown-alert,
53
+ .markdown-body .math-block, .markdown-body .mermaid-box {{ break-inside: avoid; }}
54
+ .markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4 {{ break-after: avoid; }}
55
+ .markdown-body pre code {{ white-space: pre-wrap; word-break: break-word; }}
56
+ </style></head>
57
+ <body><article class="markdown-body">{body}</article></body></html>
58
+ """
59
+
60
+ SYSTEM_PROMPT = """You are a writing assistant embedded in a markdown editor. The user has \
61
+ highlighted part of a markdown document and asked you to do something with it. \
62
+ The full document is supplied for context; the highlighted part is wrapped in \
63
+ ⟦SELECTION⟧ … ⟦/SELECTION⟧ markers.
64
+
65
+ Rules:
66
+ - Reply with the result ONLY: no preamble, no explanation, no closing remarks, \
67
+ no surrounding quotes, no code fence around the whole reply.
68
+ - In "replace" mode your reply replaces the selection verbatim, so it must be \
69
+ valid markdown that fits seamlessly where the selection was. Keep the existing \
70
+ markdown structure (headings, lists, links, emphasis, math, code) unless asked \
71
+ to change it. Do not include text from outside the selection.
72
+ - In "comment" mode reply with concise feedback in markdown; the document is not changed.
73
+ - British English spelling unless the document clearly uses another convention.
74
+ - If asked to use a skill, invoke it with the Skill tool before writing."""
75
+
76
+
77
+ # ---------------------------------------------------------------- files
78
+
79
+ def safe_path(rel: str) -> Path:
80
+ p = (ROOT / rel).resolve()
81
+ if p != ROOT and ROOT not in p.parents:
82
+ raise ValueError("path escapes the root folder")
83
+ return p
84
+
85
+
86
+ def version_of(p: Path) -> str:
87
+ return str(p.stat().st_mtime_ns) if p.exists() else "0"
88
+
89
+
90
+ def list_files():
91
+ out = []
92
+ for dirpath, dirnames, filenames in os.walk(ROOT):
93
+ dirnames[:] = sorted(d for d in dirnames if not d.startswith(".") and d not in SKIP_DIRS)
94
+ for f in sorted(filenames):
95
+ if f.lower().endswith(MD_EXT):
96
+ p = Path(dirpath) / f
97
+ out.append({"path": p.relative_to(ROOT).as_posix(), "version": version_of(p)})
98
+ return out
99
+
100
+
101
+ def browse(dirpath: str):
102
+ """Folders and markdown files in `dirpath`, for the in-app file browser."""
103
+ d = Path(dirpath).expanduser().resolve() if dirpath else ROOT
104
+ if not d.is_dir():
105
+ raise ValueError(f"not a folder: {d}")
106
+ entries = []
107
+ try:
108
+ for p in d.iterdir():
109
+ if p.name.startswith(".") or p.name in SKIP_DIRS:
110
+ continue
111
+ try:
112
+ if p.is_dir():
113
+ entries.append({"name": p.name, "dir": True})
114
+ elif p.suffix.lower() in MD_EXT:
115
+ entries.append({"name": p.name, "dir": False})
116
+ except OSError:
117
+ pass
118
+ except PermissionError:
119
+ raise ValueError(f"permission denied: {d}")
120
+ entries.sort(key=lambda e: (not e["dir"], e["name"].lower()))
121
+ return {"dir": str(d), "parent": str(d.parent) if d.parent != d else None,
122
+ "home": str(Path.home()), "entries": entries}
123
+
124
+
125
+ def set_root(target: str):
126
+ """Point the editor at a folder, or at a file's folder; returns the file to open."""
127
+ global ROOT
128
+ t = Path(target).expanduser().resolve()
129
+ if t.is_file() and t.suffix.lower() in MD_EXT:
130
+ ROOT = t.parent
131
+ return t.name
132
+ if t.is_dir():
133
+ ROOT = t
134
+ return None
135
+ raise ValueError(f"not a markdown file or folder: {t}")
136
+
137
+
138
+ def list_skills():
139
+ skills = []
140
+ if SKILLS_DIR.is_dir():
141
+ for d in sorted(SKILLS_DIR.iterdir()):
142
+ md = d / "SKILL.md"
143
+ if not md.is_file():
144
+ continue
145
+ desc = ""
146
+ try:
147
+ head = md.read_text(encoding="utf-8").split("---")
148
+ for line in head[1].splitlines() if len(head) > 2 else []:
149
+ if line.startswith("description:"):
150
+ desc = line.split(":", 1)[1].strip()
151
+ except OSError:
152
+ pass
153
+ skills.append({"name": d.name, "description": desc})
154
+ return skills
155
+
156
+
157
+ # ---------------------------------------------------------------- claude
158
+
159
+ def build_prompt(req):
160
+ doc = req.get("doc", "")
161
+ s, e = int(req.get("start", 0)), int(req.get("end", 0))
162
+ whole = s == 0 and e >= len(doc)
163
+ marked = doc if whole else doc[:s] + "⟦SELECTION⟧" + doc[s:e] + "⟦/SELECTION⟧" + doc[e:]
164
+ mode = req.get("mode", "replace")
165
+ parts = [
166
+ f'<document path="{req.get("path", "")}">\n{marked}\n</document>',
167
+ f"<selection>\n{doc[s:e]}\n</selection>" if not whole else
168
+ "<selection>The whole document is selected.</selection>",
169
+ f"<mode>{mode}</mode>",
170
+ f"<instruction>\n{req.get('instruction', '').strip()}\n</instruction>",
171
+ ]
172
+ if req.get("previous"):
173
+ parts.append(f"<previous_attempt>\n{req['previous']}\n</previous_attempt>\n"
174
+ "The user was not satisfied with the previous attempt; the instruction "
175
+ "above is their feedback on it.")
176
+ parts.append("Reply with the replacement text only." if mode == "replace"
177
+ else "Reply with your comments only.")
178
+ return "\n\n".join(parts)
179
+
180
+
181
+ def unfence(text: str, original: str) -> str:
182
+ t = text.strip()
183
+ if t.startswith("```") and t.endswith("```") and not original.lstrip().startswith("```"):
184
+ lines = t.splitlines()
185
+ if len(lines) >= 2:
186
+ t = "\n".join(lines[1:-1])
187
+ return t
188
+
189
+
190
+ def ask_claude(req):
191
+ cmd = [CLAUDE_BIN, "-p", "--output-format", "json", "--no-session-persistence",
192
+ "--append-system-prompt", SYSTEM_PROMPT]
193
+ if req.get("model"):
194
+ cmd += ["--model", req["model"]]
195
+ cmd += ["--tools", "Skill,Read", "--allowedTools", "Skill,Read"]
196
+ t0 = time.time()
197
+ proc = subprocess.run(cmd, input=build_prompt(req), capture_output=True, text=True,
198
+ timeout=600, cwd=tempfile.gettempdir())
199
+ try:
200
+ data = json.loads(proc.stdout)
201
+ except json.JSONDecodeError:
202
+ raise RuntimeError((proc.stderr or proc.stdout or "no output from claude").strip()[:2000])
203
+ if data.get("is_error"):
204
+ raise RuntimeError(str(data.get("result") or data)[:2000])
205
+ doc = req.get("doc", "")
206
+ original = doc[int(req.get("start", 0)):int(req.get("end", 0))]
207
+ return {
208
+ "result": unfence(data.get("result", ""), original),
209
+ "seconds": round(time.time() - t0, 1),
210
+ "cost": data.get("total_cost_usd"),
211
+ }
212
+
213
+
214
+ # ---------------------------------------------------------------- pdf
215
+
216
+ def export_pdf(req):
217
+ """Print the editor's rendered HTML to <name>.pdf beside the markdown file."""
218
+ if not CHROME_BIN:
219
+ raise RuntimeError("PDF export needs Google Chrome or Chromium (or set MDEDIT_CHROME)")
220
+ md = safe_path(req["path"])
221
+ pdf = md.with_suffix(".pdf")
222
+ page = PDF_TEMPLATE.format(title=md.stem.replace("<", "&lt;"), body=req["html"])
223
+ # the page sits beside the .md so relative image links resolve
224
+ tmp_html = md.with_name(f".{md.stem}.print.html")
225
+ tmp_html.write_text(page, encoding="utf-8")
226
+ profile = tempfile.mkdtemp(prefix="mdedit-chrome-")
227
+ try:
228
+ proc = subprocess.run(
229
+ [CHROME_BIN, "--headless=new", "--disable-gpu", "--no-first-run", "--no-pdf-header-footer",
230
+ f"--user-data-dir={profile}", "--virtual-time-budget=15000", "--run-all-compositor-stages-before-draw",
231
+ f"--print-to-pdf={pdf}", tmp_html.as_uri()],
232
+ capture_output=True, text=True, timeout=120)
233
+ if proc.returncode != 0 or not pdf.exists():
234
+ raise RuntimeError(f"Chrome failed to print: {(proc.stderr or proc.stdout).strip()[-800:]}")
235
+ finally:
236
+ tmp_html.unlink(missing_ok=True)
237
+ shutil.rmtree(profile, ignore_errors=True)
238
+ return {"pdf": pdf.relative_to(ROOT).as_posix(), "bytes": pdf.stat().st_size}
239
+
240
+
241
+ # ---------------------------------------------------------------- http
242
+
243
+ class Handler(SimpleHTTPRequestHandler):
244
+ def __init__(self, *a, **kw):
245
+ super().__init__(*a, directory=str(STATIC), **kw)
246
+
247
+ def log_message(self, fmt, *args):
248
+ if "/api/events" not in str(args[0] if args else ""):
249
+ super().log_message(fmt, *args)
250
+
251
+ def end_headers(self):
252
+ self.send_header("Cache-Control", "no-store")
253
+ super().end_headers()
254
+
255
+ def send_json(self, obj, status=200):
256
+ body = json.dumps(obj).encode()
257
+ self.send_response(status)
258
+ self.send_header("Content-Type", "application/json")
259
+ self.send_header("Content-Length", str(len(body)))
260
+ self.end_headers()
261
+ self.wfile.write(body)
262
+
263
+ def read_json(self):
264
+ n = int(self.headers.get("Content-Length") or 0)
265
+ return json.loads(self.rfile.read(n) or b"{}")
266
+
267
+ def do_GET(self):
268
+ u = urlparse(self.path)
269
+ q = {k: v[0] for k, v in parse_qs(u.query).items()}
270
+ try:
271
+ if u.path == "/api/config":
272
+ return self.send_json({"root": str(ROOT), "initial": INITIAL,
273
+ "skills": list_skills(), "files": list_files()})
274
+ if u.path == "/api/download":
275
+ p = safe_path(q["path"])
276
+ if not p.is_file() or p.suffix.lower() != ".pdf":
277
+ return self.send_json({"error": "not found"}, 404)
278
+ data = p.read_bytes()
279
+ self.send_response(200)
280
+ self.send_header("Content-Type", "application/pdf")
281
+ self.send_header("Content-Disposition", f'attachment; filename="{p.name}"')
282
+ self.send_header("Content-Length", str(len(data)))
283
+ self.end_headers()
284
+ self.wfile.write(data)
285
+ return
286
+ if u.path == "/api/browse":
287
+ return self.send_json(browse(q.get("dir", "")))
288
+ if u.path == "/api/files":
289
+ return self.send_json(list_files())
290
+ if u.path == "/api/file":
291
+ p = safe_path(q["path"])
292
+ if not p.is_file():
293
+ return self.send_json({"error": "not found"}, 404)
294
+ return self.send_json({"path": q["path"], "text": p.read_text(encoding="utf-8"),
295
+ "version": version_of(p)})
296
+ if u.path == "/api/events":
297
+ return self.events()
298
+ except (ValueError, KeyError) as exc:
299
+ return self.send_json({"error": str(exc)}, 400)
300
+ return super().do_GET()
301
+
302
+ def do_PUT(self):
303
+ if urlparse(self.path).path != "/api/file":
304
+ return self.send_json({"error": "unknown endpoint"}, 404)
305
+ try:
306
+ req = self.read_json()
307
+ p = safe_path(req["path"])
308
+ base = req.get("base_version")
309
+ if not req.get("force") and base is not None and version_of(p) != base:
310
+ return self.send_json({"error": "conflict", "version": version_of(p),
311
+ "text": p.read_text(encoding="utf-8") if p.exists() else ""}, 409)
312
+ p.parent.mkdir(parents=True, exist_ok=True)
313
+ tmp = p.with_name("." + p.name + ".tmp")
314
+ tmp.write_text(req["text"], encoding="utf-8")
315
+ os.replace(tmp, p)
316
+ return self.send_json({"version": version_of(p)})
317
+ except (ValueError, KeyError) as exc:
318
+ return self.send_json({"error": str(exc)}, 400)
319
+
320
+ def do_POST(self):
321
+ u = urlparse(self.path)
322
+ try:
323
+ req = self.read_json()
324
+ if u.path == "/api/pdf":
325
+ return self.send_json(export_pdf(req))
326
+ if u.path == "/api/root":
327
+ initial = set_root(req["path"])
328
+ print(f"[root] {ROOT}", flush=True)
329
+ return self.send_json({"root": str(ROOT), "initial": initial, "files": list_files()})
330
+ if u.path == "/api/new":
331
+ rel = req["path"].strip()
332
+ if not rel.lower().endswith(MD_EXT):
333
+ rel += ".md"
334
+ p = safe_path(rel)
335
+ if p.exists():
336
+ return self.send_json({"error": "file already exists"}, 409)
337
+ p.parent.mkdir(parents=True, exist_ok=True)
338
+ p.write_text(req.get("text", f"# {p.stem}\n\n"), encoding="utf-8")
339
+ return self.send_json({"path": rel, "version": version_of(p)})
340
+ if u.path == "/api/ask":
341
+ print(f"[ask] {req.get('mode')}: {req.get('instruction', '')[:80]!r}", flush=True)
342
+ return self.send_json(ask_claude(req))
343
+ except subprocess.TimeoutExpired:
344
+ return self.send_json({"error": "Claude timed out"}, 504)
345
+ except RuntimeError as exc:
346
+ return self.send_json({"error": str(exc)}, 502)
347
+ except (ValueError, KeyError) as exc:
348
+ return self.send_json({"error": str(exc)}, 400)
349
+ return self.send_json({"error": "unknown endpoint"}, 404)
350
+
351
+ def events(self):
352
+ """Server-sent events: file list changes and per-file version changes."""
353
+ self.send_response(200)
354
+ self.send_header("Content-Type", "text/event-stream")
355
+ self.end_headers()
356
+ last = None
357
+ try:
358
+ while True:
359
+ files = list_files()
360
+ if files != last:
361
+ self.wfile.write(f"data: {json.dumps(files)}\n\n".encode())
362
+ self.wfile.flush()
363
+ last = files
364
+ else:
365
+ self.wfile.write(b": ping\n\n")
366
+ self.wfile.flush()
367
+ time.sleep(0.6)
368
+ except (BrokenPipeError, ConnectionResetError):
369
+ pass
370
+
371
+
372
+ INITIAL = None
373
+
374
+
375
+ def main():
376
+ global ROOT, INITIAL
377
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
378
+ ap.add_argument("path", nargs="?", default=".", help="folder or .md file to edit (default: current folder)")
379
+ ap.add_argument("--port", type=int, default=8765)
380
+ ap.add_argument("--no-browser", action="store_true")
381
+ args = ap.parse_args()
382
+
383
+ target = Path(args.path).expanduser().resolve()
384
+ if target.suffix.lower() in MD_EXT:
385
+ ROOT, INITIAL = target.parent, target.name
386
+ if not target.exists():
387
+ target.parent.mkdir(parents=True, exist_ok=True)
388
+ target.write_text(f"# {target.stem}\n\n", encoding="utf-8")
389
+ else:
390
+ ROOT = target
391
+ ROOT.mkdir(parents=True, exist_ok=True)
392
+
393
+ server = ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
394
+ server.daemon_threads = True
395
+ url = f"http://127.0.0.1:{args.port}/"
396
+ print(f"MD editor on {url} (root: {ROOT})", flush=True)
397
+ if not args.no_browser:
398
+ threading.Timer(0.5, lambda: webbrowser.open(url)).start()
399
+ try:
400
+ server.serve_forever()
401
+ except KeyboardInterrupt:
402
+ pass
403
+
404
+
405
+ if __name__ == "__main__":
406
+ main()