pindeck 2.8.2 → 3.0.0-beta.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.
- package/README.md +159 -66
- package/cli.mjs +4 -4
- package/lib/agent-process.mjs +49 -0
- package/lib/agent-tools.mjs +85 -0
- package/lib/claude.mjs +34 -66
- package/lib/codex.mjs +104 -0
- package/lib/models.mjs +66 -0
- package/lib/pdf-text.jxa.js +9 -0
- package/package.json +6 -4
- package/public/editor.css +15 -0
- package/public/editor.js +95 -41
- package/public/index.html +29 -12
- package/server.mjs +227 -122
package/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# PinDeck
|
|
2
2
|
|
|
3
|
-
> **macOS
|
|
3
|
+
> **macOS-only · Version 3 beta preview** (`3.0.0-beta.1`). The beta uses npm’s `beta` channel; stable releases use `latest`. See availability and installation below.
|
|
4
4
|
|
|
5
|
-
**Pin it. Say it. Done.** PinDeck is a local editor for HTML slide decks (reveal.js): the slides on
|
|
6
|
-
the left, the slide in the middle, and a chat with Claude Code on the right. You click elements to
|
|
7
|
-
pin them, write what to change, and
|
|
5
|
+
**Pin it. Say it. Done.** PinDeck is a local macOS editor for HTML slide decks (reveal.js): the slides on
|
|
6
|
+
the left, the slide in the middle, and a chat with Claude Code or OpenAI Codex on the right. You click elements to
|
|
7
|
+
pin them, write what to change, and the selected assistant edits the HTML and CSS. The slide updates in place.
|
|
8
8
|
|
|
9
9
|

