#!/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') // 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') const VERSIONS_PATH = path.join(CONFIG_DIR, 'versions.json') // docId -> last pulled version 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 }) } function loadVersions() { try { return JSON.parse(fs.readFileSync(VERSIONS_PATH, 'utf8')) } catch { return {} } } function saveVersion(docId, version) { if (!version) return const all = loadVersions() all[docId] = version 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 const TOKEN = process.env.MDE_TOKEN || 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 --token ; token in the account menu) mde whoami show who you are Documents mde ls [--json] list your documents mde search [--folder name] search titles and markdown, ranked mde new [-f file] create a doc (markdown from -f or stdin) 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 mde push <doc> -f file replace a doc's content (also reads stdin) refuses if the doc changed since your last pull, and refuses empty input (an empty source file is usually an accident, not an intended clear) --force overwrite/clear anyway --label "msg" name the version mde open <doc> open a doc in the browser 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 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 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) mde reject <doc> <id...> reject suggestion(s) mde history <doc> [--json] list saved versions 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) --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 --skip-self drop your own echoes (agent name, username, --author name, or writes labeled "you: …") --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 Agent presence mde register <name> [--role chief] [--harness x] register this process 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. Share URLs work without login: mde cat "https://host/d/abc?key=…" Environment MDE_URL, MDE_TOKEN override the saved server/token (great for CI and agents) 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'], new: ['-f', '--file'], create: ['-f', '--file'], pull: ['-o', '--out'], push: ['-f', '--file', '--force', '--label', '--author'], 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'], events: ['--since', '--json'], watch: ['--since', '--json', '--exec', '--skip-self', '--cursor', '--daemon', '--daemon-off', '--author'], papercut: ['--category', '--operation'], feedback: ['--category', '--operation'], } const BOOL_FLAGS = new Set(['--json', '--force', '--skip-self', '--cursor', '--daemon', '--daemon-off']) main().catch(err => { console.error(`error: ${err.message}`); process.exit(1) }) async function main() { // delegated subcommand: its flags belong to the child, not this parser if (cmd === 'chief-supervisor') return chiefSupervisor() checkFlags(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 '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 '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: console.error(`unknown command: ${cmd}\n`) console.log(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 ---------- function checkFlags(allowed) { for (const a of args) { if (a.startsWith('-') && !allowed.includes(a)) { throw new Error(`unknown flag for ${cmd || 'mde'}: ${a} (see: mde help)`) } } } function flag(name) { const i = args.indexOf(name) if (i === -1) return null if (BOOL_FLAGS.has(name)) return true return args[i + 1] ?? null } function positional() { const out = [] for (let i = 0; i < args.length; i++) { if (args[i].startsWith('-')) { if (!BOOL_FLAGS.has(args[i])) i++; continue } out.push(args[i]) } return out } 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)`) } } async function req(base, p, { method = 'GET', body, key, raw } = {}) { const headers = {} 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) { const err = new Error(data.error || `${res.status} ${res.statusText}`) err.status = res.status err.data = data 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 substring. 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 (/^[A-Za-z0-9_-]{10}$/.test(ref)) return { base, id: ref, key: null } const { docs } = await req(base, '/docs') const matches = docs.filter(d => d.title.toLowerCase().includes(ref.toLowerCase())) if (matches.length === 1) return { base, id: matches[0].id, key: null } if (matches.length === 0) throw new Error(`no doc matching “${ref}”`) 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()) }) } }) } function readInput(file) { if (file) return fs.readFileSync(file, 'utf8') if (!process.stdin.isTTY) return fs.readFileSync(0, 'utf8') return 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() { let url = positional()[0] || cfg.url || process.env.MDE_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 const explicit = flag('--token') if (explicit) return finishLogin(url, explicit) const r = await fetch(`${url}/api/cli/login`, { method: 'POST' }) if (!r.ok) throw new Error(`couldn't start a login with ${url} (${r.status})`) const { code, secret, verify_url: verifyUrl, interval, expires_in: expiresIn } = await r.json() console.log(`Approve this login in your browser (code ${code}):\n\n ${verifyUrl}\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() saveConfig({ 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 (!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 (args.includes('--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' } if (TOKEN && target.origin === new URL(needBase()).origin) 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 (!TOKEN) 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 (!TOKEN) 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')) { throw new Error(`already claimed by ${err.data.claimedBy || 'another agent'} at ${err.data.claimedAt || 'an unknown time'}`) } 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') const { agent } = await req(needBase(), '/agents/register', { method: 'POST', body: { name, ...(role ? { role } : {}), ...(flag('--harness') ? { harness: flag('--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 (args.includes('--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: 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() { const title = positional()[0] || '' const content = 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') } async function pull() { const { base, id, key } = await resolveDoc(positional()[0]) const { text, version } = await req(base, `/docs/${id}/content`, { key, raw: true }) saveVersion(id, version) const out = flag('-o') || flag('--out') if (out) { fs.writeFileSync(out, text) console.log(`pulled ${id} → ${out} (version ${version})`) } else { process.stdout.write(text.endsWith('\n') || text === '' ? text : text + '\n') } } async function push() { const { base, id, key } = await resolveDoc(positional()[0]) const content = readInput(flag('-f') || flag('--file')) if (content == null) throw new Error('pass -f <file> or pipe markdown on stdin') // 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() && !flag('--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 (!TOKEN) body.author = await guestAuthor() // safe by default: if we pulled this doc before, only push on top of that version const tracked = loadVersions()[id] if (tracked && !flag('--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 if (flag('--force')) body.allowClear = true try { const { version } = await req(base, `/docs/${id}/content`, { method: 'PUT', key, body }) if (tracked) saveVersion(id, version) // keep the pull cache current console.log('pushed') } catch (err) { if (err.status !== 409 || !err.data?.currentVersion) throw err throw new Error(`the doc changed since your last pull (now at version ${err.data.currentVersion}). mde pull ${id} fetch the latest and reapply your changes mde push ${id} --force overwrite it anyway`) } } async function openDoc() { const { base, id, key } = await resolveDoc(positional()[0]) const url = `${base}/d/${id}${key ? `?key=${key}` : ''}` const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open' spawn(opener, [url], { detached: true, stdio: 'ignore' }).unref() console.log(url) } async function share() { const pos = positional() const { base, id, key } = await resolveDoc(pos[0]) const role = pos[1] || 'view' 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 if (!/^[A-Za-z0-9_-]{10}$/.test(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 = flag('--line') const payload = { body } if (line) payload.line = Number(line) if (!TOKEN) 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 (args.includes('--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 (!TOKEN) 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 (args.includes('--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 (args.includes('--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 (!TOKEN) 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 (!TOKEN) 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 (!TOKEN) 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...>`) for (const sid of sids) { await req(base, `/docs/${id}/suggestions/${sid}`, { method: 'POST', key, body: { action } }) } console.log(`${action}ed ${sids.length > 1 ? sids.length + ' suggestions' : sids[0]}`) } async function events() { const { base, id, key } = await resolveDoc(positional()[0]) const since = Number(flag('--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)) } async function watch() { const docRef = positional()[0] const { base, id, key } = await resolveDoc(docRef) if (flag('--daemon')) return daemonInstall(docRef, id) if (flag('--daemon-off')) return daemonRemove(id) const json = !!flag('--json') const exec = flag('--exec') const useCursor = !!flag('--cursor') // --skip-self: drop your own echoes — events you caused shouldn't wake your // own automation. "Yours" = your account's token agent name (falling back to // its username) or your guest author name. Writes labeled "<you>: …" use the // multi-agent convention for share-key writes, which all show actor Guest. let self = null if (flag('--skip-self')) { self = authorOf() if (TOKEN) { try { const user = (await req(base, '/me')).user self = user.agentName || user.username } catch {} } if (!self) throw new Error('--skip-self needs an identity: log in, or set --author / MDE_AUTHOR') } const cursors = useCursor ? loadVersionsFile(CURSORS_PATH) : null // precedence: explicit --since, then the persisted cursor, then "now" let since = flag('--since') != null ? Number(flag('--since')) : cursors?.[id] ?? (await req(base, `/docs/${id}/events?since=1000000000`, { key })).latest let backoff = 1000 console.error(`watching ${id} from #${since}`) // stderr: --json stdout stays machine-clean process.on('SIGINT', () => process.exit(0)) for (;;) { try { const { events, latest } = await req(base, `/docs/${id}/events?since=${since}&wait=25`, { key }) const batch = events.filter(e => !self || !(e.actor === self || (typeof e.payload?.label === 'string' && e.payload.label.startsWith(`${self}:`)))) for (const e of batch) console.log(json ? JSON.stringify(e) : 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: id, MDE_LATEST: String(latest), MDE_EVENTS: JSON.stringify(batch), } }) p.stdout.on('data', d => process.stdout.write(d)) p.stderr.on('data', d => process.stderr.write(d)) p.on('close', res); p.on('error', res) }) } since = latest if (useCursor) saveInVersionsFile(CURSORS_PATH, id, since) backoff = 1000 } 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) } console.error(`watch: ${err.message} — retrying in ${backoff / 1000}s`) await new Promise(r => setTimeout(r, backoff)) 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 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_TOKEN', 'MDE_AUTHOR']) { if (process.env[k]) envVars[k] = process.env[k] } 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`) 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`) 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 } 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]) 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}`) } }