#!/usr/bin/env node // mde — CLI for Workbench (Google Docs for markdown). // Zero dependencies. Install: curl -fsSL /cli | sudo tee /usr/local/bin/mde >/dev/null && sudo chmod +x /usr/local/bin/mde // CommonJS on purpose: the install pipes this file to an extensionless // /usr/local/bin/mde, which Node loads as CJS — ESM imports would throw there. const fs = require('node:fs') const path = require('node:path') const os = require('node:os') const readline = require('node:readline') const { spawn } = require('node:child_process') const crypto = require('node:crypto') // Kept in the self-contained download so an installed extensionless `mde` // can report its build without needing package.json beside it. const VERSION = '0.1.0' const CONFIG_DIR = process.env.MDE_CONFIG_DIR || path.join(os.homedir(), '.config', 'mde') const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json') // "|" (and "||" for pull -o) -> last // pulled version. Keyed by server + doc (+ file) so several agents sharing one // config dir, or one agent on two servers, can't vouch for each other's reads. const VERSIONS_PATH = path.join(CONFIG_DIR, 'versions.json') function loadConfig() { try { return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) } catch { return {} } } function saveConfig(cfg) { fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true }) fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 }) // mode only applies on creation — a pre-existing (or umask-widened) file // holding the token must be tightened on every save too try { fs.chmodSync(CONFIG_PATH, 0o600) } catch { /* best effort (e.g. Windows) */ } } function loadVersions() { try { return JSON.parse(fs.readFileSync(VERSIONS_PATH, 'utf8')) } catch { return {} } } function writeVersions(all) { fs.mkdirSync(CONFIG_DIR, { recursive: true }) fs.writeFileSync(VERSIONS_PATH, JSON.stringify(all, null, 2) + '\n') } const CURSORS_PATH = path.join(CONFIG_DIR, 'watch-cursors.json') // docId -> last seen event seq function loadVersionsFile(p) { try { return JSON.parse(fs.readFileSync(p, 'utf8')) } catch { return {} } } function saveInVersionsFile(p, docId, value) { const all = loadVersionsFile(p) all[docId] = value fs.mkdirSync(path.dirname(p), { recursive: true }) fs.writeFileSync(p, JSON.stringify(all, null, 2) + '\n') } const cfg = loadConfig() const BASE = process.env.MDE_URL || cfg.url // MDE_TOKEN_FILE: a 0600 file holding the token (what watch --daemon wires up // so the token never sits in a plist/unit file) function tokenFromFile() { const f = process.env.MDE_TOKEN_FILE if (!f) return null try { return fs.readFileSync(f, 'utf8').trim() || null } catch { return null } } const TOKEN = process.env.MDE_TOKEN || tokenFromFile() || cfg.token const [, , cmd, ...args] = process.argv // tasteful color, only where a human is looking (TTY, no NO_COLOR) const TTY = process.stdout.isTTY && !process.env.NO_COLOR const bold = (t) => TTY ? `\x1b[1m${t}\x1b[0m` : t const dim = (t) => TTY ? `\x1b[2m${t}\x1b[0m` : t const green = (t) => TTY ? `\x1b[32m${t}\x1b[0m` : t const HELP = `mde — collaborative markdown docs, from the terminal Setup mde --version print the CLI build version mde login [url] sign in — approve in your browser, no token hunting (scripts: mde login [url] --token ; token in the account menu) server: the url argument, else MDE_URL, else the saved one mde whoami show who you are Documents mde ls [--json] list your documents mde search [--folder name] [--json] search titles and markdown, ranked mde new [-f file] create a doc (markdown from -f or stdin; the title may be several words, quoted or not) works logged-out too: creates an anonymous doc at a secret link mde cat <doc> print a doc as markdown (pending suggestions excluded) mde pull <doc> [-o file] fetch a doc and remember its version for safe pushes -o refuses to overwrite a file you edited since your last pull into it (--force discards those local edits) mde push <doc> -f file replace a doc's content (also reads stdin) after a pull it is safe: if the doc changed since, nothing is overwritten — the current doc is saved to <file>.remote.md and mde shows how to merge with no prior pull it is a blind overwrite (warned on stderr; the server may still refuse a destructive shrink) refuses empty input (an empty source file is usually an accident, not an intended clear) --base <version> push on top of this version (after merging) --label "msg" name the version --force overwrite/clear anyway — discards others' changes mde history <doc> [--json] list saved versions (--show <n> prints one) mde revision <doc> <n> print saved version n as markdown (--json for metadata) mde restore <doc> <n> restore saved version n (undo a bad write) mde open <doc> open a doc in the browser (always prints the URL) mde share <doc> [role] get a share link (view|comment|suggest|edit, default view) works on an edit-key share URL too, not just your own docs mde rm <doc> delete a doc (by id, share URL, or exact title) anonymous docs delete via their edit-key share URL mde upload <doc> <file> attach an image/screenshot, video, audio, or code file; prints its URL and the markdown to embed it --insert also append the embed to the end of the doc (code/text assets have no inline embed: the URL is printed and nothing is inserted) --name <path> asset manifest path (e.g. scripts/x.py) --type <mime> override the type guessed from the extension --json print the full upload response Folders and skills mde folders print your recursive folder tree mde folder new <name> [--parent id] create a root or child folder mde move <doc> <folderId|none> file or unfile a document mde skill manifest <url|slug> [--v N] print an install manifest mde skill release <folderId> [-m "notes"] cut an immutable release Collaboration mde ask <doc> <text> create an open ASK mde claim <doc> <askId> [--as name] atomically claim an ASK (with a token, the claim is recorded as the account's agent name) mde resolve <doc> <askId> [-m note] resolve an ASK (or an existing comment id) mde comments <doc> [--json] list comments (and suggestions) mde comment <doc> <text> [--line N] add a comment mde reply <doc> <commentId> <text> reply to a comment mde chat <doc> <text> [--fence <id>] post a message to a doc's chat fence (--fence picks a chat block by its #id when a doc has several; @name mentions of the owner notify) mde suggest <doc> --replace <old> --with <new> propose an edit (creates a delete+insert pair) mde suggest <doc> --delete <text> propose removing text mde suggest <doc> --append <text> propose adding text at the end mde accept <doc> <id...> accept suggestion(s); every id is tried and reported, exit 1 if any failed mde reject <doc> <id...> reject suggestion(s) mde events <doc> [--since N] [--json] list recent activity (comments, suggestions, edits, versions) mde watch <doc> [--since N] [--json] follow activity live (long-polls; Ctrl-C to stop) retries network/5xx blips; exits non-zero on a permanent error (auth, not found, bad request) mde watch --all [--json] follow EVERY doc you can see with one connection (needs a token); events carry their doc id mde watch --folder <id|folder-link> the same, for one folder's docs --exec <cmd> run a command per event batch (env: MDE_EVENTS json, MDE_DOC, MDE_LATEST) — re-read doc state in the command, don't replay events. With --json the command's stdout goes to stderr, so stdout stays pure JSON lines --skip-self drop this agent's own echoes: events by the account's agent name or by --author / MDE_AUTHOR, and versions labeled "<name>: …". With no agent name set, token writes show as your username, same as your own browser edits, so those are NOT skipped (set an agent name in the account menu) --cursor persist the last-seen seq per doc; restarts resume --daemon install all of the above as a background service (launchd on macOS, systemd --user on Linux) with logs in ~/.config/mde/logs; remove with --daemon-off. The service reads the token at runtime from your 0600 config (or a 0600 token file) — it is never written into it Agent presence mde register <name> [--role chief] [--harness x] register this process (re-registering without --harness keeps the saved one) mde heartbeat <name> refresh its presence mde activity <name> [--harness x] stream the agent's live activity downloads and runs the right adapter for the agent's harness (auto-detected from the registry; --harness overrides), with URL/token/name wired from your mde config — the /chief surface shows the work live. Tails the harness transcript where one exists, or tee a pipe through it: cursor-agent -p --output-format stream-json "…" | mde activity <name> Ctrl-C to stop. Adapter contract: /adapters.md Chief supervision mde chief-supervisor <cmd> [...] run the between-conversations chief daemon (heartbeat, cursor-deduped sweeps, durable reminders, reconnect prompts) — see: mde chief-supervisor help and docs/CHIEF-SUPERVISOR.md Feedback mde papercut <doc> <summary> [--category api|cli|docs|handoff|other] privately report concrete Workbench friction to maintainers --operation ID identify the affected handoff operation <doc> may be a doc id, a share URL, or a (partial) title; an exact title beats partial matches, and a 10-character title that isn't an id still resolves. Share URLs work without login: mde cat "https://host/d/abc?key=…" Your token is only sent to your configured server (MDE_URL / mde login); a share URL on any other host is used as a guest, with just its key. Text may start with "-" in comment/chat/reply/ask/suggest; "--" ends flags anywhere (mde comment <doc> -- --line is literal text). Environment MDE_URL, MDE_TOKEN override the saved server/token (great for CI and agents) MDE_TOKEN_FILE read the token from this file instead (keep it 0600) MDE_CONFIG_DIR config directory (default ~/.config/mde) MDE_AUTHOR attribution name when acting via a share URL without a token (also: --author <name> on push/comment/reply/suggest) with no name set, mde asks once and saves it; in scripts it signs as "agent". Names are marked "guest" and can't match an account username — use the account's token to write as it. ` // flags each command accepts; anything else is an error, not silence const FLAGS = { login: ['--token'], ls: ['--json'], list: ['--json'], search: ['--folder', '--json'], new: ['-f', '--file'], create: ['-f', '--file'], pull: ['-o', '--out', '--force'], push: ['-f', '--file', '--force', '--label', '--author', '--base'], comments: ['--json'], comment: ['--line', '--author'], chat: ['--fence', '--author'], reply: ['--author'], folder: ['--parent'], skill: ['--v', '-m'], claim: ['--as'], resolve: ['-m'], register: ['--role', '--harness'], activity: ['--harness'], suggest: ['--replace', '--with', '--delete', '--append', '--author'], history: ['--json', '--show'], revision: ['--json'], restore: ['--author'], events: ['--since', '--json'], watch: ['--since', '--json', '--exec', '--skip-self', '--cursor', '--daemon', '--daemon-off', '--author', '--all', '--folder'], papercut: ['--category', '--operation'], feedback: ['--category', '--operation'], upload: ['--insert', '--name', '--type', '--json', '--author'], attach: ['--insert', '--name', '--type', '--json', '--author'], } const BOOL_FLAGS = new Set(['--json', '--force', '--insert', '--skip-self', '--cursor', '--daemon', '--daemon-off', '--all']) // commands whose positional args are free text: a word that merely starts with // "-" is text there, not an unknown flag ("--" also ends flags everywhere) const TEXT_CMDS = new Set(['comment', 'chat', 'reply', 'ask', 'suggest', 'papercut', 'feedback', 'search', 'new', 'create']) const DOC_ID_RE = /^[A-Za-z0-9_-]{10}$/ main().catch(err => { console.error(`error: ${err.message}`) printServerGuidance(err) process.exit(1) }) // The API attaches agent guidance to most errors: `hint` (what to do next) and // sometimes `use` (the exact calls). Surface them instead of dropping them. function printServerGuidance(err) { const data = err?.data if (!data || typeof data !== 'object') return if (typeof data.hint === 'string' && data.hint) console.error(`hint: ${data.hint}`) if (data.use && typeof data.use === 'object') { console.error('api:') for (const [k, v] of Object.entries(data.use)) { const line = v && typeof v === 'object' && v.method ? `${v.method} ${v.path || ''}${v.requiredHeader ? ` (${v.requiredHeader})` : ''}` : JSON.stringify(v) console.error(` ${k}: ${line}`) } } } async function main() { // delegated subcommand: its flags belong to the child, not this parser if (cmd === 'chief-supervisor') return chiefSupervisor() parseArgs(FLAGS[cmd] || []) switch (cmd) { case 'login': return login() case 'whoami': return whoami() case 'ls': case 'list': return ls() case 'search': return searchDocs() case 'new': case 'create': return createDoc() case 'cat': return cat() case 'pull': return pull() case 'push': return push() case 'open': return openDoc() case 'share': return share() case 'rm': case 'delete': return rm() case 'upload': case 'attach': return upload() case 'folders': return folders() case 'folder': return folderCommand() case 'move': return moveDoc() case 'skill': return skillCommand() case 'ask': return createAskCommand() case 'claim': return claimAsk() case 'comments': return comments() case 'comment': return comment() case 'chat': return chat() case 'reply': return reply() case 'resolve': return resolveItem() case 'suggest': return suggest() case 'accept': return decide('accept') case 'reject': return decide('reject') case 'history': return history() case 'revision': return revision() case 'restore': return restore() case 'events': return events() case 'watch': return watch() case 'register': return registerAgent() case 'heartbeat': return heartbeatAgent() case 'activity': return activityStream() case 'papercut': case 'feedback': return papercut() case '--version': case 'version': case '-V': console.log(`mde ${VERSION}`); return case 'help': case '--help': case '-h': console.log(HELP); return case undefined: if (!TOKEN && !BASE) { welcome(); process.exit(1) } console.log(HELP); process.exit(1); return default: // stderr: a script capturing stdout must not mistake the help for output console.error(`unknown command: ${cmd}\n`) console.error(HELP) process.exit(1) } } // The supervisor lives in its own module so the self-contained mde download // stays a single file. Repo checkouts get the subcommand; a bare // curl-installed mde points at where to get it instead of half-working. function chiefSupervisor() { const script = path.join(path.dirname(fs.realpathSync(process.argv[1])), 'chief-supervisor.cjs') if (!fs.existsSync(script)) { console.error('chief-supervisor is not installed next to this mde binary.') console.error('It ships in the Workbench repo as cli/chief-supervisor.cjs — see docs/CHIEF-SUPERVISOR.md') process.exit(1) } const p = spawn(process.execPath, [script, ...args], { stdio: 'inherit' }) p.on('close', code => process.exit(typeof code === 'number' ? code : 1)) p.on('error', () => process.exit(1)) } // ---------- helpers ---------- // One pass over argv: known flags (and their values — a value may itself start // with "-", e.g. --with "-x") vs positionals. "--" ends flags. An unknown // "-word" is an error, except in free-text commands (where it's text) and // when it is shaped like a doc id (ids can start with "-"). // var without initializers: main() (and so parseArgs) runs before these lines // are evaluated — an initializer here would reset what it parsed var FLAGV var POS function parseArgs(allowed) { const allFlags = new Set([...Object.values(FLAGS).flat(), ...BOOL_FLAGS]) const dd = args.indexOf('--') const head = dd === -1 ? args : args.slice(0, dd) const tail = dd === -1 ? [] : args.slice(dd + 1) const flags = new Map() const pos = [] for (let i = 0; i < head.length; i++) { const a = head[i] if (allowed.includes(a)) { const v = BOOL_FLAGS.has(a) ? true : (i + 1 < head.length ? head[++i] : null) if (!flags.has(a)) flags.set(a, v) continue } const idShaped = DOC_ID_RE.test(a) && !allFlags.has(a) if (a.length > 1 && a.startsWith('-') && !TEXT_CMDS.has(cmd) && !idShaped) { throw new Error(`unknown flag for ${cmd || 'mde'}: ${a} (see: mde help; put -- before text that starts with "-")`) } pos.push(a) } FLAGV = flags POS = [...pos, ...tail] } function flag(name) { return FLAGV.has(name) ? FLAGV.get(name) : null } function hasFlag(name) { return FLAGV.has(name) } function positional() { return POS.slice() } // a flag that must carry a whole number: absent → null, junk → error function intFlag(name) { if (!hasFlag(name)) return null const v = flag(name) if (v == null || !/^\d+$/.test(String(v))) { throw new Error(`${name} needs a whole number (got ${v == null ? 'nothing' : JSON.stringify(v)})`) } return Number(v) } function authorOf() { return flag('--author') || process.env.MDE_AUTHOR || cfg.author || null } // Attribution when writing via a share link without a token: use --author / // MDE_AUTHOR / saved name; otherwise ask once (interactive) or sign as "agent". // Account usernames are off-limits without their token — the server rejects those. async function guestAuthor() { const known = authorOf() if (known) return known if (!process.stdin.isTTY) return 'agent' const name = await ask('Your name (shown on your comments and suggestions): ') if (!name) return 'agent' cfg.author = name saveConfig(cfg) console.error(`(saved to ${CONFIG_PATH} — override with --author or MDE_AUTHOR)`) return name } // One-line heads-up (once per run) when requests are being 308'd to the new // domain — old URLs work forever, but direct calls skip a round trip. let renameHinted = false function hintIfRenamed(base, res) { if (renameHinted || !res.redirected) return const finalHost = new URL(res.url).host if (finalHost !== new URL(base).host) { renameHinted = true console.error(`note: Simple Markdown Editor is now Workbench — set MDE_URL=https://${finalHost} (old URLs keep working)`) } } // ---------- token scoping ---------- // The account token goes ONLY to the configured server (MDE_URL / saved url). // A pasted share URL can point anywhere; sending it the bearer token would hand // a stranger's server full access to the account. Foreign URLs work as a guest // with just their ?key=. The old and new domain names count as one server. function canonOrigin(u) { try { const url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(u) ? u : `https://${u}`) let host = url.hostname.toLowerCase().replace(/^www\./, '') if (host === 'simplemarkdowneditor.com') host = 'workbench.md' return `${url.protocol}//${host}${url.port ? `:${url.port}` : ''}` } catch { return null } } function tokenFor(base) { if (!TOKEN || !BASE) return null const target = canonOrigin(base) return target && target === canonOrigin(BASE) ? TOKEN : null } // for anything printed (dry runs, diagnostics): enough to tell tokens apart function maskToken(t) { if (!t) return t return t.length > 12 ? `${t.slice(0, 4)}…${t.slice(-4)}` : '…' } let foreignNoted = false function noteForeign(base, status) { if (foreignNoted || !TOKEN || tokenFor(base) || (status !== 401 && status !== 403)) return foreignNoted = true let host = base try { host = new URL(base).host } catch {} console.error(`note: ${host} is not your configured server (${BASE || 'none set'}), so your mde token was not sent — use a share link with ?key=, or point MDE_URL at that server`) } async function req(base, p, { method = 'GET', body, key, raw } = {}) { const headers = {} const token = tokenFor(base) if (token) headers.authorization = `Bearer ${token}` if (key) headers['x-share-key'] = key if (body !== undefined) headers['content-type'] = 'application/json' const res = await fetch(`${base}/api${p}`, { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined }) hintIfRenamed(base, res) if (raw && res.ok) return { text: await res.text(), version: res.headers.get('x-doc-version') } const isJson = res.headers.get('content-type')?.includes('json') const data = isJson ? await res.json() : { error: await res.text() } if (!res.ok) { noteForeign(base, res.status) const err = new Error(data.error || `${res.status} ${res.statusText}`) err.status = res.status err.data = data const retryAfter = Number(res.headers.get('retry-after')) if (Number.isFinite(retryAfter) && retryAfter > 0) err.retryAfter = retryAfter throw err } return data } function needBase() { if (!BASE) throw new Error('not logged in — run: mde login (or set MDE_URL)') return BASE.replace(/\/+$/, '') } // Resolve a <doc> argument: id, share URL, or title (exact beats partial). async function resolveDoc(ref) { if (!ref) throw new Error('which doc? pass an id, share URL, or title') try { const u = new URL(ref) const m = u.pathname.match(/\/d\/([A-Za-z0-9_-]+)/) if (m) return { base: u.origin, id: m[1], key: u.searchParams.get('key') } } catch { /* not a URL */ } const base = needBase() if (DOC_ID_RE.test(ref)) { // id-shaped: an id wins, but "Tech Notes" is a 10-char title too — on a // 404 fall back to a title lookup. With no title match (or no way to list // titles) keep the id, so the command reports the server's own error. try { await req(base, `/docs/${encodeURIComponent(ref)}`) return { base, id: ref, key: null } } catch (err) { if (err.status !== 404) return { base, id: ref, key: null } } try { const hit = pickByTitle((await req(base, '/docs')).docs, ref) if (hit) return { base, id: hit, key: null } } catch (err) { if (/ambiguous/.test(err.message)) throw err } return { base, id: ref, key: null } } const { docs } = await req(base, '/docs') const id = pickByTitle(docs, ref) if (!id) throw new Error(`no doc matching “${ref}”`) return { base, id, key: null } } // exact case-insensitive title first; otherwise a unique substring match function pickByTitle(docs, ref) { const want = ref.toLowerCase() const exact = docs.filter(d => String(d.title).toLowerCase() === want) const matches = exact.length ? exact : docs.filter(d => String(d.title).toLowerCase().includes(want)) if (matches.length === 1) return matches[0].id if (matches.length === 0) return null throw new Error(`“${ref}” is ambiguous:\n` + matches.map(d => ` ${d.id} ${d.title}`).join('\n')) } function ask(question, { hidden } = {}) { return new Promise(res => { const rl = readline.createInterface({ input: process.stdin, output: process.stderr }) if (hidden) { process.stderr.write(question) const onData = (ch) => { if (ch.toString().includes('\n')) process.stderr.write('\n') } process.stdin.on('data', onData) rl.question('', a => { process.stdin.off('data', onData); rl.close(); res(a.trim()) }) rl._writeToOutput = () => {} } else { rl.question(question, a => { rl.close(); res(a.trim()) }) } }) } // Markdown from -f, else from piped stdin, else null. A spawned process can // hand us a non-blocking pipe that is open but empty (an agent harness that // never writes or closes it) — a sync read then throws EAGAIN. Fall back to // an async read: if nothing arrives within idleMs, there's no input; once // bytes start flowing, read to EOF. async function readInput(file, { idleMs = 1000 } = {}) { if (file) return fs.readFileSync(file, 'utf8') if (process.stdin.isTTY) return null const chunks = [] const buf = Buffer.alloc(1 << 16) for (;;) { let n try { n = fs.readSync(0, buf, 0, buf.length, null) } catch (err) { if (err.code === 'EOF') n = 0 else if (err.code === 'EAGAIN' || err.code === 'EWOULDBLOCK') break else throw err } if (n === 0) return Buffer.concat(chunks).toString('utf8') chunks.push(Buffer.from(buf.subarray(0, n))) } return new Promise(resolve => { let settled = false const done = (value) => { if (settled) return settled = true clearTimeout(timer) process.stdin.removeAllListeners('data') process.stdin.destroy() resolve(value) } const all = () => Buffer.concat(chunks).toString('utf8') const timer = chunks.length ? null : setTimeout(() => done(null), idleMs) process.stdin.on('data', c => { clearTimeout(timer); chunks.push(c) }) process.stdin.on('end', () => done(all())) process.stdin.on('error', () => done(chunks.length ? all() : null)) }) } function ago(ts) { const m = Math.floor((Date.now() - ts) / 60000) if (m < 1) return 'just now' if (m < 60) return `${m}m ago` const h = Math.floor(m / 60) if (h < 24) return `${h}h ago` return `${Math.floor(h / 24)}d ago` } // ---------- commands ---------- async function login() { // most specific wins: the url argument, then MDE_URL, then the saved server let url = positional()[0] || process.env.MDE_URL || cfg.url || 'https://workbench.md' url = url.replace(/\/+$/, '') if (!/^https?:\/\//.test(url)) url = 'https://' + url // scripts and agents pass --token; humans get the browser approval flow if (hasFlag('--token')) { const explicit = flag('--token') // a bare --token (empty $VAR in a script) must not silently fall into the // interactive browser flow and hang a CI job if (!explicit || !explicit.trim()) throw new Error('--token needs a value: mde login [url] --token <token> (copy one from the account menu)') return finishLogin(url, explicit.trim()) } // userCode:true asks for a separate confirmation code that is NOT in the // approval URL; the browser page makes you type it (anti-phishing). const r = await fetch(`${url}/api/cli/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ userCode: true }), }) if (!r.ok) throw new Error(`couldn't start a login with ${url} (${r.status})`) const { code, secret, user_code: userCode, verify_url: verifyUrl, interval, expires_in: expiresIn } = await r.json() console.log(`Approve this login in your browser (code ${code}):\n\n ${verifyUrl}\n`) if (userCode) console.log(`When asked, type this confirmation code: ${userCode}\n`) openInBrowser(verifyUrl) const deadline = Date.now() + expiresIn * 1000 while (Date.now() < deadline) { await new Promise(res => setTimeout(res, (interval || 2) * 1000)) const c = await fetch(`${url}/api/cli/login/${code}/claim`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ secret }), }) if (!c.ok) throw new Error('this login expired — run mde login again') const data = await c.json() if (data.token) return finishLogin(url, data.token) } throw new Error('login timed out — run mde login again (or pass --token, from the account menu on the web)') } async function finishLogin(url, token) { const res = await fetch(`${url}/api/me`, { headers: { authorization: `Bearer ${token}` } }) if (!res.ok) throw new Error(`that token didn’t work — copy a fresh one from the account menu at ${url}, or run mde login without --token to sign in via your browser`) const { user } = await res.json() // keep unrelated saved settings (e.g. the guest author name) saveConfig({ ...cfg, url, token }) let count = null try { count = (await (await fetch(`${url}/api/docs`, { headers: { authorization: `Bearer ${token}` } })).json()).docs.length } catch { /* cosmetic */ } console.log(`${green('✓')} You’re in — ${bold(user.username)} @ ${url}${count != null ? dim(` (${count} doc${count === 1 ? '' : 's'})`) : ''}`) console.log(dim('\nTry:')) console.log(` mde new ${JSON.stringify('My first doc')} ${dim('create a doc (opens in your browser)')}`) console.log(` mde ls ${dim('list your docs')}`) console.log(` mde watch <doc> ${dim('follow a doc’s activity live')}`) } // first contact with no config: three lines, one obvious next step function welcome() { console.log(`${bold('mde')} — collaborative markdown docs, from the terminal\n`) console.log(` ${green('mde login')} sign in (approve in your browser)`) console.log(` mde new "Title" create a doc — works even logged out`) console.log(dim(` mde help everything else`)) } // Best-effort: pop the approval page in the default browser; the printed URL // is the real interface, so failures here are silent. function openInBrowser(url) { if (!process.stdout.isTTY) return // scripts/CI: never pop a browser const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open' try { spawn(cmd, [url], { stdio: 'ignore', detached: true }).on('error', () => {}).unref() } catch { /* printed URL suffices */ } } async function whoami() { const base = needBase() const { user } = await req(base, '/me') console.log(`${user.username} @ ${base}`) } async function ls() { const base = needBase() const { docs } = await req(base, '/docs') if (flag('--json')) { console.log(JSON.stringify(docs, null, 2)); return } if (!docs.length) { console.log('no documents yet — try: mde new "My doc"'); return } for (const d of docs) { console.log(`${d.id} ${pad(ago(d.updated_at), 12)} ${d.title}`) } } function pad(s, n) { return String(s).padEnd(n) } function searchQuery(query, folder) { const filter = folder ? ` folder:"${String(folder).replace(/"/g, '').trim()}"` : '' return `${query}${filter}`.trim() } async function searchDocs() { const base = needBase() const query = positional().join(' ').trim() if (!query) throw new Error('usage: mde search <query> [--folder name]') const { results } = await req(base, `/search?q=${encodeURIComponent(searchQuery(query, flag('--folder')))}`) if (flag('--json')) { console.log(JSON.stringify(results, null, 2)); return } if (!results.length) { console.log('no matching documents'); return } for (const result of results) { const snippet = String(result.snippet || '').replace(/<\/?mark>/gi, '') console.log(`${result.docId} ${result.title}${result.folderId ? ` [${result.folderId}]` : ''}`) if (snippet && snippet !== result.title) console.log(` ${snippet}`) } } function printFolderTree(nodes, depth = 0) { for (const folder of nodes) { console.log(`${' '.repeat(depth)}${folder.id} ${folder.name} (${folder.docCount} doc${folder.docCount === 1 ? '' : 's'})`) printFolderTree(folder.children || [], depth + 1) } } async function folders() { const base = needBase() const { folders: tree } = await req(base, '/folders') if (!tree.length) { console.log('no folders yet — try: mde folder new "Projects"'); return } printFolderTree(tree) } async function folderCommand() { const pos = positional() if (pos[0] !== 'new') throw new Error('usage: mde folder new <name> [--parent id]') const name = pos.slice(1).join(' ').trim() if (!name) throw new Error('usage: mde folder new <name> [--parent id]') const parentId = flag('--parent') if (hasFlag('--parent') && !parentId) throw new Error('--parent needs a folder id') const { folder } = await req(needBase(), '/folders', { method: 'POST', body: { name, ...(parentId ? { parentId } : {}) }, }) console.log(`${folder.id} ${folder.name}`) } async function moveDoc() { const pos = positional() if (!pos[0] || !pos[1]) throw new Error('usage: mde move <doc> <folderId|none>') const { base, id } = await resolveDoc(pos[0]) const folderId = pos[1].toLowerCase() === 'none' ? null : pos[1] await req(base, `/docs/${id}/move`, { method: 'POST', body: { folderId } }) console.log(folderId ? `moved ${id} → ${folderId}` : `unfiled ${id}`) } function skillTarget(ref, version) { let target try { target = new URL(ref) } catch { if (!/^[A-Za-z0-9_-]+$/.test(ref || '')) throw new Error('skill must be a folder share URL or directory slug') target = new URL(`/skills/${encodeURIComponent(ref)}/manifest`, `${needBase()}/`) } if (!['http:', 'https:'].includes(target.protocol)) throw new Error('skill URL must use http or https') const directory = target.pathname.match(/^\/skills\/([^/]+)\/?$/) if (directory) target.pathname = `/skills/${directory[1]}/manifest` else if (!/^\/folders\/[A-Za-z0-9_-]+\/?$/.test(target.pathname) && !/^\/skills\/[^/]+\/manifest\/?$/.test(target.pathname)) { throw new Error('skill URL must point to /folders/<id> or /skills/<slug>[/manifest]') } if (/^\/folders\//.test(target.pathname)) target.searchParams.set('format', 'install.json') if (version != null) { if (!/^\d+$/.test(version) || Number(version) < 1) throw new Error('--v must be a positive release version') target.searchParams.set('v', version) } return target } async function printSkillManifest(ref) { if (!ref) throw new Error('usage: mde skill manifest <url|slug> [--v N]') const target = skillTarget(ref, flag('--v')) const headers = { accept: 'application/json' } const token = tokenFor(target.origin) if (token) headers.authorization = `Bearer ${token}` const res = await fetch(target, { headers }) const data = res.headers.get('content-type')?.includes('json') ? await res.json() : { error: await res.text() } if (!res.ok) { const err = new Error(data.error || `${res.status} ${res.statusText}`) err.status = res.status; err.data = data throw err } console.log(JSON.stringify(data, null, 2)) } async function releaseSkill(folderId) { if (!folderId) throw new Error('usage: mde skill release <folderId> [-m "notes"]') const notes = flag('-m') || 'Released with mde' const { release } = await req(needBase(), `/folders/${encodeURIComponent(folderId)}/releases`, { method: 'POST', body: { notes }, }) console.log(`released ${folderId} v${release.version}: ${release.notes}`) } async function skillCommand() { const pos = positional() if (pos[0] === 'manifest') return printSkillManifest(pos[1]) if (pos[0] === 'release') return releaseSkill(pos[1]) throw new Error('usage: mde skill manifest <url|slug> [--v N] | mde skill release <folderId> [-m "notes"]') } async function commandAgentName(base) { const explicit = flag('--as') if (explicit) return explicit if (!tokenFor(base)) return guestAuthor() const { user } = await req(base, '/me') return user.agentName || user.username } async function createAskCommand() { const pos = positional() const { base, id, key } = await resolveDoc(pos[0]) const text = pos.slice(1).join(' ').trim() if (!text) throw new Error('usage: mde ask <doc> <text>') const body = { text } if (!tokenFor(base)) body.author = await guestAuthor() const { ask } = await req(base, `/docs/${id}/asks`, { method: 'POST', key, body }) console.log(`asked (${ask.id})`) } async function claimAsk() { const pos = positional() const { base, id, key } = await resolveDoc(pos[0]) if (!pos[1]) throw new Error('usage: mde claim <doc> <askId> [--as name]') const agent = await commandAgentName(base) try { const { ask } = await req(base, `/docs/${id}/asks/${encodeURIComponent(pos[1])}/claim`, { method: 'POST', key, body: { agent }, }) console.log(`claimed ${ask.id} as ${ask.claimedBy}`) } catch (err) { if (err.status === 409 && err.data?.reason === 'chief-window') { throw new Error(`chief-window until ${new Date(err.data.windowEndsAt).toISOString()}`) } if (err.status === 409 && Object.prototype.hasOwnProperty.call(err.data || {}, 'claimedBy')) { const at = err.data.claimedAt const when = at == null ? 'an unknown time' : Number.isFinite(Number(at)) ? `${new Date(Number(at)).toISOString()} (${ago(Number(at))})` : String(at) throw new Error(`already claimed by ${err.data.claimedBy || 'another agent'} at ${when}`) } // with an account token the claim is recorded as the account's identity; // an --as naming someone else is refused — say how to fix it if (err.status === 409 && tokenFor(base) && /account identity/i.test(err.message)) { const identity = (err.message.match(/identity:\s*(.+)$/) || [])[1] const e2 = new Error(`${err.message} this token claims as ${identity ? `“${identity}”` : 'the account’s agent name (or username)'}, not “${agent}”. Either: mde claim ${pos[0]} ${pos[1]} (omit --as — claims as ${identity || 'the account identity'}) set the account's agent name to “${agent}” (account menu, or POST /api/me/agent-name) and retry`) throw e2 } throw err } } async function registerAgent() { const name = positional()[0] if (!name) throw new Error('usage: mde register <name> [--role chief] [--harness x]') const role = flag('--role') if (role && !['agent', 'chief'].includes(role)) throw new Error('--role must be agent or chief') if (hasFlag('--harness') && !flag('--harness')) throw new Error('--harness needs a value') const base = needBase() // re-registering (e.g. a restart script) without --harness must not erase // the harness on record — carry the saved one forward let harness = flag('--harness') if (!harness) { try { const { agents } = await req(base, '/agents') harness = agents.find(a => String(a.name).toLowerCase() === name.toLowerCase())?.harness || null } catch { /* first registration, or no list access: register without one */ } } const { agent } = await req(base, '/agents/register', { method: 'POST', body: { name, ...(role ? { role } : {}), ...(harness ? { harness } : {}) }, }) console.log(`registered ${agent.name} (${agent.role}, ${agent.freshness})`) } async function heartbeatAgent() { const name = positional()[0] if (!name) throw new Error('usage: mde heartbeat <name>') const { agent } = await req(needBase(), '/agents/heartbeat', { method: 'POST', body: { name } }) console.log(`heartbeat ${agent.name} (${agent.freshness})`) } // mde activity <name> [--harness x] — start the live-activity stream for a // registered agent with one command: resolve the harness (registry entry // unless --harness overrides), download the matching adapter from /adapters, // and run it with WORKBENCH_URL/TOKEN/AGENT_NAME wired from the mde config. // The adapter does the real work (tail the harness transcript, or tee stdin // when piped); this verb only removes the env fiddling. MDE_ACTIVITY_DRY_RUN=1 // prints the resolution as JSON instead of running — the testable seam. async function activityStream() { const base = needBase() if (!TOKEN) throw new Error('mde activity needs your account token — run: mde login (adapters authenticate activity pushes with it)') const name = positional()[0] if (!name) throw new Error('usage: mde activity <agent-name> [--harness x]') let harness = flag('--harness') if (hasFlag('--harness') && !harness) throw new Error('--harness needs a value (see GET /adapters for what exists)') if (!harness) { const { agents } = await req(base, '/agents') const agent = agents.find(a => a.name.toLowerCase() === name.toLowerCase()) if (!agent) throw new Error(`no registered agent named “${name}” — register first (mde register ${name} --harness <x>) or pass --harness`) harness = agent.harness if (!harness) throw new Error(`agent “${name}” has no harness on record — re-register with one (mde register ${name} --harness <x>) or pass --harness`) } // fetch the adapter for this harness; a 404 lists what exists instead const dl = await fetch(`${base}/adapters/${encodeURIComponent(harness)}.mjs`) if (!dl.ok) { let names = [] try { names = (await (await fetch(`${base}/adapters`)).json()).adapters.map(a => a.name) } catch { /* the error below still helps */ } throw new Error(`no adapter for harness “${harness}”${names.length ? ` — available: ${names.join(', ')}` : ''}. The contract for writing one is at ${base}/adapters.md`) } const adapterDir = path.join(CONFIG_DIR, 'adapters') fs.mkdirSync(adapterDir, { recursive: true }) const adapterPath = path.join(adapterDir, `${harness}.mjs`) fs.writeFileSync(adapterPath, await dl.text(), { mode: 0o600 }) // fresh every run: deploys update adapters const env = { ...process.env, WORKBENCH_URL: base, WORKBENCH_TOKEN: TOKEN, AGENT_NAME: name } if (process.env.MDE_ACTIVITY_DRY_RUN) { console.log(JSON.stringify({ agent: name, harness, adapter: adapterPath, env: { WORKBENCH_URL: base, WORKBENCH_TOKEN: maskToken(TOKEN), AGENT_NAME: name }, }, null, 2)) return } console.error(`streaming ${name} (${harness} adapter) → ${base} — watch it at ${base}/chief. Ctrl-C to stop.`) // stdio inherit end to end: a piped stdin reaches the adapter (stdin/tee // mode), tail-mode status lines land on stderr, pass-through on stdout. // Piped input is announced with --stdin explicitly: adapters sniff a FIFO // themselves, but a pipe from another program can be a socket (Node spawn // pipes are), which their fstat check misses. let piped = false try { const st = fs.fstatSync(0); piped = st.isFIFO() || st.isSocket() } catch { /* no stdin at all */ } const p = spawn(process.execPath, [adapterPath, ...(piped ? ['--stdin'] : [])], { env, stdio: 'inherit' }) const code = await new Promise(res => { p.on('close', res); p.on('error', () => res(1)) }) process.exit(typeof code === 'number' ? code : 1) } async function createDoc() { // unquoted multi-word titles are common (mde new My Notes Doc) — keep them all const title = positional().join(' ').trim() const content = (await readInput(flag('-f') || flag('--file'))) || '' if (TOKEN) { const base = needBase() const { doc } = await req(base, '/docs', { method: 'POST', body: { title: title || 'Untitled', content } }) console.log(`${doc.url}`) openInBrowser(doc.url) // TTY-gated: scripts/agents just get the URL return } // no token? no problem — mint an anonymous doc that lives at its secret link const base = (BASE || 'https://workbench.md').replace(/\/+$/, '') const res = await fetch(`${base}/new`, { redirect: 'manual' }) const loc = res.headers.get('location') if (!loc) throw new Error(`could not create a doc at ${base}/new (${res.status})`) const url = `${base}${loc}` const parsed = new URL(url) const docId = parsed.pathname.split('/')[2] const key = parsed.searchParams.get('key') if (content) await req(base, `/docs/${docId}/content`, { method: 'PUT', key, body: { content } }) if (title) await req(base, `/docs/${docId}`, { method: 'PATCH', key, body: { title } }) console.log(url) openInBrowser(url) // TTY-gated: scripts/agents just get the URL } async function cat() { const { base, id, key } = await resolveDoc(positional()[0]) const { text } = await req(base, `/docs/${id}/content`, { key, raw: true }) process.stdout.write(text.endsWith('\n') || text === '' ? text : text + '\n') } // ---------- pull/push version tracking ---------- // versions.json: "<origin>|<id>" -> version of the last pull/safe push, and // "<origin>|<id>|<abs file>" -> { version, sha } for pull -o / push -f, so a // later stdout pull can't vouch for a file that was never merged. Pre-scoping // entries were keyed by bare id; they're still honored (and migrated on write). function versionKeys(base, id, file) { const docKey = `${canonOrigin(base) || base}|${id}` return { docKey, fileKey: file ? `${docKey}|${path.resolve(file)}` : null } } function sha256(text) { return crypto.createHash('sha256').update(text).digest('hex') } function recordVersion(base, id, version, file, text) { if (!version) return const all = loadVersions() const { docKey, fileKey } = versionKeys(base, id, file) all[docKey] = version if (typeof all[id] === 'string') delete all[id] // migrate the legacy bare-id entry if (fileKey) all[fileKey] = { version, sha: sha256(text) } writeVersions(all) } // how far two texts diverge, as line counts (a summary, not a merge) function lineDelta(mine, theirs) { const count = (t) => { const m = new Map(); for (const l of t.split('\n')) m.set(l, (m.get(l) || 0) + 1); return m } const a = count(mine), b = count(theirs) let onlyMine = 0, onlyTheirs = 0 for (const [l, n] of a) onlyMine += Math.max(0, n - (b.get(l) || 0)) for (const [l, n] of b) onlyTheirs += Math.max(0, n - (a.get(l) || 0)) return { onlyMine, onlyTheirs } } function mergeSteps(ref, mine, remotePath, version) { return ` to merge: diff ${mine} ${remotePath} fold the changes you want to keep into ${mine}, then push on top of that version: mde push ${ref} -f ${mine} --base ${version}` } async function pull() { const ref = positional()[0] const { base, id, key } = await resolveDoc(ref) const out = flag('-o') || flag('--out') if ((hasFlag('-o') || hasFlag('--out')) && !out) throw new Error('-o needs a file path') const { text, version } = await req(base, `/docs/${id}/content`, { key, raw: true }) if (out) { // Never silently clobber edits made to the file since we last pulled into // it — that's exactly the "pull after a conflict" trap that loses work. const { fileKey } = versionKeys(base, id, out) const rec = loadVersions()[fileKey] if (rec?.sha && fs.existsSync(out) && !flag('--force')) { const local = fs.readFileSync(out, 'utf8') if (local !== text && sha256(local) !== rec.sha) { const remotePath = `${out}.remote.md` fs.writeFileSync(remotePath, text) const d = lineDelta(local, text) throw new Error(`${out} has local edits since you pulled it (version ${rec.version}) — not overwriting them. the current doc (version ${version}) is saved to ${remotePath} (${d.onlyTheirs} line(s) there aren't in your file; ${d.onlyMine} of yours aren't there) ${mergeSteps(ref, out, remotePath, version)} to throw away your local edits instead: mde pull ${ref} -o ${out} --force`) } } fs.writeFileSync(out, text) recordVersion(base, id, version, out, text) console.log(`pulled ${id} → ${out} (version ${version})`) } else { recordVersion(base, id, version) process.stdout.write(text.endsWith('\n') || text === '' ? text : text + '\n') } } async function push() { const ref = positional()[0] const { base, id, key } = await resolveDoc(ref) const file = flag('-f') || flag('--file') const content = await readInput(file) if (content == null) throw new Error('pass -f <file> or pipe markdown on stdin') const force = !!flag('--force') // Empty input is far more often a broken source (tmp-cleaned file, failed // generator) than an intended clear — a blind push once wiped a large doc. if (!content.trim() && !force) { throw new Error('refusing to push empty content — the file/stdin is empty. If you really mean to clear the doc, push --force') } const body = { content } const label = flag('--label') if (label) body.label = label if (!tokenFor(base)) body.author = await guestAuthor() // safe by default: only push on top of the version this file (or, for // stdin, this doc) was last pulled at; --base names the version explicitly const all = loadVersions() const { docKey, fileKey } = versionKeys(base, id, file) const fileRec = fileKey ? all[fileKey] : null let tracked = fileRec?.version || all[docKey] || (typeof all[id] === 'string' ? all[id] : null) if (hasFlag('--base')) { if (!flag('--base')) throw new Error('--base needs a version (the one printed by pull or by a refused push)') tracked = flag('--base') } if (tracked && !force) body.baseVersion = tracked // --force is the explicit "I mean it" — carry the server's clear opt-in too, // so a forced empty/near-empty push isn't re-refused by the API's wipe guard // --force also opts past the live-edit guard (their text stays in version history) if (force) { body.allowClear = true; body.overwriteLive = true } if (!tracked && !force) { console.error(`note: no pulled version of ${id} on record, so this push blindly replaces whatever is there now (anyone's newer edits included). For a safe push: mde pull ${ref} -o file, edit, mde push ${ref} -f file`) } const mine = file || '<your file>' try { const { version } = await req(base, `/docs/${id}/content`, { method: 'PUT', key, body }) if (tracked) recordVersion(base, id, version, file, content) // keep the pull cache current console.log('pushed') } catch (err) { if (err.status !== 409) throw err const data = err.data || {} const code = String(data.code || '') const msg = String(data.error || err.message) const wipe = code === 'wipe_guard' || (!code && /refusing a blind/i.test(msg)) const live = code === 'live_edits' const conflict = !wipe && (code ? /conflict|stale|live_edits/i.test(code) : /changed since/i.test(msg)) if (wipe) { // the server refused a destructive shrink of a doc we never read — // NOT a version conflict. Nothing changed; don't steer toward a wipe. const e = new Error(`${msg} nothing was changed. This push had no pulled version behind it and would shrink the doc drastically. if your file or stdin is empty/truncated by accident, fix it — the doc is untouched. if the shrink is intended: mde pull ${ref} -o ${file || 'file'} to read the doc first, make your edit there, then mde push ${ref} -f ${file || 'file'}`) e.data = typeof data.hint === 'string' ? { hint: data.hint } : null throw e } if (!conflict) throw err let remote = null try { remote = await req(base, `/docs/${id}/content`, { key, raw: true }) } catch { /* fall back to the 409's version */ } const now = remote?.version || data.currentVersion || 'unknown' let saved = '' if (remote) { const remotePath = file ? `${file}.remote.md` : path.join(os.tmpdir(), `mde-${id}.remote.md`) fs.writeFileSync(remotePath, remote.text) const d = lineDelta(content, remote.text) saved = ` the current doc is saved to ${remotePath} (${d.onlyTheirs} line(s) there aren't in your ${file ? 'file' : 'input'}; ${d.onlyMine} of yours aren't there) ${mergeSteps(ref, mine, remotePath, now)}` } throw new Error(`${live ? 'someone is editing this doc live and changed it since you last read it' : 'the doc changed since your last pull'} — nothing was overwritten. ${tracked ? `your edit is based on version ${tracked}; ` : ''}the doc is now at version ${now}.${saved || ` fetch it with: mde cat ${ref} > latest.md, fold its changes into yours, then mde push ${ref} -f ${mine} --base ${now}`} last resort, discarding their changes: mde push ${ref} ${file ? `-f ${file} ` : ''}--force`) } } // Extension → the MIME types POST /docs/:id/assets accepts. The server checks // the bytes against the declared type, so a wrong guess fails loudly. // (a function, not a const: main() runs before module-level consts below it) function uploadMime() { return { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', mp4: 'video/mp4', webm: 'video/webm', mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg', js: 'application/javascript', mjs: 'application/javascript', css: 'text/css', wasm: 'application/wasm', json: 'application/json', txt: 'text/plain', log: 'text/plain', md: 'text/markdown', py: 'text/x-python', sh: 'application/x-sh', ts: 'text/x-typescript', yaml: 'application/yaml', yml: 'application/yaml', toml: 'application/toml', csv: 'text/csv', } } // what a file's leading bytes say it is (images — the common mislabel) function sniffImage(buf) { const ascii = (a, b) => buf.subarray(a, b).toString('latin1') if (buf.length >= 8 && buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return { name: 'a PNG image', type: 'image/png' } if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return { name: 'a JPEG image', type: 'image/jpeg' } if (ascii(0, 6) === 'GIF87a' || ascii(0, 6) === 'GIF89a') return { name: 'a GIF image', type: 'image/gif' } if (ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'WEBP') return { name: 'a WebP image', type: 'image/webp' } if (/^\s*<(\?xml|svg)/i.test(ascii(0, 256))) return { name: 'SVG/XML (not accepted — export a PNG)', type: null } return null } async function upload() { const pos = positional() const file = pos[1] if (!pos[0] || !file) throw new Error('usage: mde upload <doc> <file> [--insert] [--name path] [--type mime]') if (!fs.existsSync(file)) throw new Error(`no such file: ${file}`) const ext = path.extname(file).slice(1).toLowerCase() const UPLOAD_MIME = uploadMime() const type = flag('--type') || UPLOAD_MIME[ext] if (!type) { throw new Error(`can't tell the type of “${path.basename(file)}” — pass --type <mime> (supported: ${[...new Set(Object.values(UPLOAD_MIME))].join(', ')}; SVG isn't accepted)`) } const { base, id, key } = await resolveDoc(pos[0]) const headers = { 'content-type': type } const token = tokenFor(base) if (token) headers.authorization = `Bearer ${token}` if (key) headers['x-share-key'] = key const name = flag('--name') if (name) headers['x-asset-name'] = name const bytes = fs.readFileSync(file) const res = await fetch(`${base}/api/docs/${id}/assets`, { method: 'POST', headers, body: bytes }) hintIfRenamed(base, res) const data = res.headers.get('content-type')?.includes('json') ? await res.json() : { error: await res.text() } if (!res.ok) { noteForeign(base, res.status) let message = data.error || `upload failed (${res.status})` if (res.status === 415 && /do not match/i.test(message)) { const seen = sniffImage(bytes) message += seen ? ` — “${path.basename(file)}” is actually ${seen.name}${seen.type && seen.type !== type ? `; rename it or pass --type ${seen.type}` : ''}` : ` — “${path.basename(file)}” doesn't start like a ${type} file (is it the file you meant, or a different format under this extension?)` } const err = new Error(message) err.status = res.status; err.data = data throw err } for (const w of data.warnings || []) console.error(`warning: ${w.message}`) let inserted = false if (flag('--insert')) { if (data.kind === 'code') { // the upload worked — it just has no inline embed; that's not a failure console.error(`note: uploaded — code/text assets have no inline embed, so nothing was inserted into the doc (link to ${data.url} yourself if you want)`) } else { // append on top of the version we read; retry (with jittered backoff) // if someone edits in between let lastErr = null for (let attempt = 0; attempt < 6; attempt++) { try { const { text, version } = await req(base, `/docs/${id}/content`, { key, raw: true }) const body = { content: `${text.replace(/\s*$/, '')}\n\n${data.markdown}\n`, baseVersion: version, label: `upload ${path.basename(file)}` } if (!tokenFor(base)) body.author = await guestAuthor() await req(base, `/docs/${id}/content`, { method: 'PUT', key, body }) inserted = true break } catch (err) { lastErr = err if (err.status !== 409) break await new Promise(r => setTimeout(r, Math.min(4000, 150 * 2 ** attempt) * (0.5 + Math.random()))) } } if (!inserted) { // the asset exists either way — never leave it orphaned and unprinted if (flag('--json')) console.log(JSON.stringify(data, null, 2)) else { console.log(data.url); console.log(data.markdown) } const e = new Error(`uploaded ${data.url}, but couldn't insert it into the doc (${lastErr?.message || 'unknown error'}) — paste the markdown above where you want it`) e.data = lastErr?.data throw e } } } if (flag('--json')) { console.log(JSON.stringify(data, null, 2)); return } console.log(data.url) if (inserted) console.log(dim(`inserted at the end of the doc: ${data.markdown}`)) else if (data.markdown) console.log(data.markdown) } async function openDoc() { const { base, id, key } = await resolveDoc(positional()[0]) const url = `${base}/d/${id}${key ? `?key=${key}` : ''}` console.log(url) // headless boxes have no opener (spawn ENOENT) — the printed URL is enough const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open' try { spawn(opener, [url], { detached: true, stdio: 'ignore' }) .on('error', () => console.error('(no browser opener here — open the URL above yourself)')) .unref() } catch { /* printed URL suffices */ } } async function share() { const SHARE_ROLES = ['view', 'comment', 'suggest', 'edit'] const pos = positional() const role = pos[1] || 'view' if (!SHARE_ROLES.includes(role)) throw new Error(`bad role “${role}” — use one of: ${SHARE_ROLES.join(', ')}`) const { base, id, key } = await resolveDoc(pos[0]) const { share } = await req(base, `/docs/${id}/shares`, { method: 'POST', key, body: { role } }) console.log(share.url) } async function rm() { const ref = positional()[0] const { base, id, key } = await resolveDoc(ref) // deletion is irreversible from here — don't accept fuzzy title matches // (an id-shaped ref that resolved to a different id came from a title lookup) if (id !== ref && !/^https?:\/\//.test(ref)) { const { docs } = await req(base, '/docs') const doc = docs.find(d => d.id === id) if (doc && doc.title.toLowerCase() !== ref.toLowerCase()) { throw new Error(`“${ref}” only partially matches “${doc.title}” — to delete it, use the exact title or: mde rm ${id}`) } } await req(base, `/docs/${id}`, { method: 'DELETE', key, body: {} }) console.log('deleted') } async function comments() { const { base, id, key } = await resolveDoc(positional()[0]) const [{ comments }, { suggestions }] = await Promise.all([ req(base, `/docs/${id}/comments`, { key }), req(base, `/docs/${id}/suggestions`, { key }), ]) if (flag('--json')) { console.log(JSON.stringify({ comments, suggestions }, null, 2)); return } if (suggestions.length) { console.log('suggestions:') for (const s of suggestions) { console.log(` ${s.id} [${s.type === 'insert' ? '+' : '-'}] ${s.author}, ${ago(s.createdAt)}: ${JSON.stringify(trim(s.text))}`) } console.log('') } const open = comments.filter(c => !c.resolved) const resolved = comments.filter(c => c.resolved) if (!open.length && !suggestions.length) console.log('no open comments') for (const c of open) { const where = c.quote ? ` re: ${JSON.stringify(trim(c.quote))}` : '' console.log(`${c.id} ${c.author}, ${ago(c.createdAt)}${where}`) console.log(` ${c.body}`) for (const r of c.replies) console.log(` ↳ ${r.author}: ${r.body}`) } if (resolved.length) console.log(`(${resolved.length} resolved)`) } function trim(s) { return s.length > 60 ? s.slice(0, 60) + '…' : s } async function comment() { const pos = positional() const { base, id, key } = await resolveDoc(pos[0]) const body = pos.slice(1).join(' ') if (!body) throw new Error('what should the comment say?') const line = intFlag('--line') const payload = { body } if (line != null) payload.line = line if (!tokenFor(base)) payload.author = await guestAuthor() const { id: cid, anchored } = await req(base, `/docs/${id}/comments`, { method: 'POST', key, body: payload }) console.log(`commented (${cid})${anchored === false ? ' — note: that line could not be anchored, added as a doc-level comment' : ''}`) } // Post one message to a doc's team-chat fence without round-tripping the whole // document — the CLI face of POST /docs/:id/chat/message, for agents whose // sandbox blocks curl but allows mde. async function chat() { const pos = positional() const { base, id, key } = await resolveDoc(pos[0]) const text = pos.slice(1).join(' ') if (!text) throw new Error('usage: mde chat <doc> <text> [--fence <id>] [--author <name>]') const fence = flag('--fence') if (hasFlag('--fence') && !fence) throw new Error('--fence needs a fence id (the #id on the ```chat block)') const body = { text } if (fence) body.fence = fence if (!tokenFor(base)) body.author = await guestAuthor() const posted = await req(base, `/docs/${id}/chat/message`, { method: 'POST', key, body }) console.log(`posted to #${posted.fence || 'chat'} as @${posted.author}`) } async function papercut() { const pos = positional() const { base, id, key } = await resolveDoc(pos[0]) const summary = pos.slice(1).join(' ') if (!summary) throw new Error('usage: mde papercut <doc> <summary> [--category api|cli|docs|handoff|other] [--operation ID]') const category = flag('--category') if (hasFlag('--category') && !category) throw new Error('--category needs a value: api, cli, docs, handoff, or other') if (category && !['api', 'cli', 'docs', 'handoff', 'other'].includes(category)) { throw new Error('--category must be api, cli, docs, handoff, or other') } const operation = flag('--operation') if (hasFlag('--operation') && !operation) throw new Error('--operation needs an operation ID') const body = { summary, client: 'mde' } if (category) body.category = category if (operation) body.operation = operation const { id: reportId } = await req(base, `/docs/${id}/feedback`, { method: 'POST', key, body }) console.log(`papercut reported (${reportId})`) } async function reply() { const pos = positional() const { base, id, key } = await resolveDoc(pos[0]) const cid = pos[1] const body = pos.slice(2).join(' ') if (!cid || !body) throw new Error('usage: mde reply <doc> <commentId> <text>') const payload = { body } if (!tokenFor(base)) payload.author = await guestAuthor() await req(base, `/docs/${id}/comments/${cid}/replies`, { method: 'POST', key, body: payload }) console.log('replied') } async function resolveItem() { const pos = positional() const { base, id, key } = await resolveDoc(pos[0]) if (!pos[1]) throw new Error('usage: mde resolve <doc> <askId> [-m note]') const { asks } = await req(base, `/docs/${id}/asks?state=all`, { key }) if (asks.some(ask => ask.id === pos[1])) { const body = { ...(flag('-m') ? { note: flag('-m') } : {}) } if (!tokenFor(base)) body.author = await guestAuthor() await req(base, `/docs/${id}/asks/${encodeURIComponent(pos[1])}/resolve`, { method: 'POST', key, body }) console.log('ASK resolved') return } if (flag('-m')) throw new Error(`no ASK ${pos[1]} on this document`) await req(base, `/docs/${id}/comments/${pos[1]}/resolve`, { method: 'POST', key, body: {} }) console.log('resolved comment') } async function suggest() { const { base, id, key } = await resolveDoc(positional()[0]) const replace = flag('--replace') const del = flag('--delete') const append = flag('--append') let body if (replace != null) { const text = flag('--with') if (text == null) throw new Error('usage: mde suggest <doc> --replace <old> --with <new>') body = { type: 'replace', find: replace, text } } else if (del != null) { body = { type: 'delete', find: del } } else if (append != null) { body = { type: 'insert', at: 'end', text: append } } else { throw new Error('pass --replace <old> --with <new>, --delete <text>, or --append <text>') } if (!tokenFor(base)) body.author = await guestAuthor() const { ids } = await req(base, `/docs/${id}/suggestions`, { method: 'POST', key, body }) if (body.type === 'replace' && ids.length === 2) { console.log(`suggested replace (delete ${ids[0]} + insert ${ids[1]}) — accept both to apply`) } else { console.log(`suggested (${ids.join(', ')}) — pending review`) } } async function decide(action) { const pos = positional() const { base, id, key } = await resolveDoc(pos[0]) const sids = pos.slice(1) if (!sids.length) throw new Error(`usage: mde ${action} <doc> <suggestionId...>`) // try every id and say what happened to each — stopping at the first bad // one silently skipped the rest and hid that earlier ones were applied const failed = [] for (const sid of sids) { try { await req(base, `/docs/${id}/suggestions/${encodeURIComponent(sid)}`, { method: 'POST', key, body: { action } }) if (sids.length > 1) console.log(`${action}ed ${sid}`) } catch (err) { failed.push(sid) console.error(`failed ${sid}: ${err.message}`) } } if (!failed.length) { console.log(`${action}ed ${sids.length > 1 ? sids.length + ' suggestions' : sids[0]}`) return } const ok = sids.length - failed.length throw new Error(`${ok} of ${sids.length} ${action}ed; failed: ${failed.join(', ')}`) } async function events() { const { base, id, key } = await resolveDoc(positional()[0]) const since = intFlag('--since') ?? 0 const { events } = await req(base, `/docs/${id}/events?since=${since}`, { key }) if (flag('--json')) { console.log(JSON.stringify(events, null, 2)); return } if (!events.length) { console.log('no activity yet'); return } for (const e of events) console.log(fmtEvent(e)) } // --skip-self: which events are this agent's own echoes. Only identities that // are THIS agent count: the account's agent name (token writes attribute to // it), or --author / MDE_AUTHOR (share-key writes). The bare account username // is NOT enough — the human's own browser edits carry it too, and hiding those // would drop exactly what the agent is watching for. With a username-only // account, only writes the server marks as token-made are skipped. async function selfFilter(base) { const names = new Set() let username = null if (TOKEN && tokenFor(base)) { try { const user = (await req(base, '/me')).user if (user.agentName) names.add(user.agentName) else username = user.username } catch { /* fall back to the author name */ } } if (!names.size && authorOf()) names.add(authorOf()) if (!names.size && !username) throw new Error('--skip-self needs an identity: log in, or set --author / MDE_AUTHOR') if (username) { console.error(`note: --skip-self: this account has no agent name, so its writes show as “${username}”, the same as your own browser edits. Only API-token writes are skipped (the server marks them); browser edits still show. On an older server without that marking they aren't skipped — set an agent name (account menu, or POST /api/me/agent-name) to be sure.`) } const labeled = (label) => typeof label === 'string' && [...names].some(n => label.startsWith(`${n}:`)) return (e) => { if (names.has(e.actor)) return true // "<name>: …" version labels: the multi-agent convention for share-key // writes (which all show actor Guest). Versions only — a comment or reply // that STARTS with your name is usually addressed to you. if ((e.type === 'content.replaced' || e.type === 'version.saved') && labeled(e.payload?.label)) return true if (username && e.actor === username) { const via = e.via || e.source || e.payload?.via if (via === 'token') return true if (e.type === 'chat.message' && e.payload?.kind === 'agent') return true // server-stamped token write } return false } } // One watch loop for both feeds: a single doc (`/docs/ID/events`, cursor = the // doc's event seq) and everything you can see (`/events`, cursor = the opaque // string the server hands back; --all / --folder). async function watch() { const all = hasFlag('--all') || hasFlag('--folder') const json = !!flag('--json') const exec = flag('--exec') const useCursor = !!flag('--cursor') const isSelfFor = async (base) => (flag('--skip-self') ? selfFilter(base) : null) const cursors = useCursor ? loadVersionsFile(CURSORS_PATH) : null let base, key, slot, poll, since, label, isSelf, envDoc if (all) { if (flag('--daemon') || flag('--daemon-off')) throw new Error('--daemon works per doc — run `mde watch --all` under your own supervisor (it resumes from --cursor)') // --folder takes a folder id (with your token) or a folder share URL if (hasFlag('--folder') && typeof flag('--folder') !== 'string') throw new Error('--folder needs a folder id or a folder share link') const folderRef = flag('--folder') || '' let folderId = folderRef base = needBase() key = null if (/^https?:\/\//.test(folderRef)) { const u = new URL(folderRef) base = u.origin folderId = (u.pathname.match(/\/folders\/([^/]+)/) || [])[1] || '' key = u.searchParams.get('key') if (!folderId) throw new Error('that is not a folder link — expected https://HOST/folders/FOLDER_ID?key=…') } const qs = (folderId && !key ? `&folder=${encodeURIComponent(folderId)}` : '') slot = folderId ? `@folder:${folderId}` : '@all' label = folderId ? `folder ${folderId}` : 'every doc you can see' envDoc = '' isSelf = await isSelfFor(base) // --since here is a ms timestamp or a cursor a previous run printed const sinceFlag = flag('--since') // precedence: explicit --since, then the persisted cursor, then "now" — // pinned to a real cursor up front (as the per-doc mode pins a seq), so // nothing that happens between "watching…" and the first poll is skipped since = typeof sinceFlag === 'string' ? sinceFlag : cursors?.[slot] ?? (await req(base, `/events?since=latest${qs}`, { key })).cursor poll = async (cur) => { const r = await req(base, `/events?since=${encodeURIComponent(cur)}&wait=25${qs}`, { key }) return { events: r.events, next: r.cursor, more: !!r.capped } } } else { const docRef = positional()[0] const doc = await resolveDoc(docRef) if (flag('--daemon')) return daemonInstall(docRef, doc.id) if (flag('--daemon-off')) return daemonRemove(doc.id) ;({ base, key } = doc) const id = doc.id slot = id label = id envDoc = id isSelf = await isSelfFor(base) const sinceFlag = intFlag('--since') // precedence: explicit --since, then the persisted cursor, then "now" since = sinceFlag != null ? sinceFlag : cursors?.[id] ?? (await req(base, `/docs/${id}/events?since=1000000000`, { key })).latest poll = async (cur) => { const r = await req(base, `/docs/${id}/events?since=${cur}&wait=25`, { key }) // a typing signal ends a hold early with no events — that is a real answer return { events: r.events, next: r.latest, more: !!r.capped || !!(r.typing && r.typing.length) } } } let backoff = 1000 let idle = 0 // consecutive empty answers that came back at once console.error(all ? `watching ${label}` : `watching ${label} from #${since}`) // stderr: --json stdout stays machine-clean process.on('SIGINT', () => process.exit(0)) for (;;) { try { const asked = Date.now() const { events, next, more } = await poll(since) const batch = isSelf ? events.filter(e => !isSelf(e)) : events for (const e of batch) console.log(json ? JSON.stringify(e) : (e.doc ? `[${e.doc}] ` : '') + fmtEvent(e)) // one --exec run per poll batch (a burst that lands together is handled // once) — consumers should re-read doc STATE, not replay events, so a // double wake is harmless if (exec && batch.length) { await new Promise(res => { // pipe + forward, NOT stdio 'inherit': an inheriting child writes // straight into this process's stdout, and when that's a pipe nobody // drains (an agent harness holding a watcher in a background // terminal), the child blocks on its first full-pipe write and never // exits — freezing this whole loop at a stuck cursor while events // keep flowing. Forwarding through our own async (memory-buffered) // streams keeps the child's exit independent of stdout consumers. const p = spawn(exec, { shell: true, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, MDE_DOC: envDoc, MDE_LATEST: String(next), MDE_EVENTS: JSON.stringify(batch), ...(all ? { MDE_DOCS: [...new Set(batch.map(e => e.doc))].join(',') } : {}), } }) // --json stdout is a machine stream (one event per line): the // handler's chatter goes to stderr so it can't corrupt it p.stdout.on('data', d => (json ? process.stderr : process.stdout).write(d)) p.stderr.on('data', d => process.stderr.write(d)) p.on('close', res); p.on('error', res) }) } if (next != null) since = next if (useCursor) saveInVersionsFile(CURSORS_PATH, slot, since) backoff = 1000 // A long-poll with nothing to say should take the whole wait. An empty // answer that comes straight back means the server did not hold us (an // older server over its hold limit, or holding switched off) — pace // ourselves instead of asking again at once: 1s, 2s, 4s … up to 30s. if (!events.length && !more && Date.now() - asked < 1000) { idle++ await new Promise(r => setTimeout(r, Math.min(30_000, 1000 * 2 ** (idle - 1)))) } else idle = 0 } catch (err) { // Permanent errors (bad auth, gone, bad request) won't fix themselves — // fail fast instead of hammering the server forever. Only network blips, // rate limits (429) and server errors (5xx) are worth retrying. if (err.status && err.status !== 429 && err.status < 500) { console.error(`watch: ${err.message}`) process.exit(1) } // a 429 says when to come back (Retry-After) — never sooner than that const pause = Math.max(backoff, err.retryAfter ? err.retryAfter * 1000 : 0) console.error(`watch: ${err.message} — retrying in ${Math.round(pause / 1000)}s`) await new Promise(r => setTimeout(r, pause)) backoff = Math.min(backoff * 2, 30_000) } } } // ---------- watch --daemon: a supervised watcher, so "durable wake-up" ---------- // doesn't require every agent to hand-roll launchd plists. Installs a // launchd (macOS) or systemd --user (Linux) service running // `mde watch <doc> --json --cursor [--skip-self] [--exec …]`, logging to // CONFIG_DIR/logs/. The cursor file means restarts resume, not replay. function daemonPieces(id) { const label = `com.mde.watch.${id}` return { label, log: path.join(CONFIG_DIR, 'logs', `watch-${id}.log`), plist: path.join(os.homedir(), 'Library', 'LaunchAgents', `${label}.plist`), unit: path.join(os.homedir(), '.config', 'systemd', 'user', `mde-watch-${id}.service`), } } function daemonTokenPath(id) { return path.join(CONFIG_DIR, 'daemon', `watch-${id}.token`) } function sh(cmd, cmdArgs) { return new Promise(res => { const p = spawn(cmd, cmdArgs, { stdio: ['ignore', 'ignore', 'ignore'] }) p.on('close', code => res(code === 0)); p.on('error', () => res(false)) }) } async function daemonInstall(docRef, id) { const { label, log, plist, unit } = daemonPieces(id) const script = fs.realpathSync(process.argv[1]) // the service must see the same mde environment as the installing shell — // launchd/systemd services inherit none of it const envVars = {} for (const k of ['MDE_CONFIG_DIR', 'MDE_URL', 'MDE_AUTHOR']) { if (process.env[k]) envVars[k] = process.env[k] } // The token never goes into the plist/unit (readable config, backups, // `launchctl print`). The service reads it at runtime: from the 0600 // config.json when that holds it, else from a 0600 token file we write. const tokenFile = daemonTokenPath(id) if (process.env.MDE_TOKEN && process.env.MDE_TOKEN !== cfg.token) { fs.mkdirSync(path.dirname(tokenFile), { recursive: true, mode: 0o700 }) fs.writeFileSync(tokenFile, process.env.MDE_TOKEN + '\n', { mode: 0o600 }) fs.chmodSync(tokenFile, 0o600) envVars.MDE_TOKEN_FILE = tokenFile } else if (process.env.MDE_TOKEN_FILE) { envVars.MDE_TOKEN_FILE = path.resolve(process.env.MDE_TOKEN_FILE) } if (fs.existsSync(CONFIG_PATH)) { try { fs.chmodSync(CONFIG_PATH, 0o600) } catch {} } const watchArgs = [script, 'watch', docRef, '--json', '--cursor'] if (flag('--skip-self')) watchArgs.push('--skip-self') if (flag('--author')) watchArgs.push('--author', flag('--author')) if (flag('--exec')) watchArgs.push('--exec', flag('--exec')) fs.mkdirSync(path.dirname(log), { recursive: true }) if (process.platform === 'darwin') { const xml = (s) => s.replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c])) fs.mkdirSync(path.dirname(plist), { recursive: true }) fs.writeFileSync(plist, `<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"><dict> <key>Label</key><string>${label}</string> <key>ProgramArguments</key><array>${[process.execPath, ...watchArgs].map(a => `<string>${xml(a)}</string>`).join('')}</array> ${Object.keys(envVars).length ? `<key>EnvironmentVariables</key><dict>${Object.entries(envVars).map(([k, v]) => `<key>${xml(k)}</key><string>${xml(v)}</string>`).join('')}</dict>` : ''} <key>RunAtLoad</key><true/> <key>KeepAlive</key><true/> <key>StandardOutPath</key><string>${xml(log)}</string> <key>StandardErrorPath</key><string>${xml(log)}</string> </dict></plist>\n`, { mode: 0o600 }) fs.chmodSync(plist, 0o600) await sh('launchctl', ['unload', plist]) // re-install must not double-load if (!await sh('launchctl', ['load', '-w', plist])) throw new Error(`launchctl load failed — try: launchctl load -w ${plist}`) console.log(`watching in the background (launchd: ${label})\n log: ${log}\n stop: mde watch ${docRef} --daemon-off`) } else if (process.platform === 'linux') { fs.mkdirSync(path.dirname(unit), { recursive: true }) fs.writeFileSync(unit, `[Unit] Description=mde watch ${id} [Service] ExecStart=${[process.execPath, ...watchArgs].map(a => a.includes(' ') ? JSON.stringify(a) : a).join(' ')} ${Object.entries(envVars).map(([k, v]) => `Environment=${JSON.stringify(`${k}=${v}`)}`).join('\n')} Restart=always RestartSec=5 StandardOutput=append:${log} StandardError=append:${log} [Install] WantedBy=default.target\n`, { mode: 0o600 }) fs.chmodSync(unit, 0o600) if (!await sh('systemctl', ['--user', 'daemon-reload']) || !await sh('systemctl', ['--user', 'enable', '--now', path.basename(unit)])) { throw new Error(`systemctl --user failed — try: systemctl --user enable --now ${path.basename(unit)}`) } console.log(`watching in the background (systemd: ${path.basename(unit)})\n log: ${log}\n stop: mde watch ${docRef} --daemon-off`) } else { throw new Error(`--daemon supports macOS (launchd) and Linux (systemd --user) — on ${process.platform}, run mde watch under your own supervisor`) } } async function daemonRemove(id) { const { label, plist, unit } = daemonPieces(id) let removed = false if (process.platform === 'darwin' && fs.existsSync(plist)) { await sh('launchctl', ['unload', '-w', plist]) fs.unlinkSync(plist); removed = true } else if (process.platform === 'linux' && fs.existsSync(unit)) { await sh('systemctl', ['--user', 'disable', '--now', path.basename(unit)]) fs.unlinkSync(unit); await sh('systemctl', ['--user', 'daemon-reload']); removed = true } try { fs.unlinkSync(daemonTokenPath(id)) } catch { /* none written */ } console.log(removed ? `stopped and removed the background watcher (${label})` : 'no background watcher installed for this doc') } function fmtEvent(e) { const p = e.payload || {} const clip = (s) => { s = String(s).replace(/\s+/g, ' ').trim(); return s.length > 80 ? s.slice(0, 80) + '…' : s } // the payload's most human-readable field, per event family const detail = p.text != null ? `“${clip(p.text)}”` // chat.message : p.title != null ? `“${clip(p.title)}”${p.column ? ` → ${p.column}` : ''}${p.to ? ` → ${p.to}` : ''}` // card.added / card.moved : p.card != null ? `${p.card}${p.to ? ` → ${p.to}` : ''}` : p.label != null ? JSON.stringify(p.label) : p.suggestion || p.suggestions?.join(', ') || p.comment || (p.message != null ? clip(p.message) : '') // widget.error || (p.size != null ? `${p.size} bytes` : '') || (p.revision != null ? `rev ${p.revision}` : '') return `${new Date(e.ts).toLocaleTimeString()} #${String(e.seq).padEnd(4)} ${pad(e.type, 20)} ${e.actor || ''}${detail ? ` ${detail}` : ''}` } async function history() { const { base, id, key } = await resolveDoc(positional()[0]) if (hasFlag('--show')) return printRevision(base, id, key, intFlag('--show')) const { revisions } = await req(base, `/docs/${id}/revisions`, { key }) if (flag('--json')) { console.log(JSON.stringify(revisions, null, 2)); return } if (!revisions.length) { console.log('no saved versions yet'); return } for (const r of revisions) { const label = r.label ? ` “${r.label}”` : '' console.log(`${String(r.id).padStart(6)} ${new Date(r.created_at).toLocaleString()} ${r.authors.join(', ')}${label}`) } } // A saved version, as markdown (or its metadata with --json) — the read half // of undoing a bad write from the CLI. async function printRevision(base, id, key, n) { const { revision } = await req(base, `/docs/${id}/revisions/${encodeURIComponent(n)}`, { key }) if (flag('--json')) { console.log(JSON.stringify(revision, null, 2)); return } const text = String(revision.content ?? '') process.stdout.write(text.endsWith('\n') || text === '' ? text : text + '\n') } async function revision() { const pos = positional() if (!pos[0] || !pos[1] || !/^\d+$/.test(pos[1])) throw new Error('usage: mde revision <doc> <n> (n from: mde history <doc>)') const { base, id, key } = await resolveDoc(pos[0]) return printRevision(base, id, key, Number(pos[1])) } // Restore a saved version as the doc's current content (see mde history). async function restore() { const pos = positional() if (!pos[0] || !pos[1] || !/^\d+$/.test(pos[1])) throw new Error('usage: mde restore <doc> <n> (n from: mde history <doc>)') const { base, id, key } = await resolveDoc(pos[0]) const body = { revision: Number(pos[1]) } if (!tokenFor(base)) body.author = await guestAuthor() await req(base, `/docs/${id}/restore`, { method: 'POST', key, body }) console.log(`restored ${id} to version ${pos[1]}`) }