|
|
10
10
|
|
|
@@ -14,40 +14,79 @@ checked the layout; the next request, still being typed, refers to the new pins
|
|
|
14
14
|
## What it does
|
|
15
15
|
|
|
16
16
|
* **Point instead of describe.** Pins name the exact element, word or table cell; "make 2 match 1"
|
|
17
|
-
is enough.
|
|
17
|
+
is enough. The assistant also gets the measured layout of the slide, so it sees overlaps and overflow
|
|
18
18
|
without screenshots.
|
|
19
19
|
* **Edit by hand, too.** Move, resize and nudge elements with snapping, edit text in place (math stays
|
|
20
20
|
TeX), write speaker notes, add, hide, move and delete slides. One undo history covers your edits and
|
|
21
|
-
|
|
22
|
-
* **Stay on brand.** `brand.css` is locked:
|
|
21
|
+
the assistant's.
|
|
22
|
+
* **Stay on brand.** `brand.css` is locked: the assistant edits `index.html` and `theme.css` only, and checks
|
|
23
23
|
its own work with PinDeck's layout command.
|
|
24
24
|
* **Bring material in.** Attach figures, audio and PDFs to a message, or import slides from PowerPoint
|
|
25
25
|
into the deck's own layouts.
|
|
26
26
|
* **Get it out.** Present from the browser, or export a handout or presentation PDF.
|
|
27
|
-
* **Local and small.** One Node process on 127.0.0.1, no npm dependencies, your
|
|
27
|
+
* **Local and small.** One Node process on 127.0.0.1, no npm dependencies, your existing Claude Code or Codex login.
|
|
28
28
|
|
|
29
29
|
## Quick start
|
|
30
30
|
|
|
31
|
-
Requires macOS
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
**Requires macOS.** Windows and Linux are not supported in v3.
|
|
32
|
+
|
|
33
|
+
Install the public beta once `3.0.0-beta.1` has been published:
|
|
34
34
|
|
|
35
35
|
```bash
|
|
36
|
-
npx pindeck path/to/deck
|
|
36
|
+
npx pindeck@beta /path/to/deck
|
|
37
|
+
# Or select this exact beta:
|
|
38
|
+
npx pindeck@3.0.0-beta.1 /path/to/deck
|
|
39
|
+
```
|
|
37
40
|
|
|
38
|
-
|
|
39
|
-
|
|
41
|
+
For the source preview, once the `v3-beta` branch is public:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
git clone --branch v3-beta --single-branch https://github.com/ehabets/PinDeck.git PinDeck-v3
|
|
45
|
+
cd PinDeck-v3
|
|
46
|
+
node server.mjs ocean-example
|
|
40
47
|
```
|
|
41
48
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
screenshots and PDF export; PowerPoint or Keynote (macOS) for pictures in the PowerPoint import.
|
|
49
|
+
**Availability:** this checkout contains the prepared beta; its branch and npm package have not yet
|
|
50
|
+
been published. Until then, run `node server.mjs ocean-example` from your local v3 checkout.
|
|
45
51
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
52
|
+
Use `npx pindeck@latest /path/to/deck` for the stable release. The beta channel does not replace it.
|
|
53
|
+
The example deck is included in the repository; npm users supply their own deck folder.
|
|
54
|
+
Then open <http://127.0.0.1:5180/>.
|
|
55
|
+
|
|
56
|
+
Options: `--port 5180`, `--provider claude|codex`, `--model <name>` (for this run only; otherwise the model in
|
|
57
|
+
`~/.pindeck-v3/config.json`), `--help`. The deck folder defaults to the current folder and must contain
|
|
58
|
+
`index.html`. Runtime files (chat history, screenshots, backups) are kept in `~/.pindeck-v3/` (change with
|
|
49
59
|
`PINDECK_HOME`), not in the deck. `ocean-example/` is a complete example deck to start from.
|
|
50
60
|
|
|
61
|
+
## Compatibility and requirements
|
|
62
|
+
|
|
63
|
+
* **Runtime:** Node.js 20 or later, plus a modern browser. The release workflow uses Node.js 24.
|
|
64
|
+
* **Backends:** install and sign in to Claude Code (`claude auth login`) or OpenAI Codex (`codex login`).
|
|
65
|
+
The corresponding executable must be on `PATH`; `CLAUDE_BIN` and `CODEX_BIN` can override its path.
|
|
66
|
+
Only the selected backend is required for editing. Model and effort availability comes from that
|
|
67
|
+
backend and account, and may differ from the provider’s website or desktop app.
|
|
68
|
+
* **Platforms:** macOS only. Windows and Linux are not supported in v3. The npm package declares
|
|
69
|
+
`"os": ["darwin"]`, so normal npm installation rejects other operating systems. This check can be
|
|
70
|
+
overridden; bypassing it does not make those platforms supported. Source launches on unsupported
|
|
71
|
+
platforms show a local support page without opening the deck or starting a backend. Native Windows support is a
|
|
72
|
+
possible v4 feature, not part of this beta.
|
|
73
|
+
* **Screenshots and PDF export:** require a locally installed Chrome, Chromium, Edge or Brave.
|
|
74
|
+
* **PDF input through Codex:** page-text extraction and figure rendering use macOS PDFKit and
|
|
75
|
+
`osascript`; this bridge is macOS-only. Claude uses its native PDF-reading capabilities, while
|
|
76
|
+
PinDeck’s PDF figure-rendering helper still requires macOS.
|
|
77
|
+
* **PowerPoint import:** basic `.pptx` parsing uses Node.js; rendered slide pictures require
|
|
78
|
+
PowerPoint or Keynote on macOS.
|
|
79
|
+
|
|
80
|
+
The initial live editing checks used Codex CLI **0.144.1** and Claude Code **2.1.289**.
|
|
81
|
+
Further live checks on macOS passed with Codex CLI **0.162.1** and Claude Code **2.1.301**:
|
|
82
|
+
editing, Claude effort changes, Codex model changes, conversation handover, undo/redo,
|
|
83
|
+
cancellation and recovery. These are tested versions, not guaranteed minimum requirements. Backends must support the discovery and session interfaces
|
|
84
|
+
used by PinDeck. Older CLIs can expose fewer models or fail initialization.
|
|
85
|
+
|
|
86
|
+
Codex integration uses OpenAI’s experimental `dynamicTools` extension to the app-server.
|
|
87
|
+
A future Codex update may require a PinDeck adapter update. Incompatible backends report startup
|
|
88
|
+
errors; PinDeck does not fall back to broader permissions. Recheck backend compatibility when upgrading.
|
|
89
|
+
|
|
51
90
|
## Preparing a deck
|
|
52
91
|
|
|
53
92
|
PinDeck works with reveal.js decks whose slides are `<section>` elements inside `.slides`. Two
|
|
@@ -63,7 +102,7 @@ the example's `deck.js` does for the footer), count only slides without that att
|
|
|
63
102
|
numbers will jump where a slide is hidden.
|
|
64
103
|
|
|
65
104
|
Without them PinDeck runs no footer check and locks only `.slide-foot` and `.logo`. A file called
|
|
66
|
-
`brand.css` in the deck folder is locked for
|
|
105
|
+
`brand.css` in the deck folder is locked for both backends. See `ocean-example/` for a complete deck.
|
|
67
106
|
|
|
68
107
|
## Making your own template
|
|
69
108
|
|
|
@@ -74,14 +113,15 @@ cp -R ocean-example my-template
|
|
|
74
113
|
```
|
|
75
114
|
|
|
76
115
|
1. **Brand in `brand.css`:** palette, backgrounds, footer and logo positions. PinDeck locks this file
|
|
77
|
-
for
|
|
116
|
+
for both backends, so edit it by hand outside PinDeck. Replace the logos in
|
|
78
117
|
`assets/` too.
|
|
79
118
|
2. **Everything else in `theme.css`:** type sizes, spacing, layouts and components. This can be done
|
|
80
|
-
in PinDeck with
|
|
119
|
+
in PinDeck with the selected assistant.
|
|
81
120
|
3. **`index.html`:** the footer text in the `data-` attributes of `<div class="reveal">`, and the
|
|
82
121
|
`pindeck:footer-top` and `pindeck:locked` meta tags if the footer or the brand elements move.
|
|
83
122
|
`deck.js` holds the math macros, the footer markup and the audio player.
|
|
84
|
-
4. **`README.md` and `CLAUDE.md`:**
|
|
123
|
+
4. **`README.md` and `CLAUDE.md`:** describe the new classes and rules there. PinDeck includes
|
|
124
|
+
the deck’s `CLAUDE.md` in both backends’ instructions; the assistant can read `README.md` for details.
|
|
85
125
|
The example's `CLAUDE.md` calls `../cli.mjs` and `../server.mjs`; fix these paths if the template
|
|
86
126
|
does not sit next to PinDeck.
|
|
87
127
|
|
|
@@ -92,20 +132,73 @@ and imported audio copies the first `.demo-row` (with a `data-target`). Without
|
|
|
92
132
|
slide, those PowerPoint slides are imported as content slides; without a `.demo-row`, audio gets
|
|
93
133
|
plain players.
|
|
94
134
|
|
|
95
|
-
##
|
|
96
|
-
|
|
97
|
-
|
|
135
|
+
## Models, providers and configuration
|
|
136
|
+
|
|
137
|
+
Open the existing model menu in the chat header to choose a model under **Claude** or
|
|
138
|
+
**OpenAI / ChatGPT**. Choices come from the installed backends: Claude's initialization response
|
|
139
|
+
and Codex's paginated `model/list`. Hidden or explicitly unavailable entries are omitted. Effort
|
|
140
|
+
choices appear in a separate reasoning-effort selector beside the model and follow its reported
|
|
141
|
+
capabilities. Readable labels preserve the exact backend values (for example, Extra high sends `xhigh`).
|
|
142
|
+
The selector shows provider descriptions where available and distinguishes **Model default** from a
|
|
143
|
+
**PinDeck default** fallback. Models without effort controls show a disabled Effort N/A selector.
|
|
144
|
+
Effort choices are remembered per provider/model in `effortPreferences` in config.json. A model with
|
|
145
|
+
no saved choice uses its default; an unsupported saved choice falls back to the current default.
|
|
146
|
+
The picker defaults to GPT-6 models (including 6.x) and the newest discovered Fable, Opus, Sonnet,
|
|
147
|
+
and Haiku model in each family. Claude versions are compared numerically from discovered names;
|
|
148
|
+
a rolling family alias wins ties, avoiding duplicate entries. **Show all available models** reveals
|
|
149
|
+
the full discovered catalog; this display preference is remembered in the browser. A currently selected
|
|
150
|
+
older model remains visible and is never switched automatically by the filter.
|
|
151
|
+
**Refresh models** retries discovery. A missing CLI, missing sign-in, or discovery failure is shown in its provider group.
|
|
152
|
+
These are backend-reported choices, not a guarantee of current quota or entitlement; a later rejected
|
|
153
|
+
request reports its error. Older CLIs may expose fewer models than a provider's website.
|
|
154
|
+
|
|
155
|
+
Changing models within a provider resumes that provider's conversation. Changing providers opens
|
|
156
|
+
**Switch with summary**, **Start fresh**, and **Cancel**. A switch prepares the new backend before
|
|
157
|
+
replacing the old one. Chat remains visible with a divider, and deck files and undo/redo are retained.
|
|
158
|
+
Switching back to a provider creates another new session; it does not silently restore old context.
|
|
159
|
+
|
|
160
|
+
The current handover is a local, bounded digest of recent user/assistant messages and notices, not an
|
|
161
|
+
additional model-generated summary. It excludes tool output, needs no extra inference request, and is
|
|
162
|
+
sent with the first message to the new session. Earlier messages may be omitted. **Start fresh** sends
|
|
163
|
+
no conversation digest; the assistant still receives PinDeck instructions and the current deck context.
|
|
164
|
+
|
|
165
|
+
Settings are saved when changed in `~/.pindeck-v3/config.json` (or `$PINDECK_HOME/config.json`):
|
|
98
166
|
|
|
99
167
|
```json
|
|
100
168
|
{
|
|
169
|
+
"provider": "claude",
|
|
101
170
|
"model": "sonnet",
|
|
102
171
|
"effort": "medium"
|
|
103
172
|
}
|
|
104
173
|
```
|
|
105
174
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
175
|
+
Legacy configuration without `provider` is interpreted as Claude. Unsupported effort choices are
|
|
176
|
+
adjusted to the discovered model's default when starting. Provider-tagged chat/session records prevent
|
|
177
|
+
one backend from resuming the other's IDs. `--provider` and `--model` override startup settings for
|
|
178
|
+
that run; a later menu selection is saved. `CLAUDE_BIN` and `CODEX_BIN` override executable paths.
|
|
179
|
+
|
|
180
|
+
The v3 preview defaults to **`~/.pindeck-v3`**, keeping v2 settings and conversations separate. It does
|
|
181
|
+
not copy the v2 directory automatically. To run both versions at once, use another port, for example:
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
node server.mjs /path/to/a/deck-copy --port 5183 --provider codex
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
## Backend permissions and attachments
|
|
188
|
+
|
|
189
|
+
Claude uses its native Read/Edit tools with PinDeck's narrow allowlist, safe mode, and no inherited
|
|
190
|
+
MCP configuration. Codex runs with a read-only sandbox, command execution disabled, and no approval
|
|
191
|
+
grants. Inherited MCP servers and plugins are disabled in the thread configuration. Codex receives
|
|
192
|
+
three host-controlled tools: `pindeck_read`, `pindeck_edit`, and `pindeck_command`.
|
|
193
|
+
Only `index.html` and `theme.css` can be edited. Linked writable files and paths outside the allowed
|
|
194
|
+
roots are rejected. Commands are restricted to PinDeck's layout, shot, slides, pdf and pdf-figure
|
|
195
|
+
helpers, executed with argument arrays rather than a shell. `brand.css` also retains the server's
|
|
196
|
+
post-turn restoration safeguard.
|
|
197
|
+
|
|
198
|
+
Codex reads text, PNG/JPEG/WebP/GIF images and screenshots through the bridge. SVG is read as text.
|
|
199
|
+
Attached PDFs are handled page by page using PDFKit on macOS: extracted text plus a rendered page image.
|
|
200
|
+
PDF reading/rendering through this bridge is currently macOS-only. Audio is embedded by path and is
|
|
201
|
+
not transcribed. Read operations have a 12 MB limit; long text can be read in chunks.
|
|
109
202
|
|
|
110
203
|
## Keeping the ids out of git
|
|
111
204
|
|
|
@@ -117,7 +210,7 @@ node cli.mjs git-setup path/to/deck
|
|
|
117
210
|
|
|
118
211
|
This adds a line like `path/to/deck/*.html filter=pindeck-ids` to `.gitattributes` (commit it) and
|
|
119
212
|
defines the filter in the clone's git config: git then strips the ids whenever it reads the deck's
|
|
120
|
-
HTML. The ids stay in the file on disk, where PinDeck and
|
|
213
|
+
HTML. The ids stay in the file on disk, where PinDeck and the assistant need them, but commits and diffs
|
|
121
214
|
never contain them, and a change that only adds ids does not show in `git status`. Your own edits
|
|
122
215
|
show and commit as usual. Clones without the filter configured simply see the file as it is.
|
|
123
216
|
|
|
@@ -127,24 +220,24 @@ show and commit as usual. Clones without the filter configured simply see the fi
|
|
|
127
220
|
|---|---|
|
|
128
221
|
| Select, move, resize | Select mode (`V`, the default): click an element to select it, drag to move, pull a handle to resize. Elements snap to the slide edges and centre, the footer rule and other elements (pink guides); hold `Alt` to turn snapping off, `Shift` to move along one axis. Images keep their aspect ratio (`Shift` toggles). Arrow keys nudge by 1 px, `Shift`+arrows by 10 px. `Delete` removes the selected element after a confirmation; a picture goes with its caption, and files nothing else in the deck uses can be deleted with it. `⌘Z` brings both back. |
|
|
129
222
|
| Edit text | Double-click the text (or select it and press `Enter`). The element shows its source, so math appears as TeX, e.g. `\(\mu=0.5\)`. `Enter` or a click elsewhere saves, `Esc` cancels, `Shift+Enter` adds a line break, `⌘B` / `⌘I` make text bold or italic. In a bullet, `Enter` splits it at the cursor and continues in a new bullet below; `Enter` on an empty new bullet removes it and stops. Pasted text arrives as plain text. Table cells are edited one at a time. |
|
|
130
|
-
| Undo, redo | `⌘Z` / `⇧⌘Z` or the toolbar arrows. One history for your hand edits and
|
|
223
|
+
| Undo, redo | `⌘Z` / `⇧⌘Z` or the toolbar arrows. One history for your hand edits and the assistant's changes. |
|
|
131
224
|
| Pin elements | Press `P` (or the Pin button), click elements; each click gets a number. Click a marker to remove it, `Esc` to stop. |
|
|
132
|
-
| Ask
|
|
225
|
+
| Ask the assistant | Write in the sidebar and refer to pins by number: "1: align with 2, make 3 smaller". The selected element is sent too, so "make this smaller" works. `Enter` sends, `Shift+Enter` adds a new line. |
|
|
133
226
|
| Visual questions | **+** in the chat, then **Screenshot**: the current slide with its pin markers is attached when you send. |
|
|
134
227
|
| Review | Each answer ends with the changed files, a **Diff** button and an **Undo** button (most recent change). |
|
|
135
|
-
| Stop | **Stop** interrupts
|
|
228
|
+
| Stop | **Stop** interrupts the assistant; the conversation continues. **New chat** starts over. |
|
|
136
229
|
| New slide | **+ New slide** above the slide list: a **content slide** (title and bullets), a **section divider**, or a **duplicate** of the current slide. It is added after the current slide, modelled on the deck's own slides, and its title opens for typing. |
|
|
137
|
-
| Import from PowerPoint | **+ New slide** → **Import from PowerPoint…**, then choose a `.pptx` file and tick the slides you want (hidden PowerPoint slides start unticked). They are added after the current slide in this deck's own layouts, and
|
|
230
|
+
| Import from PowerPoint | **+ New slide** → **Import from PowerPoint…**, then choose a `.pptx` file and tick the slides you want (hidden PowerPoint slides start unticked). They are added after the current slide in this deck's own layouts, and the assistant can improve the complex ones; see [PowerPoint import](#powerpoint-import). `⌘Z` removes them again, with their figures. |
|
|
138
231
|
| Speaker notes | The **Notes** field under the slide shows the current slide's notes (the speaker view, `S`, shows them when presenting). Type to change them; they are saved when you click elsewhere or press `⌘Enter`, and `Esc` puts back what was there. A slide without notes gets them; emptying the field removes them. Notes with formatting other than line breaks are edited as HTML. Drag the line above the notes to give them more or less room (double-click it for the default size); collapse them with their header. |
|
|
139
232
|
| Move a slide | Drag it up or down in the slide list; an orange line shows where it will go. It moves with its notes and the comment above it, and `⌘Z` puts it back. Slides inside vertical stacks cannot be moved yet. |
|
|
140
233
|
| 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
|
-
| Model and effort |
|
|
142
|
-
| 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
|
|
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`);
|
|
234
|
+
| Model and effort | Choose a discovered Claude or OpenAI model in the chat header, then use the separate reasoning-effort selector. Provider changes offer an optional handover; same-provider changes continue the conversation. |
|
|
235
|
+
| 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 the assistant, e.g. "put this figure on a new slide after 4". Removing an attachment before sending deletes the file again. |
|
|
236
|
+
| 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-v3`); the assistant 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". |
|
|
144
237
|
| 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
238
|
| Navigate | Slide list, `←` `→`, or the toolbar. **Present** opens the deck in a new tab. |
|
|
146
239
|
|
|
147
|
-
You can also edit `index.html` or `theme.css` in any editor; the slide reloads, and
|
|
240
|
+
You can also edit `index.html` or `theme.css` in any editor; the slide reloads, and the assistant is told
|
|
148
241
|
the files changed. CSS changes are swapped in without a reload.
|
|
149
242
|
|
|
150
243
|
## PowerPoint import
|
|
@@ -179,10 +272,10 @@ The import keeps what can be carried over exactly and makes pictures of the rest
|
|
|
179
272
|
accents, braces, matrices and the bold, italic and double-struck math letters are converted.
|
|
180
273
|
* **Hidden slides** stay hidden (`data-visibility="hidden"`).
|
|
181
274
|
|
|
182
|
-
**Hand-off to
|
|
275
|
+
**Hand-off to the assistant.** Slides the import can only approximate are tagged *complex* in the dialog:
|
|
183
276
|
diagrams of many shapes or tables with shapes over them that became pictures, pictures placed inside
|
|
184
277
|
text (often equations as images), build steps on parts of a diagram, slides without a title, and
|
|
185
|
-
text that had to shrink to fit. With **Then ask
|
|
278
|
+
text that had to shrink to fit. With **Then ask the assistant to improve the complex slides** ticked, the assistant
|
|
186
279
|
gets these slides right after the import, each with a picture of the original slide and the texts of
|
|
187
280
|
its diagrams, and rebuilds diagrams as SVG, equations as TeX and tables as HTML in the deck's style.
|
|
188
281
|
The request shows in the chat like your own messages, and its changes can be undone as usual.
|
|
@@ -194,12 +287,12 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
|
|
|
194
287
|
* **Element ids.** On start, the server gives every slide and every visible element a readable
|
|
195
288
|
`data-eid` attribute (`summary`, `summary/callout`, `summary/li-2`). Only the attribute is
|
|
196
289
|
inserted; the rest of the file stays as it was. A backup of the file before the first change is
|
|
197
|
-
kept in `~/.pindeck/decks/<deck>/backups/`. Ids survive edits, so pins, layout reports and
|
|
290
|
+
kept in `~/.pindeck-v3/decks/<deck>/backups/`. Ids survive edits, so pins, layout reports and the assistant all name
|
|
198
291
|
the same element.
|
|
199
292
|
* **Hand edits.** A move is stored as the CSS `translate` property and a resize as `width`/`height`,
|
|
200
293
|
inline on the element in `index.html`. `translate` moves an element without changing the layout
|
|
201
294
|
around it, for elements in the flow and absolutely positioned ones alike. Only that one style
|
|
202
|
-
attribute is rewritten; the rest of the file stays as it was.
|
|
295
|
+
attribute is rewritten; the rest of the file stays as it was. The assistant gets a note about each hand
|
|
203
296
|
edit. Brand-locked elements (footer, logos) cannot be selected.
|
|
204
297
|
* **Text edits.** While you type, the element holds its source HTML from `index.html`, not the
|
|
205
298
|
rendered slide, so math stays TeX and nothing that KaTeX or the deck script generates ends up in
|
|
@@ -211,26 +304,25 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
|
|
|
211
304
|
locked.
|
|
212
305
|
* **Layout instead of screenshots.** With each message the editor measures the current slide and
|
|
213
306
|
every pinned slide in your browser: position and size of every element, font sizes, and issues
|
|
214
|
-
(content below the footer rule, outside the slide, overlaps, clipped text).
|
|
307
|
+
(content below the footer rule, outside the slide, overlaps, clipped text). The assistant gets this as
|
|
215
308
|
text. To check its own work it runs `node ../cli.mjs layout <slide>...` (`--issues` for the
|
|
216
309
|
issues only). Screenshots are
|
|
217
|
-
only taken when you tick the box or
|
|
218
|
-
* **One
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
or a hand edit). Undo and redo walk that history and tell Claude on the next message.
|
|
310
|
+
only taken when you tick the box or the assistant needs a visual judgement.
|
|
311
|
+
* **One selected backend.** Claude runs as a long-lived stream-json process; Codex runs as a local
|
|
312
|
+
app-server over stdio. Codex model and effort changes reuse the active thread writer and apply
|
|
313
|
+
to the next turn. Claude changes and provider switches prepare a replacement process before
|
|
314
|
+
retiring the old one. Events from replaced sessions are ignored. Provider-specific tool instructions share the same pin/layout context.
|
|
315
|
+
* **Permissions.** The selected backend edits the two slide files through the restrictions described
|
|
316
|
+
above; it does not receive unrestricted workspace write access.
|
|
317
|
+
* **Undo.** The server keeps a copy of the editable files from before each change (an AI turn
|
|
318
|
+
or a hand edit). Undo and redo walk that history and tell the assistant on the next message.
|
|
227
319
|
|
|
228
320
|
## Files
|
|
229
321
|
|
|
230
322
|
| File | Purpose |
|
|
231
323
|
|---|---|
|
|
232
|
-
| `server.mjs` | Local server (127.0.0.1 only, random token per run): UI, deck files, events,
|
|
233
|
-
| `cli.mjs` | `layout`, `shot`, `slides`, `pdf handout` / `pdf presentation` and `pdf-figure` commands for
|
|
324
|
+
| `server.mjs` | Local server (127.0.0.1 only, random token per run): UI, deck files, events, backend session, undo, CLI endpoints. |
|
|
325
|
+
| `cli.mjs` | `layout`, `shot`, `slides`, `pdf handout` / `pdf presentation` and `pdf-figure` commands for the backends (and for you), and `git-setup`. |
|
|
234
326
|
| `public/` | The editor page, and `bridge.js`, which is injected into the deck in edit mode (never saved to it). |
|
|
235
327
|
| `lib/pptx.mjs`, `lib/zip.mjs`, `lib/xml.mjs` | Reading `.pptx` files (no dependencies): slides, text, shapes and their positions, notes. |
|
|
236
328
|
| `lib/pptx-import.mjs` | Turns PowerPoint slides into slides of the deck: figures, tables, build steps, audio, layout, HTML in the deck's style. |
|
|
@@ -239,25 +331,26 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
|
|
|
239
331
|
| `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. |
|
|
240
332
|
| `lib/pdf.mjs` | PDF export: reveal.js print layout in headless Chrome, then print to PDF. |
|
|
241
333
|
| `lib/eids.mjs` | Adds the `data-eid` attributes by editing the HTML as text. |
|
|
242
|
-
| `lib/claude.mjs` |
|
|
243
|
-
| `lib/
|
|
334
|
+
| `lib/claude.mjs`, `lib/codex.mjs`, `lib/agent-process.mjs` | Backend adapters and JSON-line transport. |
|
|
335
|
+
| `lib/models.mjs`, `lib/agent-tools.mjs` | Model/settings normalization, handover, and restricted Codex tools. |
|
|
336
|
+
| `lib/system-prompt.md` | What the assistant is told about the editor. |
|
|
244
337
|
| `CONTRIBUTING.md` | How to report bugs and contribute. |
|
|
245
338
|
| `docs/` | Images for this README. |
|
|
246
339
|
| `LICENSE` | MIT. |
|
|
247
340
|
|
|
248
341
|
## Current Limitations
|
|
249
342
|
|
|
250
|
-
* Text inside inline SVG diagrams cannot be edited in place; pin it and ask
|
|
343
|
+
* Text inside inline SVG diagrams cannot be edited in place; pin it and ask the assistant.
|
|
251
344
|
* Parts of an inline SVG move with the whole SVG; inline text (bold, spans) moves with its block.
|
|
252
|
-
* 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
|
|
253
|
-
* Moves are offsets (`translate`), so the space an element came from stays reserved. Ask
|
|
345
|
+
* 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 the assistant for those.
|
|
346
|
+
* Moves are offsets (`translate`), so the space an element came from stays reserved. Ask the assistant to turn hand edits into proper CSS when a layout should really change.
|
|
254
347
|
* The undo history lives on the server and starts fresh when PinDeck restarts.
|
|
255
348
|
|
|
256
349
|
## Versions
|
|
257
350
|
|
|
258
|
-
* **
|
|
259
|
-
|
|
260
|
-
|
|
351
|
+
* **3.0.0-beta.1**: Claude and Codex adapters, discovered model/effort menu, explicit provider
|
|
352
|
+
switching with optional handover, curated model lists, a separate effort selector with per-model
|
|
353
|
+
preferences, restricted Codex tools, and separate v3 runtime state. Not published.
|
|
261
354
|
|
|
262
355
|
* **2.8.1**: fixes from a review. Speaker notes that fail to load leave the field empty and locked, instead
|
|
263
356
|
of showing the previous slide's notes and saving your edits over them. A save that fails because the
|
|
@@ -308,7 +401,7 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
|
|
|
308
401
|
* **1.11.1**: the Hide and Show icons sit at the same height.
|
|
309
402
|
* **1.11**: hide and show slides (eye in the slide list, toolbar button, `H`); hidden slides are skipped
|
|
310
403
|
when presenting and in PDFs unless the export includes them.
|
|
311
|
-
* **1.10**: model and effort without "Default" entries (default Sonnet, medium), set in `~/.pindeck/config.json`.
|
|
404
|
+
* **1.10**: model and effort without "Default" entries (default Sonnet, medium), set in `~/.pindeck-v3/config.json`.
|
|
312
405
|
* **1.9**: `node cli.mjs git-setup` keeps PinDeck's ids out of git with a clean filter; id-only changes no longer show in `git status`.
|
|
313
406
|
* **1.8**: + New slide (content slide, section divider, duplicate); model (Opus, Sonnet) and effort menu in the chat header; a + menu in the chat for files and screenshots.
|
|
314
407
|
* **1.7**: `Delete` removes the selected element after a confirmation, a picture with its caption, and optionally the files nothing else uses; undo restores elements and files. Claude reads attached images directly instead of trying to convert them; refused commands are explained in the chat.
|
|
@@ -325,7 +418,7 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
|
|
|
325
418
|
Bug reports, ideas, example decks and pull requests are welcome. [CONTRIBUTING.md](CONTRIBUTING.md)
|
|
326
419
|
explains how to report a bug, set up PinDeck for development, test a change by hand, and what a pull
|
|
327
420
|
request should contain. In short: no npm dependencies, deck files are edited only where they must
|
|
328
|
-
change,
|
|
421
|
+
change, both backends’ permissions stay narrow, and the README describes what users see.
|
|
329
422
|
|
|
330
423
|
## License
|
|
331
424
|
|
package/cli.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
/* PinDeck command line:
|
|
2
|
+
/* PinDeck command line: the assistant's self-checks (need the PinDeck server running) and git setup.
|
|
3
3
|
|
|
4
4
|
node cli.mjs layout <slide>... measured layout and issues of one or more slides
|
|
5
5
|
(add --issues for the issues only: layout 9 10 11 --issues)
|
|
@@ -27,7 +27,7 @@ const arg = rest[0];
|
|
|
27
27
|
|
|
28
28
|
/* git-setup: a git "clean" filter strips PinDeck's data-eid attributes whenever git reads the deck's
|
|
29
29
|
HTML, so ids never reach a commit and id-only changes do not show in `git status`. The ids stay in
|
|
30
|
-
the file on disk, where PinDeck and
|
|
30
|
+
the file on disk, where PinDeck and the assistant need them. The filter is plain sed, so it does not depend
|
|
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'`;
|
|
@@ -66,14 +66,14 @@ function figureQuery(args) {
|
|
|
66
66
|
let url = process.env.DECK_EDITOR_URL, token = process.env.DECK_EDITOR_TOKEN;
|
|
67
67
|
if (!url || !token) {
|
|
68
68
|
try {
|
|
69
|
-
const home = process.env.PINDECK_HOME || path.join(os.homedir(), ".pindeck");
|
|
69
|
+
const home = process.env.PINDECK_HOME || path.join(os.homedir(), ".pindeck-v3");
|
|
70
70
|
const s = JSON.parse(fs.readFileSync(path.join(home, "server.json"), "utf8"));
|
|
71
71
|
url = s.url; token = s.token;
|
|
72
72
|
} catch {}
|
|
73
73
|
}
|
|
74
74
|
if (!url) { console.error("The PinDeck server is not running (start it with: pindeck <deck-folder>)."); process.exit(1); }
|
|
75
75
|
|
|
76
|
-
/* layout and shot take several slides, so
|
|
76
|
+
/* layout and shot take several slides, so the assistant needs no shell loop or pipe (which PinDeck refuses) */
|
|
77
77
|
const queries = cmd === "pdf-figure" ? [figureQuery([...rest])]
|
|
78
78
|
: cmd === "pdf" ? [{ slide: rest[0] || "handout", ...(rest[1] === "all" ? { all: "1" } : {}) }]
|
|
79
79
|
: cmd === "slides" ? [{}] : (slides.length ? slides : ["current"]).map((slide) => ({ slide }));
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/* JSON-line subprocess transport. Requests and notifications never outlive their process. */
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import readline from 'node:readline';
|
|
4
|
+
export class AgentProcess {
|
|
5
|
+
constructor({ binary, args, cwd, env, onMessage = () => {}, onExit = () => {}, onStderr = () => {} }) {
|
|
6
|
+
Object.assign(this, { onMessage, onExit });
|
|
7
|
+
this.pending = new Map(); this.seq = 0; this.closed = false; this.stderr = '';
|
|
8
|
+
this.proc = spawn(binary, args, { cwd, env: { ...process.env, ...env }, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
9
|
+
readline.createInterface({ input: this.proc.stdout }).on('line', line => {
|
|
10
|
+
if (this.closed) return;
|
|
11
|
+
let m; try { m = JSON.parse(line); } catch { return; }
|
|
12
|
+
const id = m.type === 'control_response' ? m.response?.request_id : m.id;
|
|
13
|
+
const pending = this.pending.get(id);
|
|
14
|
+
if (pending && (m.type === 'control_response' || !m.method)) {
|
|
15
|
+
this.pending.delete(id); clearTimeout(pending.timer);
|
|
16
|
+
const err = m.error || (m.response?.subtype === 'error' && m.response.error);
|
|
17
|
+
if (err) pending.reject(new Error(err.message || String(err)));
|
|
18
|
+
else pending.resolve(m.type === 'control_response' ? m.response.response : m.result);
|
|
19
|
+
} else this.onMessage(m);
|
|
20
|
+
});
|
|
21
|
+
this.proc.stderr.on('data', d => { this.stderr = (this.stderr + d).slice(-2000); onStderr(String(d)); });
|
|
22
|
+
this.proc.stdin.on('error', e => this.end(e));
|
|
23
|
+
this.proc.on('error', e => this.end(e));
|
|
24
|
+
this.proc.on('exit', (code, signal) => this.end(new Error(`Agent stopped (${signal || code}). ${this.stderr.trim()}`)));
|
|
25
|
+
}
|
|
26
|
+
write(value) { if (this.closed) throw new Error('Agent is not running.'); this.proc.stdin.write(JSON.stringify(value) + '\n'); }
|
|
27
|
+
request(method, params, { claude = false, timeout = 30000 } = {}) {
|
|
28
|
+
const id = claude ? `pindeck-${++this.seq}` : ++this.seq;
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
const timer = setTimeout(() => { this.pending.delete(id); reject(new Error(`Agent timed out: ${method}`)); }, timeout);
|
|
31
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
32
|
+
try { this.write(claude ? { type: 'control_request', request_id: id, request: { subtype: method, ...params } } : { id, method, params }); }
|
|
33
|
+
catch (e) { clearTimeout(timer); this.pending.delete(id); reject(e); }
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
end(error) {
|
|
37
|
+
if (this.closed) return;
|
|
38
|
+
this.closed = true;
|
|
39
|
+
for (const p of this.pending.values()) { clearTimeout(p.timer); p.reject(error); }
|
|
40
|
+
this.pending.clear(); this.onExit(error);
|
|
41
|
+
}
|
|
42
|
+
stop() {
|
|
43
|
+
this.end(new Error('Agent stopped.'));
|
|
44
|
+
this.proc.stdin.destroy(); this.proc.kill('SIGTERM');
|
|
45
|
+
const proc = this.proc;
|
|
46
|
+
const timer = setTimeout(() => { if (proc.exitCode === null && proc.signalCode === null) proc.kill('SIGKILL'); }, 1500);
|
|
47
|
+
timer.unref();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/* All Codex writes pass through this host-owned bridge, never through a shell. */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
const exec = promisify(execFile);
|
|
7
|
+
const text = value => ({ type: 'inputText', text: String(value) });
|
|
8
|
+
const schema = properties => ({ type: 'object', properties, additionalProperties: false });
|
|
9
|
+
const str = { type: 'string' };
|
|
10
|
+
export const renderedImagePath = result => result.match(/^(.+\.png) \(\d+ × \d+ px\)/m)?.[1];
|
|
11
|
+
export const deckTools = [
|
|
12
|
+
{ type: 'function', name: 'pindeck_read', description: 'Read a deck text file, attached image, screenshot, or PDF page. For PDFs supply a 1-based page; returns page text and its rendered image. For text use offset to read beyond the first 120000 characters.', inputSchema: { ...schema({ path: str, page: { type: 'integer', minimum: 1 }, offset: { type: 'integer', minimum: 0 } }), required: ['path'] } },
|
|
13
|
+
{ type: 'function', name: 'pindeck_edit', description: 'Replace one exact unique text occurrence in index.html or theme.css. Read the file first. Only these two files are editable; brand.css is locked.', inputSchema: { ...schema({ path: str, oldText: str, newText: str }), required: ['path', 'oldText', 'newText'] } },
|
|
14
|
+
{ type: 'function', name: 'pindeck_command', description: 'Run a PinDeck command: layout, shot, slides, pdf, or pdf-figure. Pass individual arguments in an array, never a shell command. Use pindeck_read on returned image paths.', inputSchema: { ...schema({ command: { enum: ['layout', 'shot', 'slides', 'pdf', 'pdf-figure'] }, args: { type: 'array', items: str, maxItems: 100 } }), required: ['command', 'args'] } },
|
|
15
|
+
];
|
|
16
|
+
export function createDeckTools({ deckDir, readDirs = [], cliFile, env, pdfTextFile }) {
|
|
17
|
+
const deck = fs.realpathSync(deckDir);
|
|
18
|
+
const roots = [deck, ...readDirs.map(d => fs.realpathSync(d))];
|
|
19
|
+
const inside = (root, file) => file === root || file.startsWith(root + path.sep);
|
|
20
|
+
function checked(file, write = false) {
|
|
21
|
+
if (typeof file !== 'string' || !file || file.includes('\0')) throw new Error('Invalid file path.');
|
|
22
|
+
const full = path.resolve(deck, file);
|
|
23
|
+
if (write && !['index.html', 'theme.css'].some(n => full === path.join(deck, n))) throw new Error('Only index.html and theme.css can be edited.');
|
|
24
|
+
// Check every component, including missing-file parents, to reject symlink escapes.
|
|
25
|
+
let existing = full;
|
|
26
|
+
while (!fs.existsSync(existing)) { const parent = path.dirname(existing); if (parent === existing) throw new Error('Missing file.'); existing = parent; }
|
|
27
|
+
const real = fs.realpathSync(existing);
|
|
28
|
+
if (!roots.some(root => inside(root, real)) || (write && real !== existing)) throw new Error('File is outside the permitted directories or is a symlink.');
|
|
29
|
+
if (write && fs.existsSync(full) && (fs.lstatSync(full).isSymbolicLink() || fs.statSync(full).nlink !== 1)) throw new Error('Linked files cannot be edited.');
|
|
30
|
+
return full;
|
|
31
|
+
}
|
|
32
|
+
async function command(name, args, signal) {
|
|
33
|
+
if (!['layout', 'shot', 'slides', 'pdf', 'pdf-figure'].includes(name) || !Array.isArray(args) || args.length > 100 || args.some(a => typeof a !== 'string' || a.length > 4096 || a.includes('\0'))) throw new Error('Invalid PinDeck command.');
|
|
34
|
+
if (name === 'pdf-figure') {
|
|
35
|
+
checked(args[0]);
|
|
36
|
+
// Host renderer can create figures: disallow symlinked output directories too.
|
|
37
|
+
const figures = path.join(deck, 'figures');
|
|
38
|
+
if (fs.existsSync(figures) && fs.realpathSync(figures) !== figures) throw new Error('Linked figures directory is not allowed.');
|
|
39
|
+
for (const a of args.slice(2).filter(a => /^figures\//.test(a))) {
|
|
40
|
+
const full = path.resolve(deck, a);
|
|
41
|
+
if (!inside(figures, full) || (fs.existsSync(full) && (fs.lstatSync(full).isSymbolicLink() || fs.statSync(full).nlink !== 1))) throw new Error('Linked or invalid output file.');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const { stdout } = await exec(process.execPath, [cliFile, name, ...args], { cwd: deck, env: { ...process.env, ...env }, signal, timeout: 120000, maxBuffer: 2 * 1024 * 1024 });
|
|
45
|
+
return stdout;
|
|
46
|
+
}
|
|
47
|
+
async function read(file, page, offset, signal) {
|
|
48
|
+
const full = checked(file), ext = path.extname(full).toLowerCase();
|
|
49
|
+
if (ext === '.pdf') {
|
|
50
|
+
if (!Number.isInteger(page) || page < 1) throw new Error('Specify a PDF page number, starting at 1.');
|
|
51
|
+
const result = await command('pdf-figure', [full, String(page)], signal);
|
|
52
|
+
const png = renderedImagePath(result);
|
|
53
|
+
if (!png) throw new Error('PDF renderer did not return an image path.');
|
|
54
|
+
let words = '';
|
|
55
|
+
if (process.platform === 'darwin' && pdfTextFile) {
|
|
56
|
+
try { words = (await exec('/usr/bin/osascript', ['-l', 'JavaScript', pdfTextFile, full, String(page)], { signal, timeout: 30000, maxBuffer: 1024 * 1024 })).stdout; }
|
|
57
|
+
catch (e) { if (signal?.aborted) throw e; words = 'Text extraction unavailable; inspect the page image.'; }
|
|
58
|
+
}
|
|
59
|
+
return [text(words), ...await read(png, undefined, 0, signal)];
|
|
60
|
+
}
|
|
61
|
+
if (!fs.statSync(full).isFile()) throw new Error('Read a file, not a directory.');
|
|
62
|
+
if (fs.statSync(full).size > 12 * 1024 * 1024) throw new Error('File exceeds the 12 MB read limit.');
|
|
63
|
+
const data = fs.readFileSync(full);
|
|
64
|
+
const mime = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif' }[ext];
|
|
65
|
+
if (mime) return [{ type: 'inputImage', imageUrl: `data:${mime};base64,${data.toString('base64')}` }];
|
|
66
|
+
if (!['.html', '.css', '.md', '.txt', '.svg', '.json', '.js', '.mjs', '.csv'].includes(ext)) throw new Error('Read supports text, PNG/JPEG/WebP/GIF images, and rendered PDF pages. Audio is embedded by path, not transcribed.');
|
|
67
|
+
const start = Number.isInteger(offset) && offset >= 0 ? offset : 0;
|
|
68
|
+
return [text(data.toString('utf8').slice(start, start + 120000))];
|
|
69
|
+
}
|
|
70
|
+
return async (name, args, signal) => {
|
|
71
|
+
signal?.throwIfAborted();
|
|
72
|
+
if (name === 'pindeck_read') return read(args.path, args.page, args.offset, signal);
|
|
73
|
+
if (name === 'pindeck_command') return [text(await command(args.command, args.args, signal))];
|
|
74
|
+
if (name === 'pindeck_edit') {
|
|
75
|
+
const full = checked(args.path, true);
|
|
76
|
+
if (typeof args.oldText !== 'string' || !args.oldText || typeof args.newText !== 'string' || args.newText.length > 2e6) throw new Error('Supply nonempty oldText and replacement text.');
|
|
77
|
+
const old = fs.readFileSync(full, 'utf8');
|
|
78
|
+
if (old.indexOf(args.oldText) < 0 || old.indexOf(args.oldText) !== old.lastIndexOf(args.oldText)) throw new Error('oldText must match exactly once. Re-read the file.');
|
|
79
|
+
signal?.throwIfAborted();
|
|
80
|
+
fs.writeFileSync(full, old.replace(args.oldText, () => args.newText));
|
|
81
|
+
return [text('Edited ' + path.basename(full))];
|
|
82
|
+
}
|
|
83
|
+
throw new Error('Unknown PinDeck tool.');
|
|
84
|
+
};
|
|
85
|
+
}
|