pindeck 2.8.2 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # PinDeck
2
2
 
3
- > **macOS only.** Windows and Linux are not supported.
3
+ > **macOS, and Linux as a beta.** On Linux, PinDeck runs as a headless server that you use from your
4
+ > Mac's browser through an SSH tunnel (see [Running on a Linux server](#running-on-a-linux-server-beta)).
5
+ > Windows is not supported.
4
6
 
5
7
  **Pin it. Say it. Done.** PinDeck is a local editor for HTML slide decks (reveal.js): the slides on
6
8
  the left, the slide in the middle, and a chat with Claude Code on the right. You click elements to
@@ -19,8 +21,8 @@ checked the layout; the next request, still being typed, refers to the new pins
19
21
  * **Edit by hand, too.** Move, resize and nudge elements with snapping, edit text in place (math stays
20
22
  TeX), write speaker notes, add, hide, move and delete slides. One undo history covers your edits and
21
23
  Claude's.
22
- * **Stay on brand.** `brand.css` is locked: Claude edits `index.html` and `theme.css` only, and checks
23
- its own work with PinDeck's layout command.
24
+ * **Stay on brand.** `brand.css` is locked: Claude edits the deck, its styles and other files of the deck
25
+ folder, never `brand.css` or `vendor/`, and checks its own work with PinDeck's layout command.
24
26
  * **Bring material in.** Attach figures, audio and PDFs to a message, or import slides from PowerPoint
25
27
  into the deck's own layouts.
26
28
  * **Get it out.** Present from the browser, or export a handout or presentation PDF.
@@ -28,9 +30,10 @@ checked the layout; the next request, still being typed, refers to the new pins
28
30
 
29
31
  ## Quick start
30
32
 
31
- Requires macOS. The npm package declares `"os": ["darwin"]`, so normal npm installation
32
- rejects other operating systems. Overriding that check does not make those platforms supported. Running the server from a cloned
33
- repository on another OS shows a local support notice instead of opening the deck or starting Claude.
33
+ Runs on macOS, and on Linux as a beta. The npm package declares `"os": ["darwin", "linux"]`, so normal
34
+ npm installation rejects other operating systems. Overriding that check does not make those platforms
35
+ supported. Running the server from a cloned repository on another OS shows a local support notice
36
+ instead of opening the deck or starting Claude.
34
37
 
35
38
  ```bash
36
39
  npx pindeck path/to/deck # from npm
@@ -39,18 +42,24 @@ git clone https://github.com/ehabets/PinDeck.git && cd PinDeck
39
42
  node server.mjs ocean-example # or from source, with the example deck in this repository
40
43
  ```
41
44
 
42
- Then open <http://127.0.0.1:5180/>. Requirements: Node 20 or later and the `claude` CLI, signed in
45
+ Then open the login link PinDeck prints (`PinDeck: http://127.0.0.1:5180/login?code=...`). It works
46
+ once and logs this browser in; for a new one, run `pindeck login` (or `node server.mjs login`). Requirements: Node 20 or later and the `claude` CLI, signed in
43
47
  with your normal Claude account. Chrome (or Chromium, Edge, Brave) is optional and is used for
