typst-mcp 1.2.0 → 1.3.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 +113 -33
- package/dist/client/auth.js +36 -3
- package/dist/client/typst-client.js +49 -100
- package/dist/index.js +80 -40
- package/dist/protocol/edit-diff.js +74 -23
- package/dist/protocol/read-content.js +61 -0
- package/dist/protocol/search-replace.js +104 -27
- package/dist/protocol/yjs-doc.js +47 -22
- package/dist/tools/definitions.js +69 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,48 +13,44 @@
|
|
|
13
13
|
|
|
14
14
|
---
|
|
15
15
|
|
|
16
|
-
##
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
Add `typst-mcp` to your MCP client configuration (`.pi/agent/mcp-adapter.json`, `claude_desktop_config.json`, or `.cursor/mcp.json`).
|
|
17
19
|
|
|
18
20
|
### Prerequisites
|
|
19
21
|
|
|
20
|
-
- [**Node.js**](https://nodejs.org/) `>= 18`
|
|
22
|
+
- [**Node.js**](https://nodejs.org/) `>= 18`
|
|
21
23
|
- [**Typst CLI**](https://github.com/typst/typst) (optional: `typst` on `PATH` for local CLI compilation speed; if not installed, `typst_get_diagnostics` automatically uses Typst Web cloud compiler diagnostics)
|
|
22
24
|
|
|
23
|
-
###
|
|
24
|
-
|
|
25
|
-
**Option 1: Quick start (no clone, no global install):**
|
|
26
|
-
|
|
27
|
-
```bash
|
|
28
|
-
npx -y typst-mcp
|
|
29
|
-
```
|
|
30
|
-
|
|
31
|
-
**Option 2: Always run latest release (bypasses local `npx` cache):**
|
|
32
|
-
|
|
33
|
-
```bash
|
|
34
|
-
npx -y typst-mcp@latest
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
Runs the published [`typst-mcp`](https://www.npmjs.com/package/typst-mcp) package (Node `>= 18`). `-y` skips the npx confirmation prompt.
|
|
25
|
+
### Option 1: Normal (Standard)
|
|
38
26
|
|
|
39
|
-
|
|
27
|
+
Runs the published [`typst-mcp`](https://www.npmjs.com/package/typst-mcp) package (`npx -y typst-mcp`):
|
|
40
28
|
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
29
|
+
```json
|
|
30
|
+
{
|
|
31
|
+
"mcpServers": {
|
|
32
|
+
"typst-mcp": {
|
|
33
|
+
"command": "npx",
|
|
34
|
+
"args": ["-y", "typst-mcp"],
|
|
35
|
+
"env": {
|
|
36
|
+
"TYPST_EMAIL": "your-email@example.com",
|
|
37
|
+
"TYPST_PASSWORD": "your-password"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
46
42
|
```
|
|
47
43
|
|
|
48
|
-
###
|
|
44
|
+
### Option 2: Always Latest
|
|
49
45
|
|
|
50
|
-
|
|
46
|
+
Bypasses local `npx` cache to automatically pull and run the newest release on launch (`npx -y typst-mcp@latest`):
|
|
51
47
|
|
|
52
48
|
```json
|
|
53
49
|
{
|
|
54
50
|
"mcpServers": {
|
|
55
51
|
"typst-mcp": {
|
|
56
52
|
"command": "npx",
|
|
57
|
-
"args": ["-y", "typst-mcp"],
|
|
53
|
+
"args": ["-y", "typst-mcp@latest"],
|
|
58
54
|
"env": {
|
|
59
55
|
"TYPST_EMAIL": "your-email@example.com",
|
|
60
56
|
"TYPST_PASSWORD": "your-password"
|
|
@@ -64,10 +60,6 @@ Add `typst-mcp` to your MCP client configuration (`.pi/agent/mcp-adapter.json`,
|
|
|
64
60
|
}
|
|
65
61
|
```
|
|
66
62
|
|
|
67
|
-
> **Tip:** You can also set `"args": ["-y", "typst-mcp@latest"]` to guarantee your MCP client automatically pulls and runs the newest version on launch.
|
|
68
|
-
|
|
69
|
-
> **Session Persistence:** Once authenticated via `typst_auth_login` (or environment variables), the `__Http-typst-session` cookie is cached locally at `~/.pi/agent/typst-mcp-session.json` so subsequent tool calls authenticate automatically without re-signing in.
|
|
70
|
-
|
|
71
63
|
---
|
|
72
64
|
|
|
73
65
|
## Overview
|
|
@@ -93,11 +85,11 @@ Add `typst-mcp` to your MCP client configuration (`.pi/agent/mcp-adapter.json`,
|
|
|
93
85
|
| **Projects** | `typst_create_project` | Create a new `typst.app` cloud project with initial `.typ`/`.bib` files and pinned compiler version. |
|
|
94
86
|
| **Projects** | `typst_delete_project` | Permanently delete a `typst.app` project by ID (`DeleteProjectMutation`). |
|
|
95
87
|
| **Files & CRDT** | `typst_list_files` | List all multiplayer Yjs text files and binary asset blobs in a project. |
|
|
96
|
-
| **Files & CRDT** | `typst_read_file` | Read
|
|
88
|
+
| **Files & CRDT** | `typst_read_file` | Read bounded source pages (200 lines/16 KiB by default), with compact metadata, whole-file content hashes, separate continuation fields, and native image blocks. |
|
|
97
89
|
| **Files & CRDT** | `typst_write_file` | Create or overwrite a `.typ`/`.bib`/`.toml` file, protected by per-file mutation queue serialization, syncing Yjs state diffs over `/editing/v3` WebSocket and REST. |
|
|
98
|
-
| **Files & CRDT** | `typst_edit_file` | Apply
|
|
90
|
+
| **Files & CRDT** | `typst_edit_file` | Apply validated, unique edits to one snapshot with optional stale-content protection, exact matching by default, compact receipts, opt-in fuzzy fallback, and bounded diff previews. |
|
|
99
91
|
| **Search & Replace** | `typst_grep_project` | Search for text or regular expressions across project files, returning file paths, line numbers, and optional surrounding context lines. |
|
|
100
|
-
| **Search & Replace** | `typst_find_and_replace` |
|
|
92
|
+
| **Search & Replace** | `typst_find_and_replace` | Replace text or regex in one file or project-wide. Batch ordered replacement rules in one call with one save per changed file, compact summaries, optional diffs, and dry-run previews. |
|
|
101
93
|
| **Assets & Tree** | `typst_upload_asset` | Upload or replace binary assets (`PNG`, `JPG`, `SVG`, `PDF`, fonts) from a local path or base64 payload. |
|
|
102
94
|
| **Assets & Tree** | `typst_rename_file` | Rename or move a file inside a `typst.app` project (`UpdatesFileMutation`). |
|
|
103
95
|
| **Assets & Tree** | `typst_delete_file` | Delete a file or binary asset from a project (`DeleteFilesMutation`). |
|
|
@@ -107,8 +99,96 @@ Add `typst-mcp` to your MCP client configuration (`.pi/agent/mcp-adapter.json`,
|
|
|
107
99
|
|
|
108
100
|
---
|
|
109
101
|
|
|
102
|
+
## Read and edit efficiently
|
|
103
|
+
|
|
104
|
+
1. Use `typst_grep_project` to locate relevant text, then read just that range with `typst_read_file` (`offset` is 1-based, `limit` defaults to 200 lines, `maxBytes` defaults to 16 KiB and can be raised to 1 MiB).
|
|
105
|
+
2. Keep the returned `contentHash`. It is SHA-256 of the **entire raw text file**, including BOM and line endings, even when reading a range or displaying line numbers.
|
|
106
|
+
3. Send all disjoint edits in one `typst_edit_file` call, with `expectedHash` set to that hash. Prefer a short unique `oldText` anchor over repeating a whole file.
|
|
107
|
+
|
|
108
|
+
```json
|
|
109
|
+
{
|
|
110
|
+
"projectId": "your-project-id",
|
|
111
|
+
"filePath": "main.typ",
|
|
112
|
+
"expectedHash": "<contentHash from the read response>",
|
|
113
|
+
"edits": [
|
|
114
|
+
{ "oldText": "September 2025", "newText": "September 2026" }
|
|
115
|
+
]
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Text read responses include source only in `content`, with `totalLines`, `startLine`, `endLine`, `truncated`, and `nextOffset` when more source remains. They never repeat source in truncation metadata or append instructions to it. Complete lines and original line endings are retained, so unnumbered pages can be concatenated without losing bytes. A trailing newline does not create a phantom extra line. An empty file has `totalLines: 0`, `startLine: 1`, `endLine: 0`.
|
|
120
|
+
|
|
121
|
+
Read metadata defaults to `{id, name}`. Use `output: "full"` for full file metadata; this does not increase the source range. `lineNumbers: true` adds display prefixes: remove those prefixes when constructing `oldText`. Byte budgets include those prefixes. If one line cannot fit, the tool returns an actionable error rather than an empty page that cannot advance.
|
|
122
|
+
|
|
123
|
+
Edits match against the same original snapshot, not the result of previous edits. All entries must be valid, and each anchor must be unique, including overlapping occurrences. Invalid batches, overlapping ranges, and stale hashes fail before saving. `matchMode: "exact"` is the default and accepts LF/CRLF differences. Use `matchMode: "fuzzy"` to opt into Unicode quote/dash/space and NFKC fallback when no exact match exists. Fallback matching preserves characters outside the matched span. Edits retain the BOM and untouched line endings, including mixed LF/CRLF files.
|
|
124
|
+
|
|
125
|
+
Successful edits return a compact receipt with file ID/name, `appliedCount`, the resulting `contentHash`, and `firstChangedLine`. This avoids returning the same changes as both a diff and a patch. Optional controls:
|
|
126
|
+
|
|
127
|
+
- `output: "diff"` adds a display diff; `output: "full"` also adds a patch and full metadata.
|
|
128
|
+
- Each diff/patch field is limited to 100 lines and `maxDiffBytes` bytes (default 8192, maximum 1 MiB). `diffTruncated` / `patchTruncated` explicitly flag omissions; read the affected range for complete source.
|
|
129
|
+
- `dryRun: true` validates and previews without saving and defaults to diff output. Its `appliedCount` describes proposed edits; its `contentHash` still identifies the unchanged cloud text.
|
|
130
|
+
|
|
131
|
+
`expectedHash` is checked on fresh text inside the shared file mutation queue, which serializes local edits, replacements, and whole-file writes to existing files. It detects changes made since the read; it is not a cloud compare-and-swap transaction and cannot prevent a collaborator from editing during the subsequent save. Yjs still merges concurrent cloud updates.
|
|
132
|
+
|
|
133
|
+
**Changed defaults:** reads previously allowed 2,000 lines/50 KiB and embedded continuation hints in `content`. Read callers should follow `nextOffset` now. MCP edits previously returned full diffs/patches and used fuzzy fallback automatically; request `output: "full"` or `matchMode: "fuzzy"` explicitly when needed. The low-level `editYjsText` helper retains its legacy fuzzy default; the client/MCP edit path defaults to exact matching.
|
|
134
|
+
|
|
135
|
+
## Batch replacements for agents
|
|
136
|
+
|
|
137
|
+
Use one `typst_find_and_replace` call for related replacements. For example, expand abbreviated month names throughout all `.typ` files:
|
|
138
|
+
|
|
139
|
+
```json
|
|
140
|
+
{
|
|
141
|
+
"projectId": "your-project-id",
|
|
142
|
+
"glob": "*.typ",
|
|
143
|
+
"replacements": [
|
|
144
|
+
{ "pattern": "\\bSep\\b", "replacement": "September", "regex": true },
|
|
145
|
+
{ "pattern": "\\bOct\\b", "replacement": "October", "regex": true }
|
|
146
|
+
]
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Each file is downloaded once, all rules run in order on the previous rule's result, and the file is saved once if its final text changed. All rules are validated before any files are written. File mutations remain serialized with other edits; a project-wide operation is **not atomic across files** if a network or save error occurs.
|
|
151
|
+
|
|
152
|
+
Batch calls default to `output: "summary"`:
|
|
153
|
+
|
|
154
|
+
```json
|
|
155
|
+
{
|
|
156
|
+
"filesModified": [
|
|
157
|
+
{
|
|
158
|
+
"file": "main.typ",
|
|
159
|
+
"replacementsCount": 3,
|
|
160
|
+
"replacementCounts": [2, 1],
|
|
161
|
+
"firstChangedLine": 4
|
|
162
|
+
}
|
|
163
|
+
],
|
|
164
|
+
"totalReplacements": 3,
|
|
165
|
+
"dryRun": false
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
`replacementCounts` follows the input rule order and counts matches applied by each rule in that changed file. Unchanged files, including batches whose rules cancel each other out, are omitted and do not contribute to totals.
|
|
170
|
+
|
|
171
|
+
- Set `dryRun: true` to preview without saving. Add `output: "diff"` to include a single combined display diff per file.
|
|
172
|
+
- Set `output: "full"` to include both the display diff and unified patch.
|
|
173
|
+
- Set `regex`, `ignoreCase`, and `limit` on each batch rule. `limit` is a non-negative integer per rule per file; `0` disables that rule.
|
|
174
|
+
- Use either `replacements` or the existing `pattern`/`replacement` fields. Existing single-replacement calls still default to `output: "full"`; they can request `"summary"` too.
|
|
175
|
+
- Use `filePath` to target one file by path or ID. A missing explicit file returns an error.
|
|
176
|
+
|
|
110
177
|
## Development & Verification
|
|
111
178
|
|
|
179
|
+
### From Source (Development)
|
|
180
|
+
|
|
181
|
+
Requires [**Bun**](https://bun.sh) `>= 1.2`:
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
git clone https://github.com/caphefalumi/typst-web-mcp.git
|
|
185
|
+
cd typst-web-mcp
|
|
186
|
+
bun install
|
|
187
|
+
bun run dev
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### Build & Test
|
|
191
|
+
|
|
112
192
|
```bash
|
|
113
193
|
# Compile publishable output to dist/
|
|
114
194
|
bun run build
|
package/dist/client/auth.js
CHANGED
|
@@ -13,9 +13,31 @@ function extractRawSetCookies(headers) {
|
|
|
13
13
|
}
|
|
14
14
|
return [];
|
|
15
15
|
}
|
|
16
|
+
export function getDefaultSessionFilePath() {
|
|
17
|
+
if (process.env.TYPST_SESSION_FILE) {
|
|
18
|
+
return process.env.TYPST_SESSION_FILE;
|
|
19
|
+
}
|
|
20
|
+
const platform = process.platform;
|
|
21
|
+
if (platform === "win32") {
|
|
22
|
+
const localAppData = process.env.LOCALAPPDATA ||
|
|
23
|
+
(process.env.APPDATA
|
|
24
|
+
? join(process.env.APPDATA, "..", "Local")
|
|
25
|
+
: join(homedir(), "AppData", "Local"));
|
|
26
|
+
return join(localAppData, "typst-mcp", "session.json");
|
|
27
|
+
}
|
|
28
|
+
if (platform === "darwin") {
|
|
29
|
+
if (process.env.XDG_CACHE_HOME) {
|
|
30
|
+
return join(process.env.XDG_CACHE_HOME, "typst-mcp", "session.json");
|
|
31
|
+
}
|
|
32
|
+
return join(homedir(), "Library", "Caches", "typst-mcp", "session.json");
|
|
33
|
+
}
|
|
34
|
+
// Linux and other POSIX systems (XDG Base Directory specification)
|
|
35
|
+
const cacheDir = process.env.XDG_CACHE_HOME || join(homedir(), ".cache");
|
|
36
|
+
return join(cacheDir, "typst-mcp", "session.json");
|
|
37
|
+
}
|
|
16
38
|
function resolveSessionFilePath(configuredPath) {
|
|
17
39
|
if (configuredPath === undefined) {
|
|
18
|
-
return
|
|
40
|
+
return getDefaultSessionFilePath();
|
|
19
41
|
}
|
|
20
42
|
return configuredPath;
|
|
21
43
|
}
|
|
@@ -92,11 +114,22 @@ export class TypstAuthManager {
|
|
|
92
114
|
}
|
|
93
115
|
}
|
|
94
116
|
loadStoredSession() {
|
|
95
|
-
if (!this.sessionFilePath
|
|
117
|
+
if (!this.sessionFilePath) {
|
|
96
118
|
return;
|
|
97
119
|
}
|
|
120
|
+
let targetPath = this.sessionFilePath;
|
|
121
|
+
if (!existsSync(targetPath)) {
|
|
122
|
+
// Legacy fallback check for seamless migration from ~/.pi/agent/typst-mcp-session.json
|
|
123
|
+
const legacyPath = join(homedir(), ".pi", "agent", "typst-mcp-session.json");
|
|
124
|
+
if (existsSync(legacyPath)) {
|
|
125
|
+
targetPath = legacyPath;
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
98
131
|
try {
|
|
99
|
-
const raw = JSON.parse(readFileSync(
|
|
132
|
+
const raw = JSON.parse(readFileSync(targetPath, "utf8"));
|
|
100
133
|
if (raw && typeof raw.cookies === "object" && raw.cookies) {
|
|
101
134
|
this.cookies = { ...raw.cookies };
|
|
102
135
|
this.authMethod = raw.authMethod ?? "cookie";
|
|
@@ -7,10 +7,11 @@ import { captureBrowserPreviewScreenshot, captureWebDiagnostics, captureWorkspac
|
|
|
7
7
|
import { CREATE_PROJECT_MUTATION, DASHBOARD_CONTENT_QUERY, DELETE_FILES_MUTATION, DELETE_PROJECT_MUTATION, EDIT_PROJECT_MUTATION, PROJECT_FILES_QUERY, UPDATE_FILES_MUTATION, } from "../protocol/graphql.js";
|
|
8
8
|
import { decodeYjsText, editYjsText, replaceYjsText, } from "../protocol/yjs-doc.js";
|
|
9
9
|
import { decodeMessage, encodeMessage, MessageKind, } from "../protocol/ws-codec.js";
|
|
10
|
-
import {
|
|
10
|
+
import { readTextContent, validateReadOptions, hashText } from "../protocol/read-content.js";
|
|
11
|
+
import { validateEdits } from "../protocol/edit-diff.js";
|
|
11
12
|
import { detectSupportedImageMimeType } from "../utils/mime.js";
|
|
12
13
|
import { withFileMutationQueue } from "../utils/file-mutation-queue.js";
|
|
13
|
-
import { matchGlob, searchInContent,
|
|
14
|
+
import { matchGlob, searchInContent, replaceBatchInContent, resolveReplacementRules, } from "../protocol/search-replace.js";
|
|
14
15
|
export { TypstLoadError, resolveTypstBinary };
|
|
15
16
|
const URL_ALPHABET = "useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict";
|
|
16
17
|
export function generateClientId(size = 8) {
|
|
@@ -220,90 +221,14 @@ export class TypstCloudClient {
|
|
|
220
221
|
return new Uint8Array(await response.arrayBuffer());
|
|
221
222
|
}
|
|
222
223
|
async readFile(projectId, fileIdOrPath, options) {
|
|
224
|
+
validateReadOptions(options ?? {});
|
|
223
225
|
const file = await this.resolveFile(projectId, fileIdOrPath);
|
|
224
226
|
if (file.isMultiplayable) {
|
|
225
227
|
const yjsBytes = await this.downloadFileYjs(projectId, file.id);
|
|
226
|
-
const text = decodeYjsText(yjsBytes);
|
|
227
|
-
const allLines = text.split("\n");
|
|
228
|
-
const totalLines = allLines.length;
|
|
229
|
-
if (options?.offset !== undefined && options.offset > totalLines) {
|
|
230
|
-
throw new Error(`Offset ${options.offset} is beyond end of file (${totalLines} lines total)`);
|
|
231
|
-
}
|
|
232
|
-
const startLine = options?.offset ? Math.max(0, options.offset - 1) : 0;
|
|
233
|
-
const startLineDisplay = startLine + 1;
|
|
234
|
-
let selectedContent;
|
|
235
|
-
let userLimitedLines;
|
|
236
|
-
if (options?.limit !== undefined) {
|
|
237
|
-
const endLine = Math.min(startLine + options.limit, totalLines);
|
|
238
|
-
selectedContent = allLines.slice(startLine, endLine).join("\n");
|
|
239
|
-
userLimitedLines = endLine - startLine;
|
|
240
|
-
}
|
|
241
|
-
else {
|
|
242
|
-
selectedContent = allLines.slice(startLine).join("\n");
|
|
243
|
-
}
|
|
244
|
-
const truncation = truncateHead(selectedContent, {
|
|
245
|
-
maxLines: options?.maxLines,
|
|
246
|
-
maxBytes: options?.maxBytes,
|
|
247
|
-
});
|
|
248
|
-
let outputText;
|
|
249
|
-
let endLineDisplay = startLineDisplay;
|
|
250
|
-
if (truncation.firstLineExceedsLimit) {
|
|
251
|
-
const firstLineSize = formatSize(Buffer.byteLength(allLines[startLine] ?? "", "utf-8"));
|
|
252
|
-
outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(truncation.maxBytes)} limit]`;
|
|
253
|
-
}
|
|
254
|
-
else if (truncation.truncated) {
|
|
255
|
-
endLineDisplay = startLineDisplay + truncation.outputLines - 1;
|
|
256
|
-
const nextOffset = endLineDisplay + 1;
|
|
257
|
-
let body = truncation.content;
|
|
258
|
-
if (options?.lineNumbers) {
|
|
259
|
-
const lines = body.split("\n");
|
|
260
|
-
const width = String(endLineDisplay).length;
|
|
261
|
-
body = lines
|
|
262
|
-
.map((line, idx) => `${String(startLineDisplay + idx).padStart(width, " ")} | ${line}`)
|
|
263
|
-
.join("\n");
|
|
264
|
-
}
|
|
265
|
-
if (truncation.truncatedBy === "lines") {
|
|
266
|
-
outputText = `${body}\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalLines}. Use offset=${nextOffset} to continue.]`;
|
|
267
|
-
}
|
|
268
|
-
else {
|
|
269
|
-
outputText = `${body}\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalLines} (${formatSize(truncation.maxBytes)} limit). Use offset=${nextOffset} to continue.]`;
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
else if (userLimitedLines !== undefined &&
|
|
273
|
-
startLine + userLimitedLines < totalLines) {
|
|
274
|
-
endLineDisplay = startLine + userLimitedLines;
|
|
275
|
-
const remaining = totalLines - endLineDisplay;
|
|
276
|
-
const nextOffset = endLineDisplay + 1;
|
|
277
|
-
let body = truncation.content;
|
|
278
|
-
if (options?.lineNumbers) {
|
|
279
|
-
const lines = body.split("\n");
|
|
280
|
-
const width = String(endLineDisplay).length;
|
|
281
|
-
body = lines
|
|
282
|
-
.map((line, idx) => `${String(startLineDisplay + idx).padStart(width, " ")} | ${line}`)
|
|
283
|
-
.join("\n");
|
|
284
|
-
}
|
|
285
|
-
outputText = `${body}\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue.]`;
|
|
286
|
-
}
|
|
287
|
-
else {
|
|
288
|
-
endLineDisplay = totalLines;
|
|
289
|
-
let body = truncation.content;
|
|
290
|
-
if (options?.lineNumbers) {
|
|
291
|
-
const lines = body.split("\n");
|
|
292
|
-
const width = String(startLineDisplay + lines.length - 1).length;
|
|
293
|
-
body = lines
|
|
294
|
-
.map((line, idx) => `${String(startLineDisplay + idx).padStart(width, " ")} | ${line}`)
|
|
295
|
-
.join("\n");
|
|
296
|
-
}
|
|
297
|
-
outputText = body;
|
|
298
|
-
}
|
|
299
228
|
return {
|
|
300
229
|
file,
|
|
301
|
-
content: outputText,
|
|
302
230
|
encoding: "utf-8",
|
|
303
|
-
|
|
304
|
-
startLine: startLineDisplay,
|
|
305
|
-
endLine: endLineDisplay,
|
|
306
|
-
truncation: truncation.truncated ? truncation : undefined,
|
|
231
|
+
...readTextContent(decodeYjsText(yjsBytes), options),
|
|
307
232
|
};
|
|
308
233
|
}
|
|
309
234
|
const blob = await this.downloadFileBlob(projectId, file.id);
|
|
@@ -433,15 +358,20 @@ export class TypstCloudClient {
|
|
|
433
358
|
files.find((f) => f.name === normalized || f.name === filePathOrId);
|
|
434
359
|
if (existing) {
|
|
435
360
|
if (existing.isMultiplayable) {
|
|
436
|
-
const
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
361
|
+
const replaceExisting = async () => {
|
|
362
|
+
const remote = await this.downloadFileYjs(projectId, existing.id);
|
|
363
|
+
const { encodedUpdate } = replaceYjsText(remote, content);
|
|
364
|
+
const { syncChannel } = await this.saveYjsUpdate(projectId, existing.id, encodedUpdate, options?.preferWebSocket ?? true);
|
|
365
|
+
return {
|
|
366
|
+
file: existing,
|
|
367
|
+
created: false,
|
|
368
|
+
bytesWritten: Buffer.byteLength(content, "utf8"),
|
|
369
|
+
syncChannel,
|
|
370
|
+
};
|
|
444
371
|
};
|
|
372
|
+
// Path-based writes must use the same ID lock as surgical edits.
|
|
373
|
+
return normalized === existing.id ? replaceExisting() :
|
|
374
|
+
withFileMutationQueue(`${projectId}:${existing.id}`, replaceExisting);
|
|
445
375
|
}
|
|
446
376
|
const rawBytes = new TextEncoder().encode(content);
|
|
447
377
|
const { response } = await this.restCall("PUT", `files/${projectId}/${existing.id}`, { body: rawBytes });
|
|
@@ -469,14 +399,29 @@ export class TypstCloudClient {
|
|
|
469
399
|
});
|
|
470
400
|
}
|
|
471
401
|
async editFile(projectId, filePathOrId, edits, options) {
|
|
402
|
+
validateEdits(edits);
|
|
403
|
+
if (options?.expectedHash !== undefined &&
|
|
404
|
+
(typeof options.expectedHash !== "string" || !/^[a-f\d]{64}$/i.test(options.expectedHash))) {
|
|
405
|
+
throw new Error("expectedHash must be the SHA-256 contentHash returned by typst_read_file.");
|
|
406
|
+
}
|
|
407
|
+
if (options?.matchMode !== undefined && !["exact", "fuzzy"].includes(options.matchMode)) {
|
|
408
|
+
throw new Error("matchMode must be exact or fuzzy.");
|
|
409
|
+
}
|
|
410
|
+
if (options?.dryRun !== undefined && typeof options.dryRun !== "boolean") {
|
|
411
|
+
throw new Error("dryRun must be a boolean.");
|
|
412
|
+
}
|
|
472
413
|
const file = await this.resolveFile(projectId, filePathOrId);
|
|
473
414
|
if (!file.isMultiplayable) {
|
|
474
415
|
throw new TypstLoadError(["NOT_MULTIPLAYABLE"], `File "${file.name}" is a binary blob and cannot be surgically text-edited`);
|
|
475
416
|
}
|
|
476
417
|
return withFileMutationQueue(`${projectId}:${file.id}`, async () => {
|
|
477
418
|
const remote = await this.downloadFileYjs(projectId, file.id);
|
|
478
|
-
const
|
|
479
|
-
|
|
419
|
+
const currentHash = hashText(decodeYjsText(remote));
|
|
420
|
+
if (options?.expectedHash !== undefined && currentHash !== options.expectedHash.toLowerCase()) {
|
|
421
|
+
throw new TypstLoadError(["STALE_CONTENT"], "File changed since read. Read the affected range again and retry with its contentHash.");
|
|
422
|
+
}
|
|
423
|
+
const { encodedUpdate, previousText, updatedText, appliedCount, diff, patch, firstChangedLine, usedFuzzyMatch, } = editYjsText(remote, edits, file.name, options?.matchMode ?? "exact");
|
|
424
|
+
const { syncChannel } = options?.dryRun ? { syncChannel: "none" } : await this.saveYjsUpdate(projectId, file.id, encodedUpdate, options?.preferWebSocket ?? true);
|
|
480
425
|
return {
|
|
481
426
|
file,
|
|
482
427
|
appliedCount,
|
|
@@ -486,6 +431,9 @@ export class TypstCloudClient {
|
|
|
486
431
|
patch,
|
|
487
432
|
firstChangedLine,
|
|
488
433
|
syncChannel,
|
|
434
|
+
contentHash: options?.dryRun ? currentHash : hashText(updatedText),
|
|
435
|
+
dryRun: Boolean(options?.dryRun),
|
|
436
|
+
usedFuzzyMatch,
|
|
489
437
|
};
|
|
490
438
|
});
|
|
491
439
|
}
|
|
@@ -749,7 +697,13 @@ export class TypstCloudClient {
|
|
|
749
697
|
};
|
|
750
698
|
}
|
|
751
699
|
async findAndReplace(projectId, options) {
|
|
700
|
+
const rules = resolveReplacementRules(options);
|
|
701
|
+
const output = options.output ?? (options.replacements ? "summary" : "full");
|
|
752
702
|
const files = await this.listFiles(projectId);
|
|
703
|
+
if (options.filePath && !files.some(file => file.isMultiplayable &&
|
|
704
|
+
(file.name === options.filePath.replace(/^\/+/, "") || file.id === options.filePath))) {
|
|
705
|
+
throw new Error(`Text file not found: ${options.filePath}`);
|
|
706
|
+
}
|
|
753
707
|
const filesModified = [];
|
|
754
708
|
let totalReplacements = 0;
|
|
755
709
|
for (const file of files) {
|
|
@@ -767,14 +721,8 @@ export class TypstCloudClient {
|
|
|
767
721
|
await withFileMutationQueue(`${projectId}:${file.id}`, async () => {
|
|
768
722
|
const remote = await this.downloadFileYjs(projectId, file.id);
|
|
769
723
|
const text = decodeYjsText(remote);
|
|
770
|
-
const repResult =
|
|
771
|
-
|
|
772
|
-
replacement: options.replacement,
|
|
773
|
-
regex: options.regex,
|
|
774
|
-
ignoreCase: options.ignoreCase,
|
|
775
|
-
limit: options.limit,
|
|
776
|
-
});
|
|
777
|
-
if (repResult.replacementCount > 0) {
|
|
724
|
+
const repResult = replaceBatchInContent(file.name, text, rules, output);
|
|
725
|
+
if (repResult.updatedText !== text) {
|
|
778
726
|
if (!options.dryRun) {
|
|
779
727
|
const { encodedUpdate } = replaceYjsText(remote, repResult.updatedText);
|
|
780
728
|
await this.saveYjsUpdate(projectId, file.id, encodedUpdate, options.preferWebSocket ?? true);
|
|
@@ -782,8 +730,9 @@ export class TypstCloudClient {
|
|
|
782
730
|
filesModified.push({
|
|
783
731
|
file: file.name,
|
|
784
732
|
replacementsCount: repResult.replacementCount,
|
|
785
|
-
diff: repResult.diff,
|
|
786
|
-
patch: repResult.patch,
|
|
733
|
+
...(repResult.diff !== undefined ? { diff: repResult.diff } : {}),
|
|
734
|
+
...(repResult.patch !== undefined ? { patch: repResult.patch } : {}),
|
|
735
|
+
...(options.replacements ? { replacementCounts: repResult.replacementCounts } : {}),
|
|
787
736
|
firstChangedLine: repResult.firstChangedLine,
|
|
788
737
|
});
|
|
789
738
|
totalReplacements += repResult.replacementCount;
|
package/dist/index.js
CHANGED
|
@@ -8,6 +8,8 @@ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextpro
|
|
|
8
8
|
import { TypstCloudClient, } from "./client/typst-client.js";
|
|
9
9
|
import { formatUnknownError } from "./client/errors.js";
|
|
10
10
|
import { TYPST_MCP_TOOLS } from "./tools/definitions.js";
|
|
11
|
+
import { validateEdits } from "./protocol/edit-diff.js";
|
|
12
|
+
import { truncateHead } from "./utils/truncate.js";
|
|
11
13
|
export { TYPST_MCP_TOOLS };
|
|
12
14
|
function resolvePreferWebSocket(value) {
|
|
13
15
|
if (typeof value === "boolean") {
|
|
@@ -22,32 +24,25 @@ function resolveOptionalNumber(value) {
|
|
|
22
24
|
return undefined;
|
|
23
25
|
}
|
|
24
26
|
export function prepareEditArguments(input) {
|
|
25
|
-
if (
|
|
27
|
+
if (input === undefined)
|
|
26
28
|
return [];
|
|
27
|
-
if (Array.isArray(input)) {
|
|
28
|
-
return input.filter((e) => e && typeof e.oldText === "string" && typeof e.newText === "string");
|
|
29
|
-
}
|
|
30
29
|
if (typeof input === "string") {
|
|
31
30
|
try {
|
|
32
|
-
|
|
33
|
-
if (Array.isArray(parsed)) {
|
|
34
|
-
return parsed.filter((e) => e && typeof e.oldText === "string" && typeof e.newText === "string");
|
|
35
|
-
}
|
|
36
|
-
if (parsed &&
|
|
37
|
-
typeof parsed.oldText === "string" &&
|
|
38
|
-
typeof parsed.newText === "string") {
|
|
39
|
-
return [parsed];
|
|
40
|
-
}
|
|
31
|
+
input = JSON.parse(input);
|
|
41
32
|
}
|
|
42
|
-
catch {
|
|
43
|
-
|
|
44
|
-
if (typeof input === "object" && input !== null) {
|
|
45
|
-
const obj = input;
|
|
46
|
-
if (typeof obj.oldText === "string" && typeof obj.newText === "string") {
|
|
47
|
-
return [{ oldText: obj.oldText, newText: obj.newText }];
|
|
33
|
+
catch {
|
|
34
|
+
throw new Error("edits must be an array of {oldText, newText}; invalid JSON.");
|
|
48
35
|
}
|
|
49
36
|
}
|
|
50
|
-
|
|
37
|
+
const edits = (Array.isArray(input) ? input : [input]);
|
|
38
|
+
validateEdits(edits);
|
|
39
|
+
return edits.map(({ oldText, newText }) => ({ oldText, newText }));
|
|
40
|
+
}
|
|
41
|
+
function requiredText(args, key) {
|
|
42
|
+
if (typeof args[key] !== "string" || !args[key].length) {
|
|
43
|
+
throw new Error(`${key} must be a non-empty string.`);
|
|
44
|
+
}
|
|
45
|
+
return args[key];
|
|
51
46
|
}
|
|
52
47
|
export async function dispatchTypstTool(client, name, args = {}) {
|
|
53
48
|
switch (name) {
|
|
@@ -78,28 +73,71 @@ export async function dispatchTypstTool(client, name, args = {}) {
|
|
|
78
73
|
}
|
|
79
74
|
case "typst_list_files":
|
|
80
75
|
return await client.listFiles(String(args.projectId));
|
|
81
|
-
case "typst_read_file":
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
76
|
+
case "typst_read_file": {
|
|
77
|
+
if (args.output !== undefined && args.output !== "compact" && args.output !== "full") {
|
|
78
|
+
throw new Error("output must be compact or full.");
|
|
79
|
+
}
|
|
80
|
+
const result = await client.readFile(requiredText(args, "projectId"), requiredText(args, "filePath"), {
|
|
81
|
+
offset: args.offset,
|
|
82
|
+
limit: args.limit,
|
|
83
|
+
maxBytes: args.maxBytes,
|
|
84
|
+
lineNumbers: args.lineNumbers,
|
|
88
85
|
});
|
|
86
|
+
return args.output === "full" ? result : {
|
|
87
|
+
...result, file: { id: result.file.id, name: result.file.name },
|
|
88
|
+
};
|
|
89
|
+
}
|
|
89
90
|
case "typst_write_file":
|
|
90
91
|
return await client.writeFile(String(args.projectId), String(args.filePath), String(args.content), {
|
|
91
92
|
preferWebSocket: resolvePreferWebSocket(args.preferWebSocket),
|
|
92
93
|
});
|
|
93
94
|
case "typst_edit_file": {
|
|
94
|
-
|
|
95
|
-
if (edits
|
|
96
|
-
|
|
97
|
-
typeof args.newText === "string") {
|
|
98
|
-
edits = [{ oldText: args.oldText, newText: args.newText }];
|
|
95
|
+
const hasShorthand = args.oldText !== undefined || args.newText !== undefined;
|
|
96
|
+
if (args.edits !== undefined && hasShorthand) {
|
|
97
|
+
throw new Error("Use either edits or oldText/newText, not both.");
|
|
99
98
|
}
|
|
100
|
-
|
|
99
|
+
const edits = prepareEditArguments(args.edits !== undefined ? args.edits :
|
|
100
|
+
hasShorthand ? { oldText: args.oldText, newText: args.newText } : undefined);
|
|
101
|
+
const output = args.output ?? (args.dryRun === true ? "diff" : "summary");
|
|
102
|
+
if (!["summary", "diff", "full"].includes(output)) {
|
|
103
|
+
throw new Error("output must be summary, diff, or full.");
|
|
104
|
+
}
|
|
105
|
+
const maxDiffBytes = args.maxDiffBytes ?? 8192;
|
|
106
|
+
if (typeof maxDiffBytes !== "number" || !Number.isSafeInteger(maxDiffBytes) ||
|
|
107
|
+
maxDiffBytes < 1 || maxDiffBytes > 1048576) {
|
|
108
|
+
throw new Error("maxDiffBytes must be an integer between 1 and 1048576.");
|
|
109
|
+
}
|
|
110
|
+
if (args.preferWebSocket !== undefined && typeof args.preferWebSocket !== "boolean") {
|
|
111
|
+
throw new Error("preferWebSocket must be a boolean.");
|
|
112
|
+
}
|
|
113
|
+
const result = await client.editFile(requiredText(args, "projectId"), requiredText(args, "filePath"), edits, {
|
|
101
114
|
preferWebSocket: resolvePreferWebSocket(args.preferWebSocket),
|
|
115
|
+
expectedHash: args.expectedHash,
|
|
116
|
+
matchMode: args.matchMode,
|
|
117
|
+
dryRun: args.dryRun,
|
|
102
118
|
});
|
|
119
|
+
const compact = {
|
|
120
|
+
file: { id: result.file.id, name: result.file.name },
|
|
121
|
+
appliedCount: result.appliedCount,
|
|
122
|
+
contentHash: result.contentHash,
|
|
123
|
+
firstChangedLine: result.firstChangedLine,
|
|
124
|
+
...(result.dryRun ? { dryRun: true } : {}),
|
|
125
|
+
...(result.usedFuzzyMatch ? { usedFuzzyMatch: true } : {}),
|
|
126
|
+
};
|
|
127
|
+
if (output === "summary")
|
|
128
|
+
return compact;
|
|
129
|
+
const diff = truncateHead(result.diff, { maxLines: 100, maxBytes: maxDiffBytes });
|
|
130
|
+
const diffFields = {
|
|
131
|
+
diff: diff.content,
|
|
132
|
+
...(diff.truncated ? { diffTruncated: true, hint: "Read the affected range to inspect all changes, or increase maxDiffBytes (100 diff lines maximum)." } : {}),
|
|
133
|
+
};
|
|
134
|
+
if (output === "diff")
|
|
135
|
+
return { ...compact, ...diffFields };
|
|
136
|
+
const patch = truncateHead(result.patch, { maxLines: 100, maxBytes: maxDiffBytes });
|
|
137
|
+
return {
|
|
138
|
+
...result, ...diffFields, patch: patch.content,
|
|
139
|
+
...(patch.truncated ? { patchTruncated: true } : {}),
|
|
140
|
+
};
|
|
103
141
|
}
|
|
104
142
|
case "typst_upload_asset": {
|
|
105
143
|
let bytes;
|
|
@@ -149,13 +187,15 @@ export async function dispatchTypstTool(client, name, args = {}) {
|
|
|
149
187
|
});
|
|
150
188
|
case "typst_find_and_replace":
|
|
151
189
|
return await client.findAndReplace(String(args.projectId), {
|
|
152
|
-
pattern:
|
|
153
|
-
replacement:
|
|
190
|
+
pattern: args.pattern,
|
|
191
|
+
replacement: args.replacement,
|
|
192
|
+
replacements: args.replacements,
|
|
193
|
+
output: args.output,
|
|
154
194
|
filePath: args.filePath,
|
|
155
195
|
glob: args.glob,
|
|
156
|
-
regex:
|
|
157
|
-
ignoreCase:
|
|
158
|
-
limit:
|
|
196
|
+
regex: args.regex,
|
|
197
|
+
ignoreCase: args.ignoreCase,
|
|
198
|
+
limit: args.limit,
|
|
159
199
|
dryRun: Boolean(args.dryRun),
|
|
160
200
|
preferWebSocket: resolvePreferWebSocket(args.preferWebSocket),
|
|
161
201
|
});
|
|
@@ -167,7 +207,7 @@ export function createTypstMcpServer(options = {}) {
|
|
|
167
207
|
const client = new TypstCloudClient(options);
|
|
168
208
|
const server = new Server({
|
|
169
209
|
name: "typst-mcp",
|
|
170
|
-
version: "1.
|
|
210
|
+
version: "1.3.0",
|
|
171
211
|
}, {
|
|
172
212
|
capabilities: {
|
|
173
213
|
tools: {},
|
|
@@ -210,7 +250,7 @@ export function createTypstMcpServer(options = {}) {
|
|
|
210
250
|
content: [
|
|
211
251
|
{
|
|
212
252
|
type: "text",
|
|
213
|
-
text: JSON.stringify(result, null, 2),
|
|
253
|
+
text: JSON.stringify(result, null, name === "typst_read_file" || name === "typst_edit_file" ? undefined : 2),
|
|
214
254
|
},
|
|
215
255
|
],
|
|
216
256
|
};
|
|
@@ -133,10 +133,13 @@ export function fuzzyFindText(content, oldText) {
|
|
|
133
133
|
contentForReplacement: content,
|
|
134
134
|
};
|
|
135
135
|
}
|
|
136
|
-
const fuzzyContent =
|
|
136
|
+
const { normalized: fuzzyContent, starts, ends } = fuzzyTextWithOffsets(content);
|
|
137
137
|
const fuzzyOldText = normalizeForFuzzyMatch(oldText);
|
|
138
|
-
const fuzzyIndex = fuzzyContent.indexOf(fuzzyOldText);
|
|
139
|
-
|
|
138
|
+
const fuzzyIndex = fuzzyOldText.length ? fuzzyContent.indexOf(fuzzyOldText) : -1;
|
|
139
|
+
// Do not replace part of an expanded Unicode grapheme (e.g. one f in ffi).
|
|
140
|
+
const last = fuzzyIndex + fuzzyOldText.length - 1;
|
|
141
|
+
if (fuzzyIndex === -1 || starts[fuzzyIndex] === starts[fuzzyIndex - 1] ||
|
|
142
|
+
ends[last] === ends[last + 1]) {
|
|
140
143
|
return {
|
|
141
144
|
found: false,
|
|
142
145
|
index: -1,
|
|
@@ -147,20 +150,69 @@ export function fuzzyFindText(content, oldText) {
|
|
|
147
150
|
}
|
|
148
151
|
return {
|
|
149
152
|
found: true,
|
|
150
|
-
index: fuzzyIndex,
|
|
151
|
-
matchLength:
|
|
153
|
+
index: starts[fuzzyIndex],
|
|
154
|
+
matchLength: ends[last] - starts[fuzzyIndex],
|
|
152
155
|
usedFuzzyMatch: true,
|
|
153
|
-
contentForReplacement:
|
|
156
|
+
contentForReplacement: content,
|
|
154
157
|
};
|
|
155
158
|
}
|
|
159
|
+
/** Keep original offsets so fallback matching cannot normalize unrelated text. */
|
|
160
|
+
function fuzzyTextWithOffsets(content) {
|
|
161
|
+
let normalized = "";
|
|
162
|
+
const starts = [];
|
|
163
|
+
const ends = [];
|
|
164
|
+
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
|
|
165
|
+
for (const { segment, index } of segmenter.segment(content)) {
|
|
166
|
+
// Append a sentinel so trailing spaces are retained until the line-level trim.
|
|
167
|
+
const value = normalizeForFuzzyMatch(segment + "\u0000").slice(0, -1);
|
|
168
|
+
normalized += value;
|
|
169
|
+
for (let i = 0; i < value.length; i++) {
|
|
170
|
+
starts.push(index);
|
|
171
|
+
ends.push(index + segment.length);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
let offset = 0;
|
|
175
|
+
const keptStarts = [];
|
|
176
|
+
const keptEnds = [];
|
|
177
|
+
const trimmedLines = normalized.split("\n").map(line => {
|
|
178
|
+
const trimmed = line.trimEnd();
|
|
179
|
+
for (let i = 0; i < trimmed.length; i++) {
|
|
180
|
+
keptStarts.push(starts[offset + i]);
|
|
181
|
+
keptEnds.push(ends[offset + i]);
|
|
182
|
+
}
|
|
183
|
+
offset += line.length;
|
|
184
|
+
if (offset < normalized.length) {
|
|
185
|
+
keptStarts.push(starts[offset]);
|
|
186
|
+
keptEnds.push(ends[offset]);
|
|
187
|
+
offset++;
|
|
188
|
+
}
|
|
189
|
+
return trimmed;
|
|
190
|
+
});
|
|
191
|
+
return { normalized: trimmedLines.join("\n"), starts: keptStarts, ends: keptEnds };
|
|
192
|
+
}
|
|
156
193
|
export function countOccurrences(content, oldText) {
|
|
157
|
-
|
|
158
|
-
const fuzzyOldText = normalizeForFuzzyMatch(oldText);
|
|
159
|
-
if (fuzzyOldText.length === 0)
|
|
194
|
+
if (!oldText.length)
|
|
160
195
|
return 0;
|
|
161
|
-
|
|
196
|
+
let count = 0;
|
|
197
|
+
let offset = content.indexOf(oldText);
|
|
198
|
+
while (offset !== -1) {
|
|
199
|
+
count++;
|
|
200
|
+
offset = content.indexOf(oldText, offset + 1);
|
|
201
|
+
}
|
|
202
|
+
return count;
|
|
203
|
+
}
|
|
204
|
+
export function validateEdits(edits) {
|
|
205
|
+
if (!Array.isArray(edits) || edits.length === 0) {
|
|
206
|
+
throw new Error("At least one edit operation is required");
|
|
207
|
+
}
|
|
208
|
+
for (const [index, edit] of edits.entries()) {
|
|
209
|
+
if (!edit || typeof edit.oldText !== "string" || typeof edit.newText !== "string" || !edit.oldText.length) {
|
|
210
|
+
throw new Error(`Edit ${index + 1} requires a non-empty oldText and a string newText.`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
162
213
|
}
|
|
163
|
-
export function applyEditsToNormalizedContent(normalizedContent, edits, path) {
|
|
214
|
+
export function applyEditsToNormalizedContent(normalizedContent, edits, path, matchMode = "fuzzy") {
|
|
215
|
+
validateEdits(edits);
|
|
164
216
|
const normalizedEdits = edits.map((e) => ({
|
|
165
217
|
oldText: normalizeToLF(e.oldText),
|
|
166
218
|
newText: normalizeToLF(e.newText),
|
|
@@ -170,19 +222,20 @@ export function applyEditsToNormalizedContent(normalizedContent, edits, path) {
|
|
|
170
222
|
throw new Error(`oldText cannot be empty for edit in ${path}`);
|
|
171
223
|
}
|
|
172
224
|
}
|
|
173
|
-
|
|
174
|
-
const usedFuzzyMatch = initialMatches.some((m) => m.usedFuzzyMatch);
|
|
175
|
-
const replacementBaseContent = usedFuzzyMatch
|
|
176
|
-
? normalizeForFuzzyMatch(normalizedContent)
|
|
177
|
-
: normalizedContent;
|
|
225
|
+
let usedFuzzyMatch = false;
|
|
178
226
|
const matchedEdits = [];
|
|
179
227
|
for (let i = 0; i < normalizedEdits.length; i++) {
|
|
180
228
|
const edit = normalizedEdits[i];
|
|
181
|
-
const
|
|
229
|
+
const exactIndex = normalizedContent.indexOf(edit.oldText);
|
|
230
|
+
const matchResult = exactIndex !== -1 ? {
|
|
231
|
+
found: true, index: exactIndex, matchLength: edit.oldText.length, usedFuzzyMatch: false,
|
|
232
|
+
} : matchMode === "fuzzy" ? fuzzyFindText(normalizedContent, edit.oldText) : { found: false };
|
|
182
233
|
if (!matchResult.found) {
|
|
183
234
|
throw new Error(`Could not find match for oldText in ${path}: ${JSON.stringify(edit.oldText.slice(0, 80))}`);
|
|
184
235
|
}
|
|
185
|
-
const
|
|
236
|
+
const fuzzy = Boolean(matchResult.usedFuzzyMatch);
|
|
237
|
+
usedFuzzyMatch ||= fuzzy;
|
|
238
|
+
const occurrences = countOccurrences(fuzzy ? normalizeForFuzzyMatch(normalizedContent) : normalizedContent, fuzzy ? normalizeForFuzzyMatch(edit.oldText) : edit.oldText);
|
|
186
239
|
if (occurrences > 1) {
|
|
187
240
|
throw new Error(`Found ${occurrences} occurrences of oldText in ${path} (must be unique): ${JSON.stringify(edit.oldText.slice(0, 80))}`);
|
|
188
241
|
}
|
|
@@ -202,13 +255,11 @@ export function applyEditsToNormalizedContent(normalizedContent, edits, path) {
|
|
|
202
255
|
}
|
|
203
256
|
}
|
|
204
257
|
const baseContent = normalizedContent;
|
|
205
|
-
const newContent =
|
|
206
|
-
? applyReplacementsPreservingUnchangedLines(normalizedContent, replacementBaseContent, matchedEdits)
|
|
207
|
-
: applyReplacements(replacementBaseContent, matchedEdits);
|
|
258
|
+
const newContent = applyReplacements(normalizedContent, matchedEdits);
|
|
208
259
|
if (baseContent === newContent) {
|
|
209
260
|
throw new Error(`No changes made to ${path}: newText equals oldText.`);
|
|
210
261
|
}
|
|
211
|
-
return { baseContent, newContent };
|
|
262
|
+
return { baseContent, newContent, matchedEdits, usedFuzzyMatch };
|
|
212
263
|
}
|
|
213
264
|
/**
|
|
214
265
|
* Generate a standard unified patch.
|
|
@@ -239,7 +290,7 @@ export function generateDiffString(oldContent, newContent, contextLines = 4) {
|
|
|
239
290
|
raw.pop();
|
|
240
291
|
}
|
|
241
292
|
if (part.added || part.removed) {
|
|
242
|
-
if (firstChangedLine === undefined
|
|
293
|
+
if (firstChangedLine === undefined) {
|
|
243
294
|
firstChangedLine = newLineNum;
|
|
244
295
|
}
|
|
245
296
|
for (const line of raw) {
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
export function hashText(text) {
|
|
3
|
+
return createHash("sha256").update(text, "utf8").digest("hex");
|
|
4
|
+
}
|
|
5
|
+
export function validateReadOptions(options) {
|
|
6
|
+
for (const key of ["offset", "limit", "maxLines", "maxBytes"]) {
|
|
7
|
+
const value = options[key];
|
|
8
|
+
if (value !== undefined && (!Number.isSafeInteger(value) || value < 1)) {
|
|
9
|
+
throw new Error(`${key} must be a positive integer.`);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
if (options.maxBytes !== undefined && options.maxBytes > 1024 * 1024) {
|
|
13
|
+
throw new Error("maxBytes must not exceed 1048576 (1 MiB). Read smaller ranges.");
|
|
14
|
+
}
|
|
15
|
+
if (options.lineNumbers !== undefined && typeof options.lineNumbers !== "boolean") {
|
|
16
|
+
throw new Error("lineNumbers must be a boolean.");
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** Return only complete source lines. Continuation metadata never enters source text. */
|
|
20
|
+
export function readTextContent(text, options = {}) {
|
|
21
|
+
validateReadOptions(options);
|
|
22
|
+
const lines = text.match(/[^\n]*\n|[^\n]+/g) ?? [];
|
|
23
|
+
const totalLines = lines.length;
|
|
24
|
+
const offset = options.offset ?? 1;
|
|
25
|
+
if (offset > Math.max(1, totalLines)) {
|
|
26
|
+
throw new Error(`Offset ${offset} is beyond end of file (${totalLines} lines total)`);
|
|
27
|
+
}
|
|
28
|
+
const maxLines = Math.min(options.limit ?? 200, options.maxLines ?? Infinity);
|
|
29
|
+
const maxBytes = options.maxBytes ?? 16 * 1024;
|
|
30
|
+
const selected = [];
|
|
31
|
+
let bytes = 0;
|
|
32
|
+
let truncatedBy = "lines";
|
|
33
|
+
const stop = Math.min(totalLines, offset - 1 + maxLines);
|
|
34
|
+
const width = String(stop).length;
|
|
35
|
+
for (let index = offset - 1; index < stop; index++) {
|
|
36
|
+
const line = lines[index];
|
|
37
|
+
const rendered = options.lineNumbers
|
|
38
|
+
? `${String(index + 1).padStart(width, " ")} | ${line}` : line;
|
|
39
|
+
const lineBytes = Buffer.byteLength(rendered, "utf8");
|
|
40
|
+
if (bytes + lineBytes > maxBytes) {
|
|
41
|
+
if (selected.length === 0) {
|
|
42
|
+
throw new Error(`Line ${offset} needs ${lineBytes} bytes. Increase maxBytes (up to 1048576) or read another line.`);
|
|
43
|
+
}
|
|
44
|
+
truncatedBy = "bytes";
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
selected.push(rendered);
|
|
48
|
+
bytes += lineBytes;
|
|
49
|
+
}
|
|
50
|
+
const endLine = offset - 1 + selected.length;
|
|
51
|
+
const truncated = endLine < totalLines;
|
|
52
|
+
return {
|
|
53
|
+
content: selected.join(""),
|
|
54
|
+
contentHash: hashText(text),
|
|
55
|
+
totalLines,
|
|
56
|
+
startLine: offset,
|
|
57
|
+
endLine,
|
|
58
|
+
truncated,
|
|
59
|
+
...(truncated ? { nextOffset: endLine + 1, truncatedBy } : {}),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -57,41 +57,118 @@ export function searchInContent(filePath, content, options) {
|
|
|
57
57
|
}
|
|
58
58
|
return { matches, totalMatches };
|
|
59
59
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if (options.regex) {
|
|
66
|
-
searchRegex = new RegExp(options.pattern, flags);
|
|
60
|
+
/** Validate the entire request before any project files can be mutated. */
|
|
61
|
+
export function resolveReplacementRules(options) {
|
|
62
|
+
const hasSingle = options.pattern !== undefined || options.replacement !== undefined;
|
|
63
|
+
if (options.replacements !== undefined && hasSingle) {
|
|
64
|
+
throw new Error("Use either replacements or pattern/replacement, not both.");
|
|
67
65
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
66
|
+
if (options.output !== undefined && !["summary", "diff", "full"].includes(options.output)) {
|
|
67
|
+
throw new Error("output must be summary, diff, or full.");
|
|
68
|
+
}
|
|
69
|
+
if (options.replacements !== undefined &&
|
|
70
|
+
(options.regex !== undefined || options.ignoreCase !== undefined || options.limit !== undefined)) {
|
|
71
|
+
throw new Error("For batch replacements, set regex, ignoreCase, and limit on each rule.");
|
|
72
|
+
}
|
|
73
|
+
const rules = options.replacements ?? [{
|
|
74
|
+
pattern: options.pattern, replacement: options.replacement,
|
|
75
|
+
regex: options.regex, ignoreCase: options.ignoreCase, limit: options.limit,
|
|
76
|
+
}];
|
|
77
|
+
if (!Array.isArray(rules) || rules.length === 0) {
|
|
78
|
+
throw new Error("replacements must be a non-empty array.");
|
|
79
|
+
}
|
|
80
|
+
for (const [index, rule] of rules.entries()) {
|
|
81
|
+
if (!rule || typeof rule.pattern !== "string" || !rule.pattern.length ||
|
|
82
|
+
typeof rule.replacement !== "string") {
|
|
83
|
+
throw new Error(`Replacement ${index + 1} requires a non-empty pattern and a string replacement.`);
|
|
84
|
+
}
|
|
85
|
+
for (const key of ["regex", "ignoreCase"]) {
|
|
86
|
+
if (rule[key] !== undefined && typeof rule[key] !== "boolean") {
|
|
87
|
+
throw new Error(`Replacement ${index + 1}: ${key} must be a boolean.`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (rule.limit !== undefined && (!Number.isInteger(rule.limit) || rule.limit < 0)) {
|
|
91
|
+
throw new Error(`Replacement ${index + 1}: limit must be a non-negative integer.`);
|
|
92
|
+
}
|
|
93
|
+
if (rule.regex) {
|
|
94
|
+
try {
|
|
95
|
+
new RegExp(rule.pattern, rule.ignoreCase ? "gi" : "g");
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
throw new Error(`Replacement ${index + 1} has an invalid regular expression.`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
71
101
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
102
|
+
return rules;
|
|
103
|
+
}
|
|
104
|
+
export function replaceInContent(filePath, content, options) {
|
|
105
|
+
const result = replaceBatchInContent(filePath, content, [options]);
|
|
106
|
+
return { ...result, diff: result.diff, patch: result.patch };
|
|
107
|
+
}
|
|
108
|
+
/** Rules run in order on the result of the previous rule. Diff covers the whole batch. */
|
|
109
|
+
export function replaceBatchInContent(filePath, content, rules, output = "full") {
|
|
110
|
+
resolveReplacementRules({ replacements: rules, output });
|
|
111
|
+
let updatedText = content;
|
|
112
|
+
const replacementCounts = [];
|
|
113
|
+
for (const options of rules) {
|
|
114
|
+
let flags = "g";
|
|
115
|
+
if (options.ignoreCase)
|
|
116
|
+
flags += "i";
|
|
117
|
+
let searchRegex;
|
|
118
|
+
if (options.regex) {
|
|
119
|
+
searchRegex = new RegExp(options.pattern, flags);
|
|
77
120
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
121
|
+
else {
|
|
122
|
+
const escaped = options.pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
123
|
+
searchRegex = new RegExp(escaped, flags);
|
|
81
124
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
125
|
+
let count = 0;
|
|
126
|
+
const limit = options.limit;
|
|
127
|
+
updatedText = updatedText.replace(searchRegex, (match, ...args) => {
|
|
128
|
+
if (limit !== undefined && count >= limit) {
|
|
129
|
+
return match;
|
|
130
|
+
}
|
|
131
|
+
count++;
|
|
132
|
+
if (!options.regex) {
|
|
133
|
+
return options.replacement;
|
|
134
|
+
}
|
|
135
|
+
// The final callback arguments are offset/input and optional named groups,
|
|
136
|
+
// not captures. Implement JS replacement tokens without exposing metadata.
|
|
137
|
+
const hasNamedGroups = typeof args[args.length - 1] === "object";
|
|
138
|
+
const groups = hasNamedGroups ? args[args.length - 1] : undefined;
|
|
139
|
+
const input = args[args.length - (hasNamedGroups ? 2 : 1)];
|
|
140
|
+
const offset = args[args.length - (hasNamedGroups ? 3 : 2)];
|
|
141
|
+
const captures = args.slice(0, hasNamedGroups ? -3 : -2);
|
|
142
|
+
return options.replacement.replace(/\$(\$|&|`|'|\d{1,2}|<[^>]*>)/g, (token, key) => {
|
|
143
|
+
if (key === "$")
|
|
144
|
+
return "$";
|
|
145
|
+
if (key === "&")
|
|
146
|
+
return match;
|
|
147
|
+
if (key === "`")
|
|
148
|
+
return input.slice(0, offset);
|
|
149
|
+
if (key === "'")
|
|
150
|
+
return input.slice(offset + match.length);
|
|
151
|
+
if (key.startsWith("<"))
|
|
152
|
+
return groups ? groups[key.slice(1, -1)] ?? "" : token;
|
|
153
|
+
const index = Number(key);
|
|
154
|
+
if (index > 0 && index <= captures.length)
|
|
155
|
+
return captures[index - 1] ?? "";
|
|
156
|
+
const first = Number(key[0]);
|
|
157
|
+
if (key.length === 2 && first > 0 && first <= captures.length) {
|
|
158
|
+
return (captures[first - 1] ?? "") + key[1];
|
|
159
|
+
}
|
|
160
|
+
return token;
|
|
161
|
+
});
|
|
86
162
|
});
|
|
87
|
-
|
|
163
|
+
replacementCounts.push(count);
|
|
164
|
+
}
|
|
88
165
|
const diffResult = generateDiffString(content, updatedText);
|
|
89
|
-
const patch = generateUnifiedPatch(filePath, content, updatedText);
|
|
90
166
|
return {
|
|
91
167
|
updatedText,
|
|
92
|
-
replacementCount: count,
|
|
93
|
-
|
|
94
|
-
|
|
168
|
+
replacementCount: replacementCounts.reduce((sum, count) => sum + count, 0),
|
|
169
|
+
replacementCounts,
|
|
170
|
+
...(output !== "summary" ? { diff: diffResult.diff } : {}),
|
|
171
|
+
...(output === "full" ? { patch: generateUnifiedPatch(filePath, content, updatedText) } : {}),
|
|
95
172
|
firstChangedLine: diffResult.firstChangedLine,
|
|
96
173
|
};
|
|
97
174
|
}
|
package/dist/protocol/yjs-doc.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import * as Y from "yjs";
|
|
2
|
-
import
|
|
3
|
-
import { applyEditsToNormalizedContent, detectLineEnding, generateDiffString, generateUnifiedPatch, normalizeToLF, restoreLineEndings, splitBom, } from "./edit-diff.js";
|
|
2
|
+
import { applyEditsToNormalizedContent, detectLineEnding, generateDiffString, generateUnifiedPatch, normalizeToLF, restoreLineEndings, splitBom, validateEdits, } from "./edit-diff.js";
|
|
4
3
|
function computeUpdateDiff(doc, remoteUpdate) {
|
|
5
4
|
let stateVector;
|
|
6
5
|
if (remoteUpdate.byteLength > 0) {
|
|
@@ -79,10 +78,8 @@ export function replaceYjsText(remoteUpdate, newContent) {
|
|
|
79
78
|
* directly on the `Y.Text("text")` CRDT, with CRLF/BOM resilience, fuzzy matching,
|
|
80
79
|
* and unified diff generation.
|
|
81
80
|
*/
|
|
82
|
-
export function editYjsText(remoteUpdate, edits, filePath = "file") {
|
|
83
|
-
|
|
84
|
-
throw new Error("At least one edit operation is required");
|
|
85
|
-
}
|
|
81
|
+
export function editYjsText(remoteUpdate, edits, filePath = "file", matchMode = "fuzzy") {
|
|
82
|
+
validateEdits(edits);
|
|
86
83
|
const doc = new Y.Doc();
|
|
87
84
|
try {
|
|
88
85
|
if (remoteUpdate.byteLength > 0) {
|
|
@@ -93,23 +90,50 @@ export function editYjsText(remoteUpdate, edits, filePath = "file") {
|
|
|
93
90
|
const { bom, text: contentWithoutBom } = splitBom(previousText);
|
|
94
91
|
const originalEnding = detectLineEnding(contentWithoutBom);
|
|
95
92
|
const normalizedContent = normalizeToLF(contentWithoutBom);
|
|
96
|
-
const { baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, filePath);
|
|
97
|
-
|
|
98
|
-
//
|
|
99
|
-
const
|
|
93
|
+
const { baseContent, newContent, matchedEdits, usedFuzzyMatch } = applyEditsToNormalizedContent(normalizedContent, edits, filePath, matchMode);
|
|
94
|
+
// Map LF-normalized match boundaries back to original UTF-16 offsets.
|
|
95
|
+
// Preserve all untouched characters, including mixed CRLF/LF line endings.
|
|
96
|
+
const offsets = [0];
|
|
97
|
+
for (let i = 0; i < contentWithoutBom.length; i++) {
|
|
98
|
+
if (contentWithoutBom[i] === "\r" && contentWithoutBom[i + 1] === "\n")
|
|
99
|
+
i++;
|
|
100
|
+
offsets.push(i + 1);
|
|
101
|
+
}
|
|
102
|
+
const rawEdits = [...matchedEdits].reverse().map(edit => {
|
|
103
|
+
const start = offsets[edit.matchIndex];
|
|
104
|
+
const end = offsets[edit.matchIndex + edit.matchLength];
|
|
105
|
+
return { start, end, newText: restoreLineEndings(edit.newText, originalEnding) };
|
|
106
|
+
});
|
|
107
|
+
let updatedContent = contentWithoutBom;
|
|
108
|
+
for (const edit of rawEdits) {
|
|
109
|
+
updatedContent = updatedContent.slice(0, edit.start) + edit.newText + updatedContent.slice(edit.end);
|
|
110
|
+
}
|
|
111
|
+
const finalContent = bom + updatedContent;
|
|
112
|
+
// Use known edit ranges instead of diffing the entire document character by
|
|
113
|
+
// character. Retain each anchor's unchanged prefix/suffix and their CRDT IDs.
|
|
100
114
|
doc.transact(() => {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
115
|
+
for (const edit of rawEdits) {
|
|
116
|
+
const old = contentWithoutBom.slice(edit.start, edit.end);
|
|
117
|
+
let prefix = 0;
|
|
118
|
+
while (prefix < old.length && prefix < edit.newText.length && old[prefix] === edit.newText[prefix])
|
|
119
|
+
prefix++;
|
|
120
|
+
// Never split surrogate pairs when trimming a common prefix/suffix.
|
|
121
|
+
if (prefix > 0 && /[\uD800-\uDBFF]/.test(old[prefix - 1]) && /[\uDC00-\uDFFF]/.test(old[prefix] ?? ""))
|
|
122
|
+
prefix--;
|
|
123
|
+
let suffix = 0;
|
|
124
|
+
while (suffix < old.length - prefix && suffix < edit.newText.length - prefix &&
|
|
125
|
+
old[old.length - 1 - suffix] === edit.newText[edit.newText.length - 1 - suffix])
|
|
126
|
+
suffix++;
|
|
127
|
+
if (suffix > 0 && /[\uDC00-\uDFFF]/.test(old[old.length - suffix]) &&
|
|
128
|
+
/[\uD800-\uDBFF]/.test(old[old.length - suffix - 1] ?? ""))
|
|
129
|
+
suffix--;
|
|
130
|
+
const index = bom.length + edit.start + prefix;
|
|
131
|
+
const removed = old.length - prefix - suffix;
|
|
132
|
+
const inserted = edit.newText.slice(prefix, edit.newText.length - suffix);
|
|
133
|
+
if (removed)
|
|
134
|
+
ytext.delete(index, removed);
|
|
135
|
+
if (inserted)
|
|
136
|
+
ytext.insert(index, inserted);
|
|
113
137
|
}
|
|
114
138
|
});
|
|
115
139
|
const { encodedUpdate, mergedState } = computeUpdateDiff(doc, remoteUpdate);
|
|
@@ -124,6 +148,7 @@ export function editYjsText(remoteUpdate, edits, filePath = "file") {
|
|
|
124
148
|
diff: diffResult.diff,
|
|
125
149
|
patch,
|
|
126
150
|
firstChangedLine: diffResult.firstChangedLine,
|
|
151
|
+
usedFuzzyMatch,
|
|
127
152
|
};
|
|
128
153
|
}
|
|
129
154
|
finally {
|
|
@@ -122,7 +122,7 @@ export const TYPST_MCP_TOOLS = [
|
|
|
122
122
|
},
|
|
123
123
|
{
|
|
124
124
|
name: "typst_read_file",
|
|
125
|
-
description: "Read
|
|
125
|
+
description: "Read project text in bounded pages (default 200 lines/16 KiB) or images as native image blocks. content contains source only; use nextOffset for continuation. contentHash covers the entire raw text file: pass it as expectedHash to typst_edit_file. Compact file metadata by default; output='full' adds metadata, not extra source lines. Use grep to locate relevant lines before reading large files.",
|
|
126
126
|
inputSchema: {
|
|
127
127
|
type: "object",
|
|
128
128
|
properties: {
|
|
@@ -135,12 +135,22 @@ export const TYPST_MCP_TOOLS = [
|
|
|
135
135
|
description: "File path/name (e.g. 'main.typ') or file ID",
|
|
136
136
|
},
|
|
137
137
|
offset: {
|
|
138
|
-
type: "
|
|
138
|
+
type: "integer",
|
|
139
|
+
minimum: 1,
|
|
139
140
|
description: "Line number to start reading from (1-indexed)",
|
|
140
141
|
},
|
|
141
142
|
limit: {
|
|
142
|
-
type: "
|
|
143
|
-
|
|
143
|
+
type: "integer",
|
|
144
|
+
minimum: 1,
|
|
145
|
+
description: "Maximum complete lines to read (default: 200). Use nextOffset to continue.",
|
|
146
|
+
},
|
|
147
|
+
maxBytes: {
|
|
148
|
+
type: "integer", minimum: 1, maximum: 1048576,
|
|
149
|
+
description: "Maximum returned source bytes, including line number prefixes (default: 16384, maximum: 1 MiB). Increase for unusually long lines.",
|
|
150
|
+
},
|
|
151
|
+
output: {
|
|
152
|
+
type: "string", enum: ["compact", "full"],
|
|
153
|
+
description: "compact (default): minimal file metadata; full: all file metadata. Both use offset/limit/maxBytes pagination.",
|
|
144
154
|
},
|
|
145
155
|
lineNumbers: {
|
|
146
156
|
type: "boolean",
|
|
@@ -178,7 +188,7 @@ export const TYPST_MCP_TOOLS = [
|
|
|
178
188
|
},
|
|
179
189
|
{
|
|
180
190
|
name: "typst_edit_file",
|
|
181
|
-
description: "Apply one
|
|
191
|
+
description: "Apply all targeted edits against one original file snapshot, with one save. Prefer short unique oldText anchors and expectedHash from typst_read_file to reject stale edits. Exact matching by default (LF/CRLF resilient); matchMode='fuzzy' opts into Unicode/whitespace fallback. Malformed, ambiguous, or overlapping edits fail without saving. Summary output by default; dryRun previews a bounded diff. Unchanged characters and BOM are preserved.",
|
|
182
192
|
inputSchema: {
|
|
183
193
|
type: "object",
|
|
184
194
|
properties: {
|
|
@@ -192,13 +202,15 @@ export const TYPST_MCP_TOOLS = [
|
|
|
192
202
|
},
|
|
193
203
|
edits: {
|
|
194
204
|
type: "array",
|
|
205
|
+
minItems: 1,
|
|
195
206
|
description: "One or more targeted non-overlapping replacements. Each oldText must be unique in the original file.",
|
|
196
207
|
items: {
|
|
197
208
|
type: "object",
|
|
198
209
|
properties: {
|
|
199
210
|
oldText: {
|
|
200
211
|
type: "string",
|
|
201
|
-
|
|
212
|
+
minLength: 1,
|
|
213
|
+
description: "Unique source substring to replace. All edits match the original file, not earlier edits' results.",
|
|
202
214
|
},
|
|
203
215
|
newText: {
|
|
204
216
|
type: "string",
|
|
@@ -216,12 +228,36 @@ export const TYPST_MCP_TOOLS = [
|
|
|
216
228
|
type: "string",
|
|
217
229
|
description: "Optional replacement text for single replacement shorthand",
|
|
218
230
|
},
|
|
231
|
+
expectedHash: {
|
|
232
|
+
type: "string", pattern: "^[a-fA-F0-9]{64}$",
|
|
233
|
+
description: "Recommended: contentHash from the latest read. Checked against fresh downloaded text inside the file mutation queue; stale files fail without saving.",
|
|
234
|
+
},
|
|
235
|
+
matchMode: {
|
|
236
|
+
type: "string", enum: ["exact", "fuzzy"],
|
|
237
|
+
description: "exact (default) matches source with newline normalization. fuzzy enables Unicode quote/dash/space/NFKC fallback only when an exact match is absent.",
|
|
238
|
+
},
|
|
239
|
+
dryRun: {
|
|
240
|
+
type: "boolean",
|
|
241
|
+
description: "Validate and preview edits without saving (default: false). Returned contentHash still identifies unchanged cloud text.",
|
|
242
|
+
},
|
|
243
|
+
output: {
|
|
244
|
+
type: "string", enum: ["summary", "diff", "full"],
|
|
245
|
+
description: "summary (default): counts, new contentHash, firstChangedLine; diff: also a bounded display diff; full: also bounded patch and full metadata. dryRun defaults to diff.",
|
|
246
|
+
},
|
|
247
|
+
maxDiffBytes: {
|
|
248
|
+
type: "integer", minimum: 1, maximum: 1048576,
|
|
249
|
+
description: "Byte budget per diff/patch field (default: 8192). Each also has a 100-line cap. Truncation is explicit; read affected ranges for full details.",
|
|
250
|
+
},
|
|
219
251
|
preferWebSocket: {
|
|
220
252
|
type: "boolean",
|
|
221
253
|
description: "Push real-time TextUpdate over /editing/v3 WebSocket in addition to REST persistence (default: true)",
|
|
222
254
|
},
|
|
223
255
|
},
|
|
224
256
|
required: ["projectId", "filePath"],
|
|
257
|
+
oneOf: [
|
|
258
|
+
{ required: ["edits"], not: { anyOf: [{ required: ["oldText"] }, { required: ["newText"] }] } },
|
|
259
|
+
{ required: ["oldText", "newText"], not: { required: ["edits"] } },
|
|
260
|
+
],
|
|
225
261
|
},
|
|
226
262
|
},
|
|
227
263
|
{
|
|
@@ -422,7 +458,7 @@ export const TYPST_MCP_TOOLS = [
|
|
|
422
458
|
},
|
|
423
459
|
{
|
|
424
460
|
name: "typst_find_and_replace",
|
|
425
|
-
description: "Find and replace text or
|
|
461
|
+
description: "Find and replace text or regex in one file or project-wide. Use replacements to batch mappings (e.g. Sep → September, Oct → October) in one call with one save per file. Rules run sequentially; later rules see earlier results. Batch output defaults to compact summary; request output='diff' or 'full' for previews. Supports dryRun and per-file mutation serialization. Project-wide changes are not atomic across files.",
|
|
426
462
|
inputSchema: {
|
|
427
463
|
type: "object",
|
|
428
464
|
properties: {
|
|
@@ -438,6 +474,27 @@ export const TYPST_MCP_TOOLS = [
|
|
|
438
474
|
type: "string",
|
|
439
475
|
description: "Replacement text (supports $1, $2 capture groups if regex is true)",
|
|
440
476
|
},
|
|
477
|
+
replacements: {
|
|
478
|
+
type: "array",
|
|
479
|
+
minItems: 1,
|
|
480
|
+
description: "Ordered replacement rules instead of pattern/replacement. Each rule runs on the previous result. Set regex, ignoreCase, and limit per rule. All rules are validated before any writes; each changed file is saved once.",
|
|
481
|
+
items: {
|
|
482
|
+
type: "object",
|
|
483
|
+
properties: {
|
|
484
|
+
pattern: { type: "string", minLength: 1 },
|
|
485
|
+
replacement: { type: "string" },
|
|
486
|
+
regex: { type: "boolean", description: "Treat pattern as regex (default: false)" },
|
|
487
|
+
ignoreCase: { type: "boolean", description: "Case-insensitive matching (default: false)" },
|
|
488
|
+
limit: { type: "integer", minimum: 0, description: "Maximum replacements for this rule per file" },
|
|
489
|
+
},
|
|
490
|
+
required: ["pattern", "replacement"],
|
|
491
|
+
},
|
|
492
|
+
},
|
|
493
|
+
output: {
|
|
494
|
+
type: "string",
|
|
495
|
+
enum: ["summary", "diff", "full"],
|
|
496
|
+
description: "summary: counts and first changed line; diff: also display diff; full: also unified patch. Defaults to summary for batches, full for legacy pattern/replacement calls. Applies to dry runs too.",
|
|
497
|
+
},
|
|
441
498
|
filePath: {
|
|
442
499
|
type: "string",
|
|
443
500
|
description: "Optional file path/name (e.g. 'main.typ'). If omitted, searches and replaces across all text files in the project.",
|
|
@@ -467,7 +524,11 @@ export const TYPST_MCP_TOOLS = [
|
|
|
467
524
|
description: "Push real-time TextUpdate over /editing/v3 WebSocket before REST persistence (default: true)",
|
|
468
525
|
},
|
|
469
526
|
},
|
|
470
|
-
required: ["projectId"
|
|
527
|
+
required: ["projectId"],
|
|
528
|
+
oneOf: [
|
|
529
|
+
{ required: ["pattern", "replacement"], not: { required: ["replacements"] } },
|
|
530
|
+
{ required: ["replacements"], not: { anyOf: [{ required: ["pattern"] }, { required: ["replacement"] }, { required: ["regex"] }, { required: ["ignoreCase"] }, { required: ["limit"] }] } },
|
|
531
|
+
],
|
|
471
532
|
},
|
|
472
533
|
},
|
|
473
534
|
];
|
package/package.json
CHANGED