attnmd 0.3.5 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +135 -41
  2. package/bin/attn.js +167 -6
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,46 +1,111 @@
1
1
  <p align="center">
2
2
  <h1 align="center">attn</h1>
3
3
  <p align="center">
4
- A markdown viewer for people who live in the terminal.<br>
5
- One command. Native window. No Electron.
4
+ Read markdown beautifully. Review it together.<br>
5
+ Native window. End-to-end encrypted collaboration. No Electron.
6
6
  </p>
7
7
  </p>
8
8
 
9
9
  <p align="center">
10
+ <a href="#collaboration">Collaboration</a> ·
10
11
  <a href="#install">Install</a> ·
11
- <a href="https://github.com/lightsofapollo/attn/issues">Issues</a> ·
12
- <a href="#contributing">Contributing</a>
12
+ <a href="#usage">Usage</a> ·
13
+ <a href="https://github.com/lightsofapollo/attn/issues">Issues</a>
13
14
  </p>
14
15
 
15
16
  ---
16
17
 
17
18
  <p align="center">
18
- <img src="assets/hero.png" alt="attn showing markdown with checkboxes, code, and a file tree" width="720">
19
+ <picture>
20
+ <source media="(prefers-color-scheme: dark)" srcset="https://media.githubusercontent.com/media/lightsofapollo/attn/main/site/static/screenshots/collab-dark.png">
21
+ <img src="https://media.githubusercontent.com/media/lightsofapollo/attn/main/site/static/screenshots/collab-light.png" alt="attn showing a shared markdown review with comments, suggestions, and a collaborator cursor" width="860">
22
+ </picture>
19
23
  </p>
20
24
 
21
25
  ```bash
22
26
  attn .
23
27
  ```
24
28
 
25
- That's it. A native window opens with your project's markdown rendered beautifully with live reload, a file tree, tabs, and a built-in editor. No config, no browser, no 200MB runtime.
29
+ attn opens your markdown in a native desktop window with a file tree, tabs,
30
+ live reload, and a real editor. When a document needs feedback, hit Share and
31
+ send an invite link. Reviewers can comment, suggest edits, and co-edit from the
32
+ same markdown surface.
33
+
34
+ No account. No browser tab. No Electron runtime. The review relay only sees
35
+ encrypted bytes.
26
36
 
27
37
  ## Why attn?
28
38
 
29
- Most markdown previewers are either browser tabs you have to manually refresh, or Electron apps that eat your RAM for breakfast.
39
+ Markdown usually lives in your repo, but review often moves somewhere else:
40
+ screenshots, pasted docs, stale exported PDFs, or a SaaS editor with a copy of
41
+ your source.
42
+
43
+ attn keeps the source of truth local and makes review a layer over the file you
44
+ already have. You get a focused reader, a capable editor, and an encrypted
45
+ review room without changing how your project is organized.
46
+
47
+ ## Features
48
+
49
+ - **Beautiful markdown rendering** - readable line length, careful typography,
50
+ syntax-highlighted code, tables, task lists, math, and Mermaid diagrams.
51
+ - **Live reload** - save in Vim, VS Code, Zed, or any editor and the native
52
+ window updates immediately.
53
+ - **Built-in editor** - toggle a ProseMirror editor with `Cmd+E` when you want
54
+ to edit in place.
55
+ - **Interactive checkboxes** - click a `- [ ]` task and attn writes the change
56
+ back to the file.
57
+ - **Project file tree** - browse folders, lazy-load large repos, and jump to
58
+ files with fuzzy search.
59
+ - **Tabs and project switching** - keep multiple files open and move between
60
+ remembered workspaces.
61
+ - **Native media preview** - images, video, and audio open alongside markdown.
62
+ - **Light and dark themes** - paper-and-ink light mode plus a low-glare dark
63
+ mode.
64
+ - **Single-instance CLI** - run `attn` from many terminals; one daemon receives
65
+ new files as tabs.
66
+
67
+ ## Collaboration
68
+
69
+ attn's review flow is built for markdown that should stay private and local.
70
+
71
+ - **Share in one click** - use the Share button, the breadcrumb/share menu, or
72
+ `Cmd+Shift+S` to create a review room for the active markdown file.
73
+ - **Encrypted invite links** - send an `attn://review/...#key=...` link or the
74
+ generated `npx attnmd ...` command. The room key is in the invite fragment,
75
+ not on the relay.
76
+ - **Comments and suggestions** - reviewers anchor feedback to selected text;
77
+ suggestions appear beside the document with Accept/Reject actions.
78
+ - **Live cursors and co-editing** - connected reviewers show up in the document
79
+ and sidebar so you can see where people are reading or editing.
80
+ - **Hybrid transport** - attn uses direct peer-to-peer collaboration when it can
81
+ and falls back to the encrypted relay when it cannot.
82
+ - **Folder share** - share a directory to publish snapshots for every markdown
83
+ file under it; newly added markdown files are picked up by the watcher.
84
+
85
+ Owner flow:
86
+
87
+ ```bash
88
+ attn path/to/docs
89
+ # In the app: Share, or Cmd+Shift+S
90
+ ```
30
91
 
