easymd-cli 0.1.0

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 ADDED
@@ -0,0 +1,110 @@
1
+ # easymd
2
+
3
+ **Google Docs for markdown — except the file on disk stays canonical.**
4
+
5
+ Collaborate on `CLAUDE.md`, `AGENTS.md`, or any `.md` file in real time. The document on screen is the actual file in your repo. No import/export round-trip, no copy-paste tax.
6
+
7
+ ## Web app (landing + demo)
8
+
9
+ Professional landing page with **token savings** messaging, **Clerk auth** for signups, and a **protected live demo** at `/demo`.
10
+
11
+ ```bash
12
+ cd web
13
+ cp .env.example .env.local # add Clerk keys from dashboard.clerk.com
14
+ npm install
15
+ npm run dev # Next.js + collab WebSocket server
16
+ ```
17
+
18
+ Open [http://localhost:3000](http://localhost:3000) → Sign up → Live collaborative editor.
19
+
20
+ ### Token savings (why markdown for AI)
21
+
22
+ Converting PDFs, Word/DOCX, or HTML to clean `.md` cuts token usage dramatically:
23
+
24
+ | Format | Typical savings |
25
+ |--------|-----------------|
26
+ | Raw HTML | 75–90% |
27
+ | Word / DOCX | 50–70% |
28
+ | Text-based PDF | 40–65% |
29
+
30
+ Markdown strips hidden formatting metadata while keeping structure agents need — then reference `@CLAUDE.md` on demand instead of pasting every prompt.
31
+
32
+ ## CLI (local file editing)
33
+
34
+ ```bash
35
+ npm install
36
+ npm run build
37
+ easymd open CLAUDE.md
38
+ ```
39
+
40
+ Share the URL with a teammate. Edits sync live via CRDT. Changes land straight back to the file on disk and into git.
41
+
42
+ ## CLI auto-sync (push every .md into your account)
43
+
44
+ Sign in once, then have every markdown file in your repo sync into your easymd account
45
+ automatically — so anything you or an AI agent writes to disk shows up in the dashboard
46
+ and is editable live.
47
+
48
+ ```bash
49
+ npx easymd-cli login # opens your browser, sign in with Clerk, authorize this machine
50
+ npx easymd-cli auto on # background watcher: every new/changed .md syncs to your account
51
+ npx easymd-cli auto status # check it's running
52
+ npx easymd-cli auto off # stop it
53
+ ```
54
+
55
+ Other commands:
56
+
57
+ ```bash
58
+ easymd sync ./docs # one-shot push of all .md under a folder
59
+ easymd watch . # foreground watcher (what `auto on` runs detached)
60
+ easymd whoami # show the logged-in account
61
+ easymd logout # remove credentials + stop auto-sync
62
+ ```
63
+
64
+ Credentials live in `~/.easymd/credentials.json` (a long-lived token bound to your Clerk
65
+ user id — secrets stay on the server). Point the CLI at a self-hosted/production instance
66
+ with `EASYMD_URL=https://your-easymd.example.com`.
67
+
68
+ ## AI agents (MCP)
69
+
70
+ easymd ships an MCP server so AI agents edit the **same live documents** humans do — changes sync in real time and persist to Supabase.
71
+
72
+ ```bash
73
+ cd web
74
+ npm run mcp # stdio MCP server (needs the collab server running + Supabase env)
75
+ ```
76
+
77
+ Tools: `list_documents`, `read_document`, `create_document`, `update_document`, `append_to_document`. Docs an agent creates show up in the app's document switcher for everyone; edits an agent makes appear live in any open editor. Register it with Cursor/Claude via `.cursor/mcp.json`.
78
+
79
+ ## Why
80
+
81
+ AI agents made markdown load-bearing. `AGENTS.md` and `CLAUDE.md` are now the single source of truth for how your project works — but collaborating on them still means pull requests for engineers or copy-paste between Google Docs and your repo for everyone else.
82
+
83
+ easymd fixes the one painful shared file:
84
+
85
+ - **Canonical file stays canonical** — edit the real `.md` on disk, not a hosted copy
86
+ - **Real-time multiplayer** — live cursors, presence, CRDT conflict resolution
87
+ - **CLI keeps it honest** — `easymd open path/to/file.md` from your terminal
88
+ - **Agents stay in the loop** — the same file humans edit is what agents read and write
89
+ - **Token-efficient by design** — lightweight `.md` beats bloated DOCX/HTML/PDF for agent context
90
+
91
+ ## Project structure
92
+
93
+ ```
94
+ web/ Next.js landing, Clerk auth, protected demo
95
+ bin/easymd.js CLI entry point
96
+ src/server.js Local HTTP + WebSocket server
97
+ src/file-sync.js Disk ↔ Yjs sync
98
+ client/ CLI browser editor UI
99
+ ```
100
+
101
+ ## Clerk setup
102
+
103
+ 1. Create an app at [dashboard.clerk.com](https://dashboard.clerk.com)
104
+ 2. Copy keys into `web/.env.local` (see `web/.env.example`)
105
+ 3. Enable Email + Google sign-in (recommended)
106
+ 4. Set redirect URLs: after sign-in/sign-up → `/demo`
107
+
108
+ ## License
109
+
110
+ MIT
package/bin/easymd.js ADDED
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { resolve } from 'path';
4
+ import { access } from 'fs/promises';
5
+ import openBrowser from 'open';
6
+ import { startServer } from '../src/server.js';
7
+ import { login, logout, whoami } from '../src/cli/auth.js';
8
+ import { syncDir, watchDir } from '../src/cli/sync.js';
9
+ import { autoOn, autoOff, autoStatus } from '../src/cli/auto.js';
10
+ import { getCredentials } from '../src/cli/config.js';
11
+
12
+ const HELP = `
13
+ easymd — collaborate on markdown files in your repo, live with humans and AI agents
14
+
15
+ Usage:
16
+ easymd login Sign in with Clerk (opens your browser) and authorize this machine
17
+ easymd logout Remove stored credentials and stop auto-sync
18
+ easymd whoami Show the logged-in account
19
+
20
+ easymd auto on [dir] Start background auto-sync: every .md is pushed to your account
21
+ easymd auto off Stop background auto-sync
22
+ easymd auto status Show whether auto-sync is running
23
+
24
+ easymd sync [dir] One-shot: push all .md files under dir (default: .) to your account
25
+ easymd watch [dir] Foreground watcher (what 'auto on' runs in the background)
26
+
27
+ easymd open <file> Open a local .md for real-time collaborative editing in the browser
28
+ easymd open <file> --port N Use a fixed port (default: random)
29
+
30
+ Environment:
31
+ EASYMD_URL easymd web app URL (default: http://localhost:3000)
32
+
33
+ Examples:
34
+ easymd login
35
+ easymd auto on # auto-sync the current repo's markdown into your account
36
+ easymd sync ./docs
37
+ easymd open CLAUDE.md
38
+ `.trim();
39
+
40
+ function flag(args, name) {
41
+ return args.includes(name);
42
+ }
43
+
44
+ async function cmdOpen(args) {
45
+ const file = args[0];
46
+ if (!file) {
47
+ console.error('Missing file path.\n');
48
+ console.log(HELP);
49
+ process.exit(1);
50
+ }
51
+ let port;
52
+ const portIdx = args.indexOf('--port');
53
+ if (portIdx !== -1) {
54
+ port = Number(args[portIdx + 1]);
55
+ if (!Number.isFinite(port) || port < 1) {
56
+ console.error('Invalid --port value.');
57
+ process.exit(1);
58
+ }
59
+ }
60
+
61
+ const absPath = resolve(file);
62
+ try {
63
+ await access(absPath);
64
+ } catch {
65
+ console.log(`Note: ${absPath} does not exist yet — it will be created on save.`);
66
+ }
67
+
68
+ const { url, shutdown } = await startServer(absPath, { port });
69
+ console.log('');
70
+ console.log(' easymd');
71
+ console.log(' ─────────────────────────────────────');
72
+ console.log(` File: ${absPath}`);
73
+ console.log(` Local: ${url}`);
74
+ console.log(' Share this URL with teammates on your network.');
75
+ console.log(' Press Ctrl+C to stop.');
76
+ console.log('');
77
+ await openBrowser(url);
78
+
79
+ const onExit = () => {
80
+ shutdown();
81
+ process.exit(0);
82
+ };
83
+ process.on('SIGINT', onExit);
84
+ process.on('SIGTERM', onExit);
85
+ }
86
+
87
+ async function cmdAuto(args) {
88
+ const sub = args[0];
89
+ if (sub === 'on') {
90
+ // One command does it all: log in via the browser if needed, then start auto-sync.
91
+ const creds = await getCredentials();
92
+ if (!creds?.token) {
93
+ console.log('Not logged in yet — opening the browser to sign in first…\n');
94
+ await login();
95
+ }
96
+ return autoOn(resolve(args[1] || '.'));
97
+ }
98
+ if (sub === 'off') return autoOff();
99
+ if (sub === 'status' || !sub) return autoStatus();
100
+ console.error(`Unknown: easymd auto ${sub}\n`);
101
+ console.log('Use: easymd auto on|off|status');
102
+ process.exit(1);
103
+ }
104
+
105
+ async function cmdWatch(args) {
106
+ const dirArgs = args.filter((a) => !a.startsWith('-'));
107
+ const root = resolve(dirArgs[0] || '.');
108
+ const quiet = flag(args, '--quiet');
109
+ const watcher = await watchDir(root, { quiet });
110
+ const onExit = async () => {
111
+ await watcher.close();
112
+ process.exit(0);
113
+ };
114
+ process.on('SIGINT', onExit);
115
+ process.on('SIGTERM', onExit);
116
+ }
117
+
118
+ async function cmdSync(args) {
119
+ const dirArgs = args.filter((a) => !a.startsWith('-'));
120
+ const root = resolve(dirArgs[0] || '.');
121
+ await syncDir(root, { quiet: flag(args, '--quiet') });
122
+ }
123
+
124
+ async function main() {
125
+ const argv = process.argv.slice(2);
126
+ const command = argv[0];
127
+ const rest = argv.slice(1);
128
+
129
+ if (!command || command === '-h' || command === '--help' || command === 'help') {
130
+ console.log(HELP);
131
+ process.exit(0);
132
+ }
133
+
134
+ switch (command) {
135
+ case 'login':
136
+ return login();
137
+ case 'logout':
138
+ return logout();
139
+ case 'whoami':
140
+ return whoami();
141
+ case 'auto':
142
+ return cmdAuto(rest);
143
+ case 'watch':
144
+ return cmdWatch(rest);
145
+ case 'sync':
146
+ return cmdSync(rest);
147
+ case 'open':
148
+ return cmdOpen(rest);
149
+ default:
150
+ console.error(`Unknown command: ${command}\n`);
151
+ console.log(HELP);
152
+ process.exit(1);
153
+ }
154
+ }
155
+
156
+ main().catch((err) => {
157
+ console.error('easymd:', err.message);
158
+ process.exit(1);
159
+ });
@@ -0,0 +1,49 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>easymd</title>
7
+ <link rel="stylesheet" href="/styles.css" />
8
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10
+ <link
11
+ href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Inter:wght@400;500;600&display=swap"
12
+ rel="stylesheet"
13
+ />
14
+ </head>
15
+ <body>
16
+ <header class="toolbar">
17
+ <div class="brand">
18
+ <span class="logo">easymd</span>
19
+ <span class="filename" id="filename">loading…</span>
20
+ </div>
21
+ <div class="toolbar-right">
22
+ <div class="presence" id="presence"></div>
23
+ <div class="view-toggle" role="tablist" aria-label="View mode">
24
+ <button class="toggle-btn active" data-view="edit" role="tab" aria-selected="true">
25
+ Edit
26
+ </button>
27
+ <button class="toggle-btn" data-view="split" role="tab" aria-selected="false">
28
+ Split
29
+ </button>
30
+ <button class="toggle-btn" data-view="preview" role="tab" aria-selected="false">
31
+ Preview
32
+ </button>
33
+ </div>
34
+ <span class="sync-status" id="sync-status" title="Sync status">●</span>
35
+ </div>
36
+ </header>
37
+
38
+ <main class="workspace" id="workspace">
39
+ <section class="editor-pane" id="editor-pane">
40
+ <div id="editor"></div>
41
+ </section>
42
+ <section class="preview-pane hidden" id="preview-pane">
43
+ <article class="markdown-body" id="preview"></article>
44
+ </section>
45
+ </main>
46
+
47
+ <script type="module" src="/client.js"></script>
48
+ </body>
49
+ </html>
package/client/main.js ADDED
@@ -0,0 +1,169 @@
1
+ import * as Y from 'yjs';
2
+ import { WebsocketProvider } from 'y-websocket';
3
+ import { EditorState } from '@codemirror/state';
4
+ import { EditorView, keymap, lineNumbers, drawSelection } from '@codemirror/view';
5
+ import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
6
+ import { markdown } from '@codemirror/lang-markdown';
7
+ import { yCollab } from 'y-codemirror.next';
8
+ import { marked } from 'marked';
9
+
10
+ const COLORS = ['#58a6ff', '#3fb950', '#d2a8ff', '#f0883e', '#ff7b72', '#79c0ff', '#e3b341'];
11
+
12
+ const filenameEl = document.getElementById('filename');
13
+ const presenceEl = document.getElementById('presence');
14
+ const syncStatusEl = document.getElementById('sync-status');
15
+ const workspaceEl = document.getElementById('workspace');
16
+ const editorPaneEl = document.getElementById('editor-pane');
17
+ const previewPaneEl = document.getElementById('preview-pane');
18
+ const previewEl = document.getElementById('preview');
19
+ const toggleButtons = document.querySelectorAll('.toggle-btn');
20
+
21
+ function randomName() {
22
+ const names = ['Alex', 'Sam', 'Jordan', 'Riley', 'Casey', 'Morgan', 'Taylor', 'Quinn'];
23
+ return names[Math.floor(Math.random() * names.length)];
24
+ }
25
+
26
+ function colorForUser(userId) {
27
+ let hash = 0;
28
+ for (let i = 0; i < userId.length; i++) {
29
+ hash = userId.charCodeAt(i) + ((hash << 5) - hash);
30
+ }
31
+ return COLORS[Math.abs(hash) % COLORS.length];
32
+ }
33
+
34
+ function setSyncStatus(state) {
35
+ syncStatusEl.className = 'sync-status';
36
+ if (state === 'connected') {
37
+ syncStatusEl.textContent = '●';
38
+ syncStatusEl.title = 'Connected — changes save to disk';
39
+ } else if (state === 'connecting') {
40
+ syncStatusEl.classList.add('connecting');
41
+ syncStatusEl.textContent = '●';
42
+ syncStatusEl.title = 'Connecting…';
43
+ } else {
44
+ syncStatusEl.classList.add('disconnected');
45
+ syncStatusEl.textContent = '●';
46
+ syncStatusEl.title = 'Disconnected';
47
+ }
48
+ }
49
+
50
+ function renderPreview(markdownText) {
51
+ previewEl.innerHTML = marked.parse(markdownText, { gfm: true, breaks: false });
52
+ }
53
+
54
+ function setView(mode) {
55
+ workspaceEl.classList.remove('split', 'preview');
56
+ editorPaneEl.classList.remove('hidden');
57
+ previewPaneEl.classList.add('hidden');
58
+
59
+ toggleButtons.forEach((btn) => {
60
+ const active = btn.dataset.view === mode;
61
+ btn.classList.toggle('active', active);
62
+ btn.setAttribute('aria-selected', String(active));
63
+ });
64
+
65
+ if (mode === 'split') {
66
+ workspaceEl.classList.add('split');
67
+ previewPaneEl.classList.remove('hidden');
68
+ } else if (mode === 'preview') {
69
+ workspaceEl.classList.add('preview');
70
+ previewPaneEl.classList.remove('hidden');
71
+ }
72
+ }
73
+
74
+ toggleButtons.forEach((btn) => {
75
+ btn.addEventListener('click', () => setView(btn.dataset.view));
76
+ });
77
+
78
+ async function init() {
79
+ setSyncStatus('connecting');
80
+
81
+ const sessionRes = await fetch('/api/session');
82
+ const session = await sessionRes.json();
83
+ filenameEl.textContent = session.fileName;
84
+ filenameEl.title = session.filePath;
85
+ document.title = `${session.fileName} — easymd`;
86
+
87
+ const ydoc = new Y.Doc();
88
+ const ytext = ydoc.getText('markdown');
89
+
90
+ const wsProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
91
+ const provider = new WebsocketProvider(
92
+ `${wsProtocol}//${location.host}`,
93
+ session.docName,
94
+ ydoc,
95
+ { connect: true }
96
+ );
97
+
98
+ const userId = crypto.randomUUID();
99
+ const userName = randomName();
100
+ const userColor = colorForUser(userId);
101
+
102
+ provider.awareness.setLocalStateField('user', {
103
+ name: userName,
104
+ color: userColor,
105
+ });
106
+
107
+ provider.on('status', ({ status }) => {
108
+ setSyncStatus(status === 'connected' ? 'connected' : 'connecting');
109
+ });
110
+
111
+ provider.on('connection-close', () => setSyncStatus('disconnected'));
112
+
113
+ function renderPresence() {
114
+ const states = provider.awareness.getStates();
115
+ presenceEl.innerHTML = '';
116
+
117
+ states.forEach((state) => {
118
+ const user = state.user;
119
+ if (!user) return;
120
+ const avatar = document.createElement('span');
121
+ avatar.className = 'presence-avatar';
122
+ avatar.style.background = user.color;
123
+ avatar.textContent = user.name.slice(0, 1).toUpperCase();
124
+ avatar.title = user.name;
125
+ presenceEl.appendChild(avatar);
126
+ });
127
+ }
128
+
129
+ provider.awareness.on('change', renderPresence);
130
+ renderPresence();
131
+
132
+ const undoManager = new Y.UndoManager(ytext);
133
+
134
+ const state = EditorState.create({
135
+ doc: ytext.toString(),
136
+ extensions: [
137
+ lineNumbers(),
138
+ drawSelection(),
139
+ history(),
140
+ markdown(),
141
+ keymap.of([...defaultKeymap, ...historyKeymap]),
142
+ yCollab(ytext, provider.awareness, { undoManager }),
143
+ EditorView.updateListener.of((update) => {
144
+ if (update.docChanged) {
145
+ renderPreview(update.state.doc.toString());
146
+ }
147
+ }),
148
+ EditorView.theme({
149
+ '&': { backgroundColor: 'transparent', color: 'var(--text)' },
150
+ '.cm-gutters': { backgroundColor: 'var(--bg)', color: 'var(--muted)' },
151
+ }),
152
+ ],
153
+ });
154
+
155
+ const view = new EditorView({ state, parent: document.getElementById('editor') });
156
+ renderPreview(view.state.doc.toString());
157
+
158
+ ytext.observe(() => {
159
+ renderPreview(ytext.toString());
160
+ });
161
+
162
+ return { view, provider };
163
+ }
164
+
165
+ init().catch((err) => {
166
+ console.error(err);
167
+ filenameEl.textContent = 'Failed to connect';
168
+ setSyncStatus('disconnected');
169
+ });