44
- screenshots and PDF export; PowerPoint or Keynote (macOS) for pictures in the PowerPoint import.
45
-
46
- Options: `--port 5180`, `--model <name>` (for this run only; otherwise the model in
47
- `~/.pindeck/config.json`), `--help`. The deck folder defaults to the current folder and must contain
48
- `index.html`. Runtime files (chat history, screenshots, backups) are kept in `~/.pindeck/` (change with
48
+ screenshots and PDF export; PowerPoint or Keynote (macOS) for pictures in the PowerPoint import; on
49
+ Linux, Poppler, LibreOffice and ffmpeg (see [Running on a Linux server](#running-on-a-linux-server-beta)).
50
+
51
+ The deck is a file (`pindeck path/to/talk.html`) or a folder, which opens its `index.html`; without
52
+ one, the current folder. Several decks can share a folder, each with its own chat and undo history;
53
+ they share the folder's `theme.css` and `brand.css`, and Claude edits only the deck that is open.
54
+ PinDeck adds element ids to the deck file when it opens it (see below), so try it on a copy of a deck
55
+ first. Options: `--port 5180`, `--model <name>` (for this run only; otherwise the model in
56
+ `~/.pindeck/config.json`), `--help`. Runtime files (chat history, screenshots, backups) are kept in `~/.pindeck/` (change with
49
57
  `PINDECK_HOME`), not in the deck. `ocean-example/` is a complete example deck to start from.
50
58
 
51
59
  ## Preparing a deck
52
60
 
53
- PinDeck works with reveal.js decks whose slides are `<section>` elements inside `.slides`. Two
61
+ PinDeck works with reveal.js decks whose slides are `<section>` elements inside `.slides`, and with other
62
+ decks that name their slides (see [Decks made without reveal.js](#decks-made-without-revealjs)). Two
54
63
  optional `<meta>` tags in the deck's `<head>` tell it about the design:
55
64
 
56
65
  ```html
@@ -65,6 +74,40 @@ numbers will jump where a slide is hidden.
65
74
  Without them PinDeck runs no footer check and locks only `.slide-foot` and `.logo`. A file called
66
75
  `brand.css` in the deck folder is locked for Claude. See `ocean-example/` for a complete deck.
67
76
 
77
+ ### Decks made without reveal.js
78
+
79
+ A deck with a script of its own, or made with a framework whose slides are elements of their own
80
+ (Shower, deck.js, a hand-written deck), can be edited too once it names its slides in its `<head>`:
81
+
82
+ ```html
83
+ <meta name="pindeck:slides" content="main.deck > section.slide"> <!-- a CSS selector for the slides -->
84
+ <meta name="pindeck:size" content="1440x1080"> <!-- their size in px -->
85
+ ```
86
+
87
+ The selector may use tag names, `#id`, `.class`, `[attr]` and `[attr="value"]`, joined by spaces or `>`,
88
+ and a list of them separated by commas. In the editor, PinDeck shows the slides itself, one at a time and
89
+ scaled to the window, and switches off the deck's own scaling, slide keys and inert or hidden marks while
90
+ you edit; that changes nothing in the deck file. Everything in [Working with it](#working-with-it) works as
91
+ with a reveal.js deck, with these differences:
92
+
93
+ * Build steps (`.fragment`) belong to reveal.js: other decks show every element at once.
94
+ * Hidden slides (`data-visibility="hidden"`) are left out of PinDeck's PDF exports; whether presenting
95
+ skips them is up to the deck's own script.
96
+ * Speaker notes are an `<aside class="notes">` in the slide, as in reveal.js. PinDeck hides them in the
97
+ editor; the deck's CSS should hide them too (`aside.notes { display: none }`).
98
+ * **Present** opens the deck at `#<slide id>`, or `#<number>` (counting from 1) for a slide without an id.
99
+ Present may open on the first slide if the deck does not go to slides by their address.
100
+ * **+ New slide** models a new slide on the deck's own (see [Making your own template](#making-your-own-template)):
101
+ a content slide gets placeholder bullets in a `.slide-body`; without one it has only its title. A new or
102
+ duplicated slide does not take the original's `id`.
103
+
104
+ Both tags are needed: PinDeck opens the slides at the given size for screenshots and PDF pages. A missing
105
+ size, or a selector that finds no slides or that PinDeck cannot read, is reported before anything changes.
106
+ While PinDeck runs, it reads the tags again for every screenshot, PDF and Claude session; the editor reads
107
+ them when the slide reloads. If a tag stops working meanwhile, the editor shows the same message and Claude
108
+ is told. A Claude session already running keeps the slide selector it started with; **New chat** starts one
109
+ with the new selector.
110
+
68
111
  ## Making your own template
69
112
 
70
113
  PinDeck has no template generator; a template is simply a deck folder. Start from a copy of the example:
@@ -82,8 +125,22 @@ cp -R ocean-example my-template
82
125
  `pindeck:footer-top` and `pindeck:locked` meta tags if the footer or the brand elements move.
83
126
  `deck.js` holds the math macros, the footer markup and the audio player.
84
127
  4. **`README.md` and `CLAUDE.md`:** Claude follows both, so describe the new classes and rules there.
85
- The example's `CLAUDE.md` calls `../cli.mjs` and `../server.mjs`; fix these paths if the template
86
- does not sit next to PinDeck.
128
+ Refer to PinDeck's commands as "the PinDeck CLI command given in the system prompt", as the
129
+ example's `CLAUDE.md` does; PinDeck tells Claude the right path wherever the deck is. An older
130
+ `node ../cli.mjs` in a deck's `CLAUDE.md` is replaced with that command.
131
+
132
+ **Instructions for Claude.** PinDeck gives Claude the deck folder's own `CLAUDE.md` and `AGENTS.md`.
133
+ It does not load `CLAUDE.md` files from parent folders, as Claude Code does in a terminal: those are
134
+ usually written for coding (run the tests, commit), which PinDeck's Claude cannot do; they would change
135
+ with where the deck happens to sit; and on a shared server a parent folder may be writable by others.
136
+ To share rules between decks, say so in the deck's own file, in one of two ways:
137
+
138
+ - **Import:** `Follow @../shared/STYLE.md for wording.` includes that file's text. Paths are relative to
139
+ the file they are in (or absolute, or `~/...`); imported files may import others, up to 5 levels,
140
+ each file once, 200 KB in all. Only text files are imported; an e-mail address, an `@name` that is
141
+ not a file, and anything in code are left as written.
142
+ - **Pointer:** `The style rules are in ../shared/STYLE.md; read them first.` Claude reads the file when
143
+ it needs it.
87
144
 
88
145
  **New slide** and the PowerPoint import model new slides on the deck's own: keep a slide with class
89
146
  `title-slide`, one with `section-slide`, and a content slide with `.slide-head` (`h2`, optionally a
@@ -107,6 +164,72 @@ plain players.
107
164
  chat header's menu is written to this file, so it always shows what PinDeck uses. Edit it while
108
165
  PinDeck is stopped, or simply use the menu.
109
166
 
167
+ ### Files outside the deck folder
168
+
169
+ A deck can use files from a folder around it, such as a style sheet or figures shared by several
170
+ talks: start PinDeck with `--asset-root <folder>` (repeatable), or list the folders in config.json as
171
+ `"assetRoots": ["~/talks"]`. When the folder contains the deck, PinDeck serves the deck from inside
172
+ it, so relative paths such as `../shared/style.css` work, in the editor, in screenshots and in PDF
173
+ exports. Claude may read those files but does not edit them.
174
+
175
+ Symlinks: PinDeck serves a file only if, with symlinks followed, it lies inside the deck folder or an
176
+ asset root. A symlink in the deck that leads anywhere else is refused (403). To use a symlinked
177
+ library folder, add the folder it points to as an asset root; it does not have to contain the deck.
178
+
179
+ ## Running on a Linux server (beta)
180
+
181
+ PinDeck can run on a Linux machine where the deck lives, with the editor in your Mac's browser. The
182
+ deck, PinDeck, Claude Code and the deck's git clone all stay on the server; the browser only shows the
183
+ editor. The server listens on 127.0.0.1 there, as on a Mac, and an SSH tunnel brings that port to your Mac.
184
+
185
+ On the server you need Node 20 or later (22 or 24 recommended), git if the deck is in a repository, and
186
+ the `claude` CLI installed and logged in under your account there, if you want the chat. Without it,
187
+ editing by hand still works. Then, in one SSH session on the server:
188
+
189
+ ```bash
190
+ node server.mjs path/to/deck --port 5180
191
+ ```
192
+
193
+ and on your Mac, in another terminal (`myserver` is your usual SSH host):
194
+
195
+ ```bash
196
+ ssh -N -T -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -L 127.0.0.1:5180:127.0.0.1:5180 myserver
197
+ ```
198
+
199
+ Open the login link PinDeck printed on the server in the Mac's browser. The local port may differ from
200
+ the server's: with `-L 127.0.0.1:8080:127.0.0.1:5180`, change 5180 in the link to 8080. PinDeck accepts any
201
+ port, as long as the address is `127.0.0.1` or `localhost`. If the tunnel drops, a message you have already sent keeps running on the
202
+ server. Restart the tunnel and the editor reconnects and shows the files as they are then; it never sends a
203
+ message twice. If PinDeck restarts, the editor asks you to log in again: run `node server.mjs login
204
+ path/to/deck` on the server for a new link.
205
+
206
+ Other users of a shared server cannot open the editor or read the deck through PinDeck: the token that
207
+ protects it is only in your browser's cookie and in PinDeck's own files in `~/.pindeck` (readable only by
208
+ you), never in a page or on a command line.
209
+
210
+ What works on Linux in this beta: editing by hand, speaker notes, slides, pins, the chat with Claude,
211
+ Undo/Redo, live reload when files change on the server, layout checks, attaching figures, audio and
212
+ PDFs, `git-setup` and the PowerPoint import. Some features use programs on the server, and are on when
213
+ they are installed (on Debian or Ubuntu: `sudo apt install poppler-utils libreoffice-impress ffmpeg`):
214
+
215
+ | Feature | Needs |
216
+ |---|---|
217
+ | Screenshots, layout checks without an open editor, PDF export | Chrome or Chromium (`PINDECK_LINUX_CHROME=0` turns these off) |
218
+ | Pictures of PDF pages (`pdf-figure`) | Poppler (`pdftoppm`, `pdfinfo`) |
219
+ | Pictures of PowerPoint slides in the import (thumbnails, diagrams) | LibreOffice (`soffice`) and Poppler |
220
+ | Waveforms of audio other than WAV in the import | ffmpeg |
221
+
222
+ Without them the editor says what is missing (hover over "Server: Linux (beta)"), and Claude is told
223
+ which commands are not available. LibreOffice may draw some slides a little differently from PowerPoint;
224
+ the pictures are references and figures, the slides themselves are rebuilt from the file. Chromium from snap may not be
225
+ allowed to use hidden folders in your home folder such as `~/.pindeck`; if screenshots fail with it, set
226
+ `PINDECK_HOME` to a folder that is not hidden, or install Chrome or Chromium from a package.
227
+
228
+ Run one PinDeck per deck. While PinDeck or Claude is working on a deck, do not edit it with other tools
229
+ or switch git branches at the same time. Stop first, or wait until Claude has finished. PinDeck notices
230
+ changes made by other programs and reloads, and it refuses Undo, Redo or an edit that would overwrite
231
+ them. Its checks cannot rule out every race with a program that writes at the same moment.
232
+
110
233
  ## Keeping the ids out of git
111
234
 
112
235
  PinDeck adds `data-eid` attributes to the deck it opens. If the deck is in git, run once per clone:
@@ -140,7 +263,7 @@ show and commit as usual. Clones without the filter configured simply see the fi
140
263
  | Hide a slide | The eye on a slide in the list, or `H`. A hidden slide stays in the deck and in PinDeck (greyed out, with a banner) but is skipped when presenting and in PDFs; **Export ▾** can include hidden slides. Show it the same way. |
141
264
  | Model and effort | Click **Claude ▾** in the chat header: Opus or Sonnet, and the effort (how long Claude thinks: low to max). The conversation continues after a change. The choice is saved in `~/.pindeck/config.json` (default: Sonnet, medium). |
142
265
  | Attach figures and audio | **+** in the chat, then **File…**, drag and drop onto the chat or a slide, or paste an image. Files are saved in `<deck>/figures/` straight away; audio also gets a waveform SVG. Then ask Claude, e.g. "put this figure on a new slide after 4". Removing an attachment before sending deletes the file again. |
143
- | Attach a PDF | The same way: a figure or table to take over, or a paper or report with more details. PDFs stay outside the deck (in `~/.pindeck`); Claude reads them, rebuilds tables as HTML, and renders a figure (a page or a cropped part of one) into `<deck>/figures/` as PNG with `cli.mjs pdf-figure` (macOS). E.g. "put Fig. 3 of this paper on slide 7" or "add the results of Table 2 to slide 9". |
266
+ | Attach a PDF | The same way: a figure or table to take over, or a paper or report with more details. PDFs stay outside the deck (in `~/.pindeck`); Claude reads them, rebuilds tables as HTML, and renders a figure (a page or a cropped part of one) into `<deck>/figures/` as PNG with `cli.mjs pdf-figure` (macOS, or Linux with Poppler). E.g. "put Fig. 3 of this paper on slide 7" or "add the results of Table 2 to slide 9". |
144
267
  | Export PDF | **Export ▾** in the toolbar. **Handout PDF**: one page per slide, build steps shown complete. **Presentation PDF**: one page per click, so a slide with two build steps gives three pages. Files go to `<deck>/export/`. Needs Chrome or Chromium. |
145
268
  | Navigate | Slide list, `←` `→`, or the toolbar. **Present** opens the deck in a new tab. |
146
269
 
@@ -212,34 +335,45 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
212
335
  * **Layout instead of screenshots.** With each message the editor measures the current slide and
213
336
  every pinned slide in your browser: position and size of every element, font sizes, and issues
214
337
  (content below the footer rule, outside the slide, overlaps, clipped text). Claude gets this as
215
- text. To check its own work it runs `node ../cli.mjs layout <slide>...` (`--issues` for the
338
+ text. To check its own work it runs the PinDeck CLI's `layout <slide>...` (`--issues` for the
216
339
  issues only). Screenshots are
217
340
  only taken when you tick the box or Claude needs a visual judgement.
218
341
  * **One Claude Code process.** The server starts `claude -p` in stream-json mode once and keeps it
219
342
  running, so there is no start-up delay per message. It runs in the deck folder under your Claude
220
343
  login, with `--append-system-prompt` from `lib/system-prompt.md` and the deck's `CLAUDE.md`.
221
- * **Permissions.** Claude may read files, edit `index.html` and `theme.css`, and run the editor's
222
- CLI. Nothing else, and in particular no edits to `brand.css`. Claude Code loads no settings files
344
+ * **Permissions.** Claude may read files, edit and create files in the deck folder, and run the editor's
345
+ CLI. Nothing else: no other commands, and no edits to `brand.css`, `vendor/`, `.git/` or, for a named
346
+ deck file, the other decks in its folder. Claude Code loads no settings files
223
347
  and no MCP servers, so no allow rule in your own, the deck's or a parent folder's settings widens this. If `brand.css` changes during a
224
348
  turn anyway, the server restores it and says so in the chat.
225
- * **Undo.** The server keeps a copy of the editable files from before each change (a Claude turn
226
- or a hand edit). Undo and redo walk that history and tell Claude on the next message.
349
+ * **Undo.** The server keeps a copy of every file a change touched, from before and after it (a Claude
350
+ turn, which may change, create or delete several files of the deck, or a hand edit). Files over 2 MB
351
+ are not copied: the chat says when a turn changed one, and Undo leaves it as it is. Hidden files,
352
+ `vendor/` and `export/` are not part of undo. Undo and redo walk that history and tell Claude on the next message. They first check
353
+ that the files are still as that change left them; if something else changed them since, they do
354
+ nothing and say which file differs, rather than overwrite that work.
355
+ * **Saving.** Every file PinDeck writes into the deck is written to a temporary file next to it and then
356
+ renamed into place, so other programs never see half a file. Each change from the editor carries the
357
+ revision of the files it was made on; if the files have changed since, the change is refused, the
358
+ slide reloads, and edited text can be copied from the message to type in again.
227
359
 
228
360
  ## Files
229
361
 
230
362
  | File | Purpose |
231
363
  |---|---|
232
- | `server.mjs` | Local server (127.0.0.1 only, random token per run): UI, deck files, events, Claude session, undo, CLI endpoints. |
364
+ | `server.mjs` | Local server (127.0.0.1 only, random token per run, one-time login links): UI, deck files, events, Claude session, undo, CLI endpoints, `login`. |
233
365
  | `cli.mjs` | `layout`, `shot`, `slides`, `pdf handout` / `pdf presentation` and `pdf-figure` commands for Claude (and for you), and `git-setup`. |
234
366
  | `public/` | The editor page, and `bridge.js`, which is injected into the deck in edit mode (never saved to it). |
235
367
  | `lib/pptx.mjs`, `lib/zip.mjs`, `lib/xml.mjs` | Reading `.pptx` files (no dependencies): slides, text, shapes and their positions, notes. |
236
368
  | `lib/pptx-import.mjs` | Turns PowerPoint slides into slides of the deck: figures, tables, build steps, audio, layout, HTML in the deck's style. |
237
369
  | `lib/waveform.mjs` | Waveform SVGs for imported audio. |
238
370
  | `lib/omml.mjs` | PowerPoint equations (Office Math) to TeX. |
239
- | `lib/pptx-render.mjs`, `lib/pdf-render.jxa.js` | Slide pictures: PowerPoint or Keynote saves a PDF, macOS PDFKit renders pages and regions of it. |
371
+ | `lib/pptx-render.mjs`, `lib/pdf-render.jxa.js` | Slide and PDF pictures: PowerPoint or Keynote (macOS) or LibreOffice (Linux) saves a PDF; macOS PDFKit or Poppler renders pages and regions of it. |
240
372
  | `lib/pdf.mjs` | PDF export: reveal.js print layout in headless Chrome, then print to PDF. |
241
373
  | `lib/eids.mjs` | Adds the `data-eid` attributes by editing the HTML as text. |
242
374
  | `lib/claude.mjs` | The long-running Claude Code process. |
375
+ | `lib/capabilities.mjs` | What this server can do on this system (macOS, Linux), for the editor, the API, the CLI and Claude. |
376
+ | `lib/deck-files.mjs` | Atomic writes and the revision of the deck's files. |
243
377
  | `lib/system-prompt.md` | What Claude is told about the editor. |
244
378
  | `CONTRIBUTING.md` | How to report bugs and contribute. |
245
379
  | `docs/` | Images for this README. |
@@ -247,14 +381,68 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
247
381
 
248
382
  ## Current Limitations
249
383
 
384
+ * reveal.js decks, and decks that name their slides with `<meta name="pindeck:slides">` (see [Decks made
385
+ without reveal.js](#decks-made-without-revealjs)). Any other deck made with another framework (Marp, Slidev,
386
+ impress.js, remark, Spectacle, Shower, deck.js, bespoke.js) is recognised and refused with its name before
387
+ PinDeck changes anything.
250
388
  * Text inside inline SVG diagrams cannot be edited in place; pin it and ask Claude.
251
389
  * Parts of an inline SVG move with the whole SVG; inline text (bold, spans) moves with its block.
252
390
  * No indenting or outdenting of bullets with `Tab` yet, and `Backspace` at the start of a bullet does not merge it with the one above; ask Claude for those.
253
391
  * Moves are offsets (`translate`), so the space an element came from stays reserved. Ask Claude to turn hand edits into proper CSS when a layout should really change.
254
392
  * The undo history lives on the server and starts fresh when PinDeck restarts.
393
+ * One writer at a time: PinDeck is not made for editing the same deck from two editors or tools at once.
394
+ * Linux (beta): pictures of PDF pages and PowerPoint slides, and waveforms of non-WAV audio, need
395
+ Poppler, LibreOffice and ffmpeg on the server (see [Running on a Linux server](#running-on-a-linux-server-beta)).
255
396
 
256
397
  ## Versions
257
398
 
399
+ * **2.10**: decks with their own file names and shared files, decks made without reveal.js, and Claude
400
+ working on the whole deck.
401
+ - A deck file by name: `pindeck path/to/talk.html` (a folder still opens its `index.html`). Several
402
+ decks can share a folder, each with its own chat and undo history.
403
+ - Claude may edit and create other files of the deck (scripts, style sheets, notes), never
404
+ `brand.css`, `vendor/` or other decks in the folder. One Undo takes back everything a turn changed,
405
+ created or deleted; files over 2 MB are not copied, and the chat says so.
406
+ - Files outside the deck folder: `--asset-root <folder>` (or `"assetRoots"` in config.json) serves
407
+ the deck from inside that folder, so paths such as `../shared/style.css` work in the editor,
408
+ screenshots and PDF exports. A symlink that leads out of the deck and its asset roots is refused.
409
+ - Shared instructions: the deck's `CLAUDE.md` and `AGENTS.md` may import other files with `@path`.
410
+ `CLAUDE.md` files in parent folders are still not loaded, on purpose (see the README).
411
+ - Decks made without reveal.js (a script of their own, Shower, deck.js, a hand-written deck): a deck
412
+ that names its slides with `<meta name="pindeck:slides">` (a CSS selector) and their size with
413
+ `<meta name="pindeck:size">` is edited like a reveal.js deck, with screenshots at the slide size and
414
+ one PDF page per slide (contributed by Zeyu Xu). Decks made with other slide frameworks (Marp,
415
+ Slidev, impress.js, remark and others) without these tags are recognised and refused with their
416
+ name, before PinDeck changes anything; the message says how to add the tags.
417
+ - Screenshots fill the picture: headless Chrome lays the page out shorter than its window, which
418
+ left the slide letterboxed in some Chrome versions (reported by Zeyu Xu).
419
+ - Linux: pictures of PDF pages with Poppler, the PowerPoint import with slide pictures from
420
+ LibreOffice, and waveforms of non-WAV audio with ffmpeg, each when installed.
421
+
422
+ * **2.9**: Linux as a beta, and safer editing.
423
+ - Linux (beta): PinDeck runs as a headless server, used from a Mac's browser through an SSH tunnel,
424
+ with Claude Code running on the server. The tunnel may use another local port than the server.
425
+ Screenshots, layout checks without an open editor and PDF export work when Chrome or Chromium is
426
+ found (`PINDECK_LINUX_CHROME=0` turns them off). Features a system cannot provide are switched off,
427
+ with the reason shown in the editor. Changes made to the deck by other programs are seen every
428
+ time, also after a file was replaced by a rename.
429
+ - Security: the editor page no longer contains the token, and the deck needs it too, so other users
430
+ of the same machine cannot read either. PinDeck prints a one-time login link at start
431
+ (`pindeck login` prints a new one), which keeps the token in an HttpOnly cookie named after the
432
+ server's port; headless Chrome logs in the same way, so the token is never on a command line. After
433
+ a restart, log in again with a new link.
434
+ - Saving is atomic; edits made on files that changed meanwhile are refused, and Undo/Redo no longer
435
+ overwrite changes made outside PinDeck.
436
+ - Audio and video play in Safari: deck files are sent with their length and byte ranges, and ogg,
437
+ opus, m4a, aac, flac and mov get their media types.
438
+ - Claude gets the right CLI command for decks outside the PinDeck folder; the example's `CLAUDE.md`
439
+ no longer names a path. The deck's `AGENTS.md` reaches Claude along with its `CLAUDE.md`.
440
+ - macOS: pictures of PDF pages (`pdf-figure`) and of PowerPoint slides have a white page instead of a
441
+ transparent one, so dark text no longer disappears on a dark slide.
442
+ - Screenshots and PDF exports are no longer now and then reported as failed although Chrome wrote them.
443
+ - `git-setup` works for decks behind symlinks or with spaces in their path. The tests run on Linux
444
+ and macOS in CI, including a real Chrome.
445
+
258
446
  * **2.8.2**: declares macOS-only support in npm metadata and the README to prevent accidental
259
447
  installation on unsupported operating systems. Source launches on other platforms show a support
260
448
  notice without opening the deck.
package/cli.mjs CHANGED
@@ -13,7 +13,7 @@
13
13
  from the top left), as PNG: without a name in the screenshots
14
14
  folder to look at, with one saved into the deck
15
15
 
16
- node cli.mjs git-setup [deck] keep PinDeck's data-eid ids out of git (no server needed)
16
+ node cli.mjs git-setup [deck] keep PinDeck's data-eid ids out of git (no server needed; a folder or deck file)
17
17
 
18
18
  <slide> is a slide id (data-eid), a slide number (1-based) or "current". */
19
19
 
@@ -31,14 +31,17 @@ const arg = rest[0];
31
31
  on where PinDeck is installed; git config is per clone, so run this once in each clone. */
32
32
  if (cmd === "git-setup") {
33
33
  const FILTER = `sed 's/ data-eid="[^"]*"//g'`;
34
- const deck = path.resolve(arg || ".");
34
+ let deck = path.resolve(arg || ".");
35
+ try { if (!fs.statSync(deck).isDirectory()) deck = path.dirname(deck); } catch {} // a deck file: its folder
36
+ try { deck = fs.realpathSync(deck); } catch {} // git reports the real path; a symlinked path would not match
35
37
  const git = (...a) => execFileSync("git", ["-C", deck, ...a], { encoding: "utf8" }).trim();
36
38
  let root;
37
39
  try { root = git("rev-parse", "--show-toplevel"); }
38
40
  catch { console.error(`${deck} is not inside a git repository.`); process.exit(1); }
39
41
  git("config", "filter.pindeck-ids.clean", FILTER);
40
42
  const rel = path.relative(root, deck).split(path.sep).join("/");
41
- const pattern = `${rel ? rel + "/" : ""}*.html`;
43
+ // .gitattributes patterns end at whitespace: spaces in folder names are written as [[:space:]]
44
+ const pattern = `${rel ? rel.replace(/\s/g, "[[:space:]]") + "/" : ""}*.html`;
42
45
  const attrFile = path.join(root, ".gitattributes");
43
46
  const attrs = fs.existsSync(attrFile) ? fs.readFileSync(attrFile, "utf8") : "";
44
47
  const line = `${pattern} filter=pindeck-ids`;
@@ -0,0 +1,118 @@
1
+ /* What this server can do on this machine, decided once on the backend and used everywhere: the editor
2
+ (status), the API (refusals before any side effect), the CLI (its error text) and Claude's system prompt.
3
+
4
+ macOS: everything, as before. Linux (beta): editing, the chat, live reload and the PowerPoint import;
5
+ pictures of PDF pages with Poppler, pictures of PowerPoint slides with LibreOffice and Poppler, and
6
+ waveforms of non-WAV audio with ffmpeg, each when installed, else off with a reason. Chrome features (screenshots, headless
7
+ layout, PDF export) work on Linux as on macOS when Chrome or Chromium is found; PINDECK_LINUX_CHROME=0
8
+ turns them off. Each entry is { available, reason }: reason says why not, in plain words. */
9
+
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+
13
+ export const SUPPORTED_PLATFORMS = ["darwin", "linux"];
14
+ export const isSupportedPlatform = (platform) => SUPPORTED_PLATFORMS.includes(platform);
15
+ export const platformLabel = (platform) => ({ darwin: "macOS", linux: "Linux (beta)" }[platform] || platform);
16
+
17
+ const CHROME_PATHS = {
18
+ darwin: ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
19
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
20
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
21
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"],
22
+ linux: ["/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser",
23
+ "/snap/bin/chromium"],
24
+ };
25
+ const OFFICE_APPS = ["/Applications/Microsoft PowerPoint.app", "/Applications/Keynote.app"];
26
+
27
+ const isExecutable = (p) => {
28
+ try { return fs.statSync(p).isFile() && (fs.accessSync(p, fs.constants.X_OK), true); } catch { return false; }
29
+ };
30
+
31
+ /* An executable by path, or by name on PATH. Never runs it. Returns { path } or { path: null, reason }. */
32
+ export function findExecutable(name, { env = process.env } = {}) {
33
+ if (name.includes("/")) {
34
+ if (isExecutable(name)) return { path: name };
35
+ return { path: null, reason: fs.existsSync(name) ? `${name} is not an executable file` : `${name} does not exist` };
36
+ }
37
+ for (const dir of String(env.PATH || "").split(path.delimiter).filter(Boolean)) {
38
+ const p = path.join(dir, name);
39
+ if (isExecutable(p)) return { path: p };
40
+ }
41
+ return { path: null, reason: `no "${name}" command on the PATH` };
42
+ }
43
+
44
+ const on = () => ({ available: true, reason: "" });
45
+ const off = (reason) => ({ available: false, reason });
46
+
47
+ export function detectCapabilities({ platform = process.platform, env = process.env } = {}) {
48
+ const mac = platform === "darwin";
49
+ const caps = { platform, label: platformLabel(platform) };
50
+
51
+ const claudeBin = env.CLAUDE_BIN || "claude";
52
+ const claude = findExecutable(claudeBin, { env });
53
+ caps.claude = claude.path ? on() : off(`Claude Code is not available on the server (${claude.reason}). ` +
54
+ `Install and log in to the claude CLI there, or set CLAUDE_BIN. Editing by hand still works.`);
55
+
56
+ let chrome = null, chromeWhy = "";
57
+ if (env.CHROME_BIN) {
58
+ const c = findExecutable(env.CHROME_BIN, { env });
59
+ chrome = c.path;
60
+ chromeWhy = c.reason ? `CHROME_BIN: ${c.reason}` : "";
61
+ } else {
62
+ chrome = (CHROME_PATHS[platform] || []).find(isExecutable) || null;
63
+ chromeWhy = chrome ? "" : "no Chrome or Chromium found (set CHROME_BIN)";
64
+ }
65
+ if (chrome && platform === "linux" && env.PINDECK_LINUX_CHROME === "0") {
66
+ chrome = null;
67
+ chromeWhy = "Chrome features are turned off (PINDECK_LINUX_CHROME=0)";
68
+ }
69
+ const chromeCap = chrome ? on() : off(chromeWhy);
70
+ caps.chromePath = chrome;
71
+ caps.screenshot = chromeCap;
72
+ caps.headlessLayout = chromeCap;
73
+ caps.pdfExport = chromeCap;
74
+
75
+ // Linux: Poppler renders PDF pages, LibreOffice exports PowerPoint slides to PDF, ffmpeg decodes audio
76
+ const has = (name) => !!findExecutable(name, { env }).path;
77
+ const poppler = has("pdftoppm") && has("pdfinfo"), soffice = has("soffice") || has("libreoffice");
78
+ caps.pdfPages = mac || poppler ? on()
79
+ : off("Pictures of PDF pages need Poppler on the server (pdftoppm and pdfinfo; install poppler-utils). Attached PDFs can still be read.");
80
+ caps.pptxImport = on(); // the import itself reads the .pptx file: no other program needed
81
+ caps.pptxPictures = mac ? (OFFICE_APPS.some((a) => fs.existsSync(a)) ? on() : off("Pictures of PowerPoint slides need PowerPoint or Keynote."))
82
+ : soffice && poppler ? on()
83
+ : off("Pictures of PowerPoint slides need LibreOffice (soffice) and Poppler (pdftoppm, pdfinfo) on the server.");
84
+ caps.audioConvert = mac || has("ffmpeg") ? on() : off("Waveforms for audio other than WAV need ffmpeg on the server.");
85
+ return caps;
86
+ }
87
+
88
+ /* The capabilities for the editor and the status API: no executable paths. */
89
+ export const publicCapabilities = ({ chromePath, ...caps }) => caps;
90
+
91
+ /* The lines of Claude's system prompt about what this server cannot do. Empty on a full macOS setup. */
92
+ export function capabilityPrompt(caps, cli) {
93
+ const lines = [];
94
+ if (!caps.screenshot.available)
95
+ lines.push(`- \`${cli} shot\` does not work here: ${caps.screenshot.reason}. Use \`${cli} layout\` to check slides; ` +
96
+ `it works while the editor is open in the user's browser.`);
97
+ if (!caps.pdfExport.available) lines.push(`- \`${cli} pdf\` does not work here: ${caps.pdfExport.reason}.`);
98
+ if (!caps.pdfPages.available) lines.push(`- \`${cli} pdf-figure\` does not work here: ${caps.pdfPages.reason}`);
99
+ if (!lines.length) return "";
100
+ return `\n\n## This PinDeck server (${caps.label})\nSome commands are not available, so do not try them:\n${lines.join("\n")}\n`;
101
+ }
102
+
103
+ /* --port / PORT: an integer 0–65535 (0: any free port, for tests). null if invalid. */
104
+ export function parsePort(value) {
105
+ const s = String(value ?? "").trim();
106
+ if (!/^\d+$/.test(s)) return null;
107
+ const n = Number(s);
108
+ return n <= 65535 ? n : null;
109
+ }
110
+
111
+ /* The command Claude runs for the CLI, quoted for the shell. The Bash permission is this exact prefix, so
112
+ it must be one fixed string; double quotes as before, single quotes when the path has characters that
113
+ double quotes do not protect. */
114
+ export function cliCommand(rel) {
115
+ if (/^[\w./@%+=:,-]+$/.test(rel)) return `node ${rel}`;
116
+ if (!/["$`\\!]/.test(rel)) return `node "${rel}"`;
117
+ return `node '${rel.replaceAll("'", `'\\''`)}'`;
118
+ }
package/lib/claude.mjs CHANGED
@@ -20,7 +20,7 @@ export class ClaudeSession {
20
20
  const args = ["-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose",
21
21
  ...(typeof this.args === "function" ? this.args() : this.args), ...(resumeId ? ["--resume", resumeId] : [])];
22
22
  const proc = spawn(process.env.CLAUDE_BIN || "claude", args, {
23
- cwd: this.cwd, env: { ...process.env, ...this.env }, stdio: ["pipe", "pipe", "pipe"],
23
+ cwd: this.cwd, env: { ...process.env, ...(typeof this.env === "function" ? this.env() : this.env) }, stdio: ["pipe", "pipe", "pipe"],
24
24
  });
25
25
  this.proc = proc;
26
26
  readline.createInterface({ input: proc.stdout }).on("line", (line) => {
@@ -45,6 +45,7 @@ export class ClaudeSession {
45
45
  };
46
46
  proc.on("error", (err) => { this.onStderr?.(String(err)); if (proc.pid === undefined) end(null, null); });
47
47
  proc.on("exit", end);
48
+ proc.exited = new Promise((resolve) => proc.once("close", resolve).once("error", () => proc.pid === undefined && resolve()));
48
49
  }
49
50
 
50
51
  send(content) {
@@ -55,12 +56,16 @@ export class ClaudeSession {
55
56
  this.#write({ type: "control_request", request_id: `int-${++this.reqId}`, request: { subtype: "interrupt" } });
56
57
  }
57
58
 
59
+ /* End of input first (Claude Code finishes and exits), SIGTERM after 1.5 s, SIGKILL after 4 s.
60
+ Resolves once the process has exited, so shutdown can wait for it. */
58
61
  stop() {
59
- if (!this.proc) return;
62
+ if (!this.proc) return Promise.resolve();
60
63
  const p = this.proc;
61
64
  this.proc = null;
62
65
  p.stdin.end();
63
- setTimeout(() => p.kill("SIGTERM"), 1500);
66
+ const term = setTimeout(() => p.kill("SIGTERM"), 1500);
67
+ const kill = setTimeout(() => p.kill("SIGKILL"), 4000);
68
+ return p.exited.finally(() => { clearTimeout(term); clearTimeout(kill); });
64
69
  }
65
70
 
66
71
  #write(obj) {
@@ -0,0 +1,94 @@
1
+ /* Writing deck files safely, and telling whether they changed.
2
+
3
+ writeAtomic: the new content goes to a temporary file next to the target, is flushed, gets the target's
4
+ permissions and then replaces the target with one rename. A reader (the browser, Claude, an editor, the
5
+ watcher) sees the old file or the new one, never half of it. A symlinked target is written where the link
6
+ points, so the link stays a link. Each file is atomic on its own; a set of files is not one transaction.
7
+
8
+ revision: a hash of the deck's editable files as they are now. The editor sends the revision it shows
9
+ with each change; a different one means someone else changed the files in between (409, nothing written).
10
+ This catches stale edits from the editor. It is not a lock: a program that writes between PinDeck's check
11
+ and its write can still lose its change, so only one writer (PinDeck, Claude, or another tool) at a time. */
12
+
13
+ import crypto from "node:crypto";
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+
17
+ export function writeAtomic(file, data) {
18
+ let target = file, mode = null;
19
+ try {
20
+ if (fs.lstatSync(file).isSymbolicLink()) target = fs.realpathSync(file);
21
+ mode = fs.statSync(target).mode & 0o7777;
22
+ } catch (e) { if (e.code !== "ENOENT") throw e; }
23
+ const tmp = path.join(path.dirname(target), `.${path.basename(target)}.pindeck-${crypto.randomBytes(6).toString("hex")}.tmp`);
24
+ let fd = null;
25
+ try {
26
+ fd = fs.openSync(tmp, "wx", mode ?? 0o666); // wx: never reuse an existing file
27
+ fs.writeFileSync(fd, data);
28
+ fs.fsyncSync(fd);
29
+ if (mode != null) fs.fchmodSync(fd, mode); // the umask must not change the file's permissions
30
+ fs.closeSync(fd);
31
+ fd = null;
32
+ fs.renameSync(tmp, target);
33
+ } catch (e) {
34
+ if (fd != null) try { fs.closeSync(fd); } catch {}
35
+ try { fs.rmSync(tmp, { force: true }); } catch {}
36
+ throw new Error(`Could not save ${path.basename(file)}: ${e.message}`);
37
+ }
38
+ }
39
+
40
+ /* Same bytes? (a missing file is null) */
41
+ export function sameContent(file, data) {
42
+ let now;
43
+ try { now = fs.readFileSync(file); } catch (e) { if (e.code === "ENOENT") return data == null; throw e; }
44
+ return data != null && now.equals(Buffer.isBuffer(data) ? data : Buffer.from(data));
45
+ }
46
+
47
+ export function revision(dir, files) {
48
+ const h = crypto.createHash("sha256");
49
+ for (const f of files) {
50
+ let c = null;
51
+ try { c = fs.readFileSync(path.join(dir, f)); } catch {}
52
+ h.update(f + (c == null ? "\0-" : `\0${c.length}\0`));
53
+ if (c) h.update(c);
54
+ }
55
+ return h.digest("hex").slice(0, 16);
56
+ }
57
+
58
+ /* The deck's files for undo: every file a turn may change, with its content (bigger files: only size and
59
+ time, too large to keep a copy of). Hidden files and folders (.git), node_modules, and the vendor/ and
60
+ export/ folders are left out; so are symlinks, which point elsewhere. */
61
+ const SKIP_TOP = new Set(["vendor", "export", "node_modules"]);
62
+ export const KEEP_BYTES = 2 * 1024 * 1024;
63
+ export function scanFiles(dir, maxBytes = KEEP_BYTES) {
64
+ const files = new Map();
65
+ const walk = (rel) => {
66
+ let entries = [];
67
+ try { entries = fs.readdirSync(path.join(dir, rel), { withFileTypes: true }); } catch { return; }
68
+ for (const e of entries) {
69
+ if (e.name.startsWith(".") || e.name === "node_modules") continue;
70
+ const r = rel ? `${rel}/${e.name}` : e.name;
71
+ if (e.isDirectory()) { if (rel || !SKIP_TOP.has(e.name)) walk(r); continue; }
72
+ if (!e.isFile()) continue;
73
+ try {
74
+ const st = fs.statSync(path.join(dir, r));
75
+ files.set(r, { size: st.size, mtimeMs: st.mtimeMs, data: st.size <= maxBytes ? fs.readFileSync(path.join(dir, r)) : null });
76
+ } catch {}
77
+ }
78
+ };
79
+ walk("");
80
+ return files;
81
+ }
82
+
83
+ /* What changed between two scans: { rel: { before, after } } with the content on each side (null: the
84
+ file did not exist), and the files that changed but are too large to keep a copy of. */
85
+ export function scanChanges(before, after) {
86
+ const changed = {}, tooLarge = [];
87
+ for (const rel of new Set([...before.keys(), ...after.keys()])) {
88
+ const b = before.get(rel), a = after.get(rel);
89
+ if (b && a && b.size === a.size && (b.data && a.data ? b.data.equals(a.data) : b.mtimeMs === a.mtimeMs)) continue;
90
+ if ((b && !b.data) || (a && !a.data)) { tooLarge.push(rel); continue; }
91
+ changed[rel] = { before: b ? b.data : null, after: a ? a.data : null };
92
+ }
93
+ return { changed, tooLarge };
94
+ }