31
- attn is a **single <20MB binary**. It forks to background as a daemon, opens a native macOS window, and watches your files. Edit in Vim, VS Code, whatever — attn reloads instantly. Open another file? It joins the same window as a tab.
92
+ CLI share flow:
93
+
94
+ ```bash
95
+ attn review share path/to/docs
96
+ ```
32
97
 
33
- **What you get:**
98
+ Reviewer flow:
34
99
 
35
- - **Live reload** — save a file, see the change. No refresh button.
36
- - **Interactive checkboxes** — click a `- [ ]` task and it writes back to the file.
37
- - **Built-in editor** — hit `Cmd+E` to toggle a full ProseMirror editor with syntax highlighting, math, and mermaid diagrams.
38
- - **File tree + fuzzy search** — browse your project with `Cmd+P`. Lazy-loads folders so it's fast on huge repos.
39
- - **Tabs + projects** — open multiple files, switch between projects with `Cmd+;`. attn remembers your workspaces.
40
- - **Mermaid diagrams** — flowcharts, sequence diagrams, and more render inline from fenced code blocks.
41
- - **Media support** — images (with zoom/pan), video, and audio play natively.
42
- - **Paper & ink themes** — warm parchment light theme by default, cool dark theme with `--dark`.
43
- - **Single instance** — run `attn` from ten terminals. One daemon, one window, new tab each time.
100
+ ```bash
101
+ attn review join 'attn://review/<room-id>#key=<secret>'
102
+ ```
103
+
104
+ For headless reviewers or agents:
105
+
106
+ ```bash
107
+ attn review join 'attn://review/<room-id>#key=<secret>' --as-agent reviewer
108
+ ```
44
109
 
45
110
  ## Install
46
111
 
@@ -58,20 +123,15 @@ npx attnmd
58
123
  npm install -g attnmd && attn
59
124
  ```
60
125
 
61
- ### From source (crates.io)
62
-
63
- ```bash
64
- cargo install attn
65
- ```
66
-
67
- ### From source (git)
126
+ ### From source
68
127
 
69
128
  ```bash
70
129
  git clone https://github.com/lightsofapollo/attn.git
71
- cd attn && cargo install --path .
130
+ cd attn
131
+ cargo install --path .
72
132
  ```
73
133
 
74
- Requires Rust 1.85+. For npm installs, Node 18+ is required.
134
+ Requires Rust 1.85+. npm installs require Node 18+.
75
135
 
76
136
  ## Usage
77
137
 
@@ -80,17 +140,28 @@ attn # open current directory
80
140
  attn README.md # open a file
81
141
  attn ~/projects/myapp # open a project
82
142
  attn --dark # force dark mode
83
- attn --status todo.md # print task progress: "3/5 tasks complete"
143
+ attn --status todo.md # print task progress, e.g. "3/5 tasks complete"
84
144
  attn --json spec.md # dump document structure as JSON
85
145
  ```
86
146
 
147
+ ### Review CLI
148
+
149
+ ```bash
150
+ attn review share docs/ # share a file or folder
151
+ attn review join 'attn://review/...' # open/join through the app
152
+ attn review join 'attn://review/...' --as-agent reviewer
153
+ attn review list-agents
154
+ attn review whoami
155
+ ```
156
+
87
157
  ### Keyboard shortcuts
88
158
 
89
159
  | Shortcut | Action |
90
160
  |---|---|
91
161
  | `Cmd+P` | Fuzzy file search |
92
162
  | `Cmd+E` | Toggle editor |
93
- | `Cmd+F` | Find & replace |
163
+ | `Cmd+F` | Find and replace |
164
+ | `Cmd+Shift+S` | Share for review |
94
165
  | `Cmd+;` | Switch project |
95
166
  | `Cmd+W` | Close tab |
96
167
  | `Cmd+Tab` / `Cmd+Shift+Tab` | Navigate tabs |
@@ -100,37 +171,60 @@ attn --json spec.md # dump document structure as JSON
100
171
 
101
172
  ## How it works
102
173
 
103
- The Svelte 5 frontend is compiled by Vite and **embedded into the Rust binary** at build time. No bundled web server, no extracted assets — it's a single self-contained executable.
174
+ The Svelte 5 frontend is compiled by Vite and embedded into the Rust binary at
175
+ build time. There is no bundled web server and no extracted asset directory at
176
+ runtime.
177
+
178
+ On first launch, attn forks a daemon to the background. The daemon opens a
179
+ native window via [wry](https://github.com/tauri-apps/wry), watches your files,
180
+ and listens on a Unix socket. Later `attn` calls connect to that socket and
181
+ open new tabs in the existing window. If the binary changes after a rebuild,
182
+ the old daemon is replaced automatically.
104
183
 
105
- First launch forks a daemon to the background. The daemon opens a native window via [wry](https://github.com/tauri-apps/wry) (the same webview engine behind Tauri) and listens on a Unix socket. Subsequent `attn` calls connect to the socket and open new tabs in the existing window. If the binary changes (you rebuild), the old daemon is automatically replaced.
184
+ Collaboration is layered on top of the local file model. The owner shares a
185
+ snapshot graph and review event log over an end-to-end encrypted room. The
186
+ relay handles discovery, mailbox fallback, and presence transport, but it does
187
+ not receive plaintext markdown or comments.
106
188
 
107
189
  ```
108
190
  src/
109
191
  main.rs CLI, native window, keyboard shortcuts
110
192
  daemon.rs Unix socket IPC, single-instance daemon
111
193
  watcher.rs File system monitoring with debouncing
112
- markdown.rs Structure extraction (tasks, phases, file refs)
113
- ipc.rs Webview Rust messaging
114
- files.rs File tree, media type detection
194
+ markdown.rs Structure extraction
195
+ ipc.rs Webview <-> Rust messaging
196
+ files.rs File tree and media type detection
115
197
  projects.rs Project registry
198
+ review/ Encrypted review rooms, anchors, transport, apply flow
116
199
 
117
200
  web/src/ Svelte 5 frontend
118
- web/styles/ Tailwind CSS
201
+ relay/ Cloudflare Worker relay for encrypted review traffic
202
+ site/ Public marketing site
119
203
  ```
120
204
 
121
- ## Contributing
205
+ ## Development
122
206
 
123
207
  ```bash
124
208
  task dev # Vite HMR + Rust in foreground
125
- task dev ATTN_PATH=path/to/file.md # Open a specific file
209
+ task dev ATTN_PATH=path/to/file.md # open a specific file
210
+ ```
211
+
212
+ Builds:
213
+
214
+ ```bash
215
+ scripts/build.sh # debug build
216
+ scripts/build.sh release # release build with devtools/screenshots
217
+ scripts/build.sh prod # production release build
126
218
  ```
127
219
 
128
- The `task dev` command starts Vite for hot module replacement and runs the Rust binary in foreground mode, pointed at the Vite dev server.
220
+ Useful gates:
129
221
 
130
222
  ```bash
131
- scripts/build.sh # Debug build
132
- scripts/build.sh release # Release build
133
- scripts/build.sh prod # Production build
223
+ npm run check --prefix web
224
+ npm test --prefix web
225
+ cargo test
226
+ npm test --prefix relay
227
+ npm run build --prefix site
134
228
  ```
135
229
 
136
230
  ## License
package/bin/attn.js CHANGED
@@ -1,11 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  const {
4
+ accessSync,
4
5
  chmodSync,
6
+ constants: fsConstants,
5
7
  createWriteStream,
6
8
  existsSync,
7
9
  mkdirSync,
8
10
  readFileSync,
11
+ realpathSync,
9
12
  renameSync,
10
13
  rmSync,
11
14
  symlinkSync,
@@ -59,6 +62,18 @@ async function main() {
59
62
  const version = packageJson.version;
60
63
  const headless = isHeadlessInvocation(args);
61
64
 
65
+ // Prefer a natively-installed `attn` only when it matches this npm
66
+ // package. `npx attnmd@<version>` must not silently hand off to an older
67
+ // Homebrew/cargo/alias binary whose review protocol may be incompatible.
68
+ const nativeBinary = findNativeBinary(version);
69
+ if (nativeBinary) {
70
+ if (process.env.ATTN_DEBUG_LAUNCHER) {
71
+ console.error(`attn: using native binary ${nativeBinary}`);
72
+ }
73
+ run(nativeBinary, args);
74
+ return;
75
+ }
76
+
62
77
  let appPath = null;
63
78
  if (process.platform === "darwin") {
64
79
  try {
@@ -69,7 +84,8 @@ async function main() {
69
84
  }
70
85
 
71
86
  if (!appPath) {
72
- if (!existsSync(runtimeBinaryPath)) {
87
+ if (!isExpectedBinaryVersion(runtimeBinaryPath, version)) {
88
+ safeRemove(runtimeBinaryPath);
73
89
  await ensureRuntimeBinary(version);
74
90
  }
75
91
  if (!existsSync(runtimeBinaryPath)) {
@@ -99,13 +115,16 @@ async function main() {
99
115
  }
100
116
 
101
117
  async function resolveAppPath(version) {
102
- const globalApp = findGlobalAppInstall();
118
+ const globalApp = findGlobalAppInstall(version);
103
119
  if (globalApp) {
104
120
  return globalApp;
105
121
  }
106
122
 
107
123
  const managedVersionApp = join(managedAppsRoot, version, "attn.app");
108
- if (existsSync(managedVersionApp)) {
124
+ if (
125
+ existsSync(managedVersionApp) &&
126
+ isExpectedBinaryVersion(join(managedVersionApp, "Contents", "MacOS", "attn"), version)
127
+ ) {
109
128
  ensureCurrentAppLink(managedVersionApp);
110
129
  return managedVersionApp;
111
130
  }
@@ -137,19 +156,158 @@ async function ensureRuntimeBinary(version) {
137
156
  console.error(`attn: installed runtime binary ${runtimeBinaryPath}`);
138
157
  }
139
158
 
140
- function findGlobalAppInstall() {
159
+ function findGlobalAppInstall(version) {
141
160
  const candidates = [
142
161
  "/Applications/attn.app",
143
162
  join(userHome, "Applications", "attn.app"),
144
163
  ];
145
164
  for (const candidate of candidates) {
146
- if (existsSync(candidate)) {
165
+ const binary = join(candidate, "Contents", "MacOS", "attn");
166
+ if (existsSync(candidate) && isExpectedBinaryVersion(binary, version)) {
147
167
  return candidate;
148
168
  }
149
169
  }
150
170
  return null;
151
171
  }
152
172
 
173
+ /**
174
+ * Locate an already-installed `attn` we can hand off to instead of
175
+ * downloading. Priority:
176
+ * 1. $ATTN_BIN explicit override
177
+ * 2. `attn` on PATH (via `command -v`)
178
+ * 3. The ~/.local/bin alias this launcher installs on first run
179
+ * 4. Common manual / package-manager install dirs (Homebrew, cargo)
180
+ *
181
+ * Each candidate is validated by `isUsableNativeBinary`, which rejects
182
+ * anything inside this npm package (so a global `npm i -g attn` symlink,
183
+ * whose realpath points back at bin/attn.js, can't cause infinite
184
+ * recursion).
185
+ */
186
+ function findNativeBinary(version) {
187
+ const candidates = [];
188
+
189
+ if (process.env.ATTN_BIN) {
190
+ candidates.push(process.env.ATTN_BIN);
191
+ }
192
+
193
+ const onPath = whichAttn();
194
+ if (onPath) {
195
+ candidates.push(onPath);
196
+ }
197
+
198
+ candidates.push(
199
+ installLinkPath, // ~/.local/bin/attn — the alias we install ourselves
200
+ "/opt/homebrew/bin/attn",
201
+ "/usr/local/bin/attn",
202
+ join(userHome, ".cargo", "bin", "attn"),
203
+ );
204
+
205
+ for (const candidate of candidates) {
206
+ if (isUsableNativeBinary(candidate, version)) {
207
+ return candidate;
208
+ }
209
+ }
210
+ return null;
211
+ }
212
+
213
+ /** Resolve `attn` on PATH without throwing. Returns null when not found. */
214
+ function whichAttn() {
215
+ const probe = spawnSync(
216
+ process.platform === "win32" ? "where" : "command",
217
+ process.platform === "win32" ? ["attn"] : ["-v", "attn"],
218
+ { encoding: "utf8", shell: process.platform !== "win32" },
219
+ );
220
+ if (probe.status !== 0 || !probe.stdout) {
221
+ return null;
222
+ }
223
+ const first = probe.stdout.split("\n").map((l) => l.trim()).find(Boolean);
224
+ return first || null;
225
+ }
226
+
227
+ /**
228
+ * True when `candidate` is an executable we can safely exec as a real
229
+ * `attn` — i.e. it exists, is executable, and (after symlink resolution)
230
+ * does NOT live inside this npm package. The last check is the recursion
231
+ * guard: a global `npm i -g attn` makes `attn` on PATH a symlink to
232
+ * `<package>/bin/attn.js`; handing off to that would re-enter this script
233
+ * forever.
234
+ */
235
+ function isUsableNativeBinary(candidate, version) {
236
+ if (!candidate) return false;
237
+ try {
238
+ accessSync(candidate, fsConstants.X_OK);
239
+ } catch {
240
+ return false;
241
+ }
242
+ let resolved;
243
+ try {
244
+ resolved = realpathSync(candidate);
245
+ } catch {
246
+ return false;
247
+ }
248
+ // Recursion guard: never hand off to another copy of THIS launcher.
249
+ // Two ways that happens:
250
+ // 1. The candidate resolves to this exact file (a symlink to it).
251
+ // 2. A global `npm i -g attn` makes `attn` on PATH a symlink to some
252
+ // package's `bin/attn.js`; any `.js` is a node launcher, not a
253
+ // native binary, so reject it.
254
+ // A compiled binary under this repo's `target/` (dev builds) is fine —
255
+ // it can't re-enter this script — so we deliberately DON'T reject the
256
+ // whole package dir, only the launcher itself + the `bin/` dir + `.js`.
257
+ try {
258
+ if (resolved === realpathSync(__filename)) return false;
259
+ } catch {
260
+ /* __filename always resolves; ignore */
261
+ }
262
+ if (resolved.endsWith(".js")) return false;
263
+ const binDirReal = (() => {
264
+ try {
265
+ return realpathSync(join(packageDir, "bin"));
266
+ } catch {
267
+ return join(packageDir, "bin");
268
+ }
269
+ })();
270
+ if (resolved.startsWith(binDirReal + "/") || resolved === binDirReal) {
271
+ return false;
272
+ }
273
+ return isExpectedBinaryVersion(resolved, version);
274
+ }
275
+
276
+ function isExpectedBinaryVersion(binary, version) {
277
+ if (!binary || !existsSync(binary)) {
278
+ return false;
279
+ }
280
+ if (process.env.ATTN_SKIP_NATIVE_VERSION_CHECK === "1") {
281
+ return true;
282
+ }
283
+ const actual = readBinaryVersion(binary);
284
+ const ok = actual === version;
285
+ if (!ok && process.env.ATTN_DEBUG_LAUNCHER) {
286
+ const label = actual ? `version ${actual}` : "unknown version";
287
+ console.error(`attn: skipping ${binary} (${label}; need ${version})`);
288
+ }
289
+ return ok;
290
+ }
291
+
292
+ function readBinaryVersion(binary) {
293
+ const result = spawnSync(binary, ["--version"], {
294
+ encoding: "utf8",
295
+ timeout: 2000,
296
+ });
297
+ if (result.error || result.status !== 0) {
298
+ return null;
299
+ }
300
+ const output = `${result.stdout || ""}\n${result.stderr || ""}`;
301
+ const match = output.match(/\b\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?\b/);
302
+ return match ? match[0] : null;
303
+ }
304
+
305
+ function safeRemove(path) {
306
+ if (existsSync(path)) {
307
+ rmSync(path, { recursive: true, force: true });
308
+ }
309
+ }
310
+
153
311
  function ensureCurrentAppLink(appPath) {
154
312
  mkdirSync(managedRoot, { recursive: true });
155
313
  try {
@@ -267,6 +425,9 @@ if [ ! -e "$APP_LINK" ]; then
267
425
  fi
268
426
  BINARY="$APP_LINK/Contents/MacOS/attn"
269
427
  HEADLESS=0
428
+ if [ "\${1:-}" = "review" ]; then
429
+ HEADLESS=1
430
+ fi
270
431
  for arg in "$@"; do
271
432
  case "$arg" in
272
433
  --status|--json|--check|--info|--eval|--click|--wait-for|--query|--fill)
@@ -365,7 +526,7 @@ function resolvePathArgs(args) {
365
526
  }
366
527
 
367
528
  function isHeadlessInvocation(args) {
368
- return args.some((arg) => HEADLESS_FLAGS.has(arg));
529
+ return args[0] === "review" || args.some((arg) => HEADLESS_FLAGS.has(arg));
369
530
  }
370
531
 
371
532
  function run(cmd, args) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "attnmd",
3
- "version": "0.3.5",
3
+ "version": "0.4.1",
4
4
  "description": "A beautiful markdown viewer that launches from the CLI",
5
5
  "license": "MIT",
6
6
  "repository": {