@volcanic-dev/tephra 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -1
- package/package.json +1 -1
- package/server.js +165 -28
package/README.md
CHANGED
|
@@ -20,9 +20,17 @@ npx -y @volcanic-dev/tephra
|
|
|
20
20
|
|------|--------------|
|
|
21
21
|
| `copy` | Copy one or more ranges `{start_line, end_line, whole_lines?, start_char?, end_char?, slot?, expect?}` from a file into clipboard slots. Both ends inclusive, 1-indexed. Newlines inside a range are included. |
|
|
22
22
|
| `cut` | Same as `copy`, but removes the ranges from the source file (ranges must be disjoint). Whole-line cuts take the newline too — no empty line left behind. |
|
|
23
|
-
| `paste` | Insert clipboard slots into a file at one or more targets `{line, char?, slot?, expect?}` — before the character at `(line, char)`; `char` defaults to 1 and may be line length + 1 to append to a line.
|
|
23
|
+
| `paste` | Insert clipboard slots into a file at one or more targets `{line, char?, slot?, expect?}` — before the character at `(line, char)`; `char` defaults to 1 and may be line length + 1 to append to a line. Add `end_line` (or `whole_lines: true`) to **replace** that range with the slot instead — the result shows exactly what was removed. |
|
|
24
|
+
| `move` | Cut + paste in **one atomic call**: `move(file, ranges, to: {file?, line, char?, expect?})`. Source ranges *and* destination are all given in the file's current coordinates — the server does the shift arithmetic, so "move lines 120–184 to line 200" just works even though the cut renumbers line 200. Cross-file moves allowed. |
|
|
24
25
|
| `peek` | List the slots that hold text, or show one slot's size and preview. |
|
|
25
26
|
|
|
27
|
+
The commonest workflows are one call each: relocate code with `move`, overwrite a stale block with a replace-mode `paste`, duplicate with `copy` + `paste`.
|
|
28
|
+
|
|
29
|
+
Two semantics worth knowing precisely:
|
|
30
|
+
|
|
31
|
+
- **`move` destinations.** A destination *inside* a moved range is a loud error (a range can't move into itself; nothing is modified). A destination *between* moved ranges is well-defined gather-at-point semantics: unmoved text keeps its relative order and the concatenated payload lands exactly at the destination — moving lines 2–3 and 6–7 of an 8-line file to line 5 yields `1, 4, 2, 3, 6, 7, 5, 8`. A destination at a moved range's own boundary is an in-place move (no change).
|
|
32
|
+
- **`expect` in replace mode anchors at the START of the replaced range.** It is a position check, not a content-of-range check: it verifies the text beginning at the anchor (and may extend past the range's end), so pass the stale block's first characters — you don't need to reproduce the whole span you're overwriting.
|
|
33
|
+
|
|
26
34
|
## Named slots
|
|
27
35
|
|
|
28
36
|
Every range and target takes an optional `slot` (default `"default"`). Gather many snippets in one pass — each into its own slot — then paste them in any order, any number of times. Multi-range calls must name a distinct slot per range.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volcanic-dev/tephra",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "MCP server that gives AI agents a clipboard of their own: copy an exact line:char range from a file, paste it anywhere — byte-for-byte, without touching the OS clipboard.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/server.js
CHANGED
|
@@ -24,9 +24,12 @@
|
|
|
24
24
|
// mismatch modifies nothing and reports where that text actually is now.
|
|
25
25
|
// Batched ranges/targets are applied bottom-up, so one read of a file
|
|
26
26
|
// yields coordinates that stay valid for the whole call, and any edit that
|
|
27
|
-
// changes line numbering reports the shift.
|
|
27
|
+
// changes line numbering reports the shift. The commonest workflows are
|
|
28
|
+
// single atomic calls: `move` is cut + paste with the server doing the
|
|
29
|
+
// shift arithmetic, and a paste target with end_line/whole_lines REPLACES
|
|
30
|
+
// a range instead of inserting.
|
|
28
31
|
//
|
|
29
|
-
// Tools: copy, cut, paste, peek.
|
|
32
|
+
// Tools: copy, cut, paste, move, peek.
|
|
30
33
|
|
|
31
34
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
32
35
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
@@ -234,7 +237,7 @@ async function guarded(fn) {
|
|
|
234
237
|
}
|
|
235
238
|
|
|
236
239
|
// @tephra-tools
|
|
237
|
-
const server = new McpServer({ name: 'tephra', version: '0.
|
|
240
|
+
const server = new McpServer({ name: 'tephra', version: '0.4.1' })
|
|
238
241
|
|
|
239
242
|
const expectSchema = z
|
|
240
243
|
.string()
|
|
@@ -328,30 +331,75 @@ server.registerTool(
|
|
|
328
331
|
}),
|
|
329
332
|
)
|
|
330
333
|
|
|
334
|
+
// @tephra-paste-targets — a paste target either inserts at an anchor or, when
|
|
335
|
+
// end_line/whole_lines is present, REPLACES a range with the slot contents.
|
|
336
|
+
function resolveTarget(text, lines, t, label) {
|
|
337
|
+
const clip = slots.get(t.slot)
|
|
338
|
+
if (clip === undefined || clip.length === 0) {
|
|
339
|
+
throw new PositionError(`${label}: slot "${t.slot}" is empty — nothing to paste. ${slots.size > 0 ? `Slots that do hold text: ${slotList()}.` : 'Use copy or cut first.'}`)
|
|
340
|
+
}
|
|
341
|
+
const isReplace = t.end_line !== undefined || t.whole_lines === true
|
|
342
|
+
if (t.end_char !== undefined && t.end_line === undefined) {
|
|
343
|
+
throw new PositionError(`${label}: end_char requires end_line.`)
|
|
344
|
+
}
|
|
345
|
+
let off
|
|
346
|
+
let endOff
|
|
347
|
+
if (isReplace && t.whole_lines) {
|
|
348
|
+
if (t.end_char !== undefined) throw new PositionError(`${label}: whole_lines replacement takes only line and end_line — omit end_char.`)
|
|
349
|
+
off = lineAt(lines, t.line, label).start
|
|
350
|
+
const endLine = t.end_line ?? t.line
|
|
351
|
+
lineAt(lines, endLine, `${label} end`)
|
|
352
|
+
endOff = endLine < lines.length ? lines[endLine].start : text.length
|
|
353
|
+
} else if (isReplace) {
|
|
354
|
+
off = charOffset(text, lines, t.line, t.char, label, 1)
|
|
355
|
+
endOff = t.end_char === undefined
|
|
356
|
+
? lineAt(lines, t.end_line, `${label} end`).contentEnd
|
|
357
|
+
: charOffset(text, lines, t.end_line, t.end_char, `${label} end`, 0) + 1
|
|
358
|
+
if (endOff <= off) throw new PositionError(`${label}: the range to replace is empty or reversed — the end does not come after the start.`)
|
|
359
|
+
} else {
|
|
360
|
+
off = charOffset(text, lines, t.line, t.char, label, 1)
|
|
361
|
+
endOff = off
|
|
362
|
+
}
|
|
363
|
+
if (t.expect !== undefined) verifyExpect(text, lines, off, t.expect, label)
|
|
364
|
+
return { off, endOff, clip, isReplace }
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function targetDesc(t) {
|
|
368
|
+
if (!(t.end_line !== undefined || t.whole_lines === true)) return `${t.line}:${t.char}`
|
|
369
|
+
if (t.whole_lines) {
|
|
370
|
+
const endLine = t.end_line ?? t.line
|
|
371
|
+
return t.line === endLine ? `line ${t.line}` : `lines ${t.line}–${endLine}`
|
|
372
|
+
}
|
|
373
|
+
return `${t.line}:${t.char} → ${t.end_line}:${t.end_char ?? 'end-of-line'}`
|
|
374
|
+
}
|
|
375
|
+
|
|
331
376
|
server.registerTool(
|
|
332
377
|
'paste',
|
|
333
378
|
{
|
|
334
379
|
title: 'Paste clipboard into file',
|
|
335
380
|
description:
|
|
336
|
-
"Insert Tephra clipboard slot contents into a file at one or more exact positions. " +
|
|
337
|
-
'
|
|
381
|
+
"Insert Tephra clipboard slot contents into a file at one or more exact positions, or replace ranges with them. " +
|
|
382
|
+
'By default text is inserted before the character at (line, char) and nothing is overwritten; char defaults to 1 (right for whole lines) and may be one past the end of the line to append to it. ' +
|
|
383
|
+
'REPLACE MODE: give end_line (plus optional end_char, or whole_lines: true for complete lines) and the slot contents replace that range instead — the result shows exactly what was removed. ' +
|
|
338
384
|
'Multiple targets are applied bottom-up, so they all use the coordinates of the file as you last read it. ' +
|
|
339
|
-
'Pass `expect` (the text that currently begins at the position) to verify before
|
|
340
|
-
'Existing text is never overwritten — it shifts to make room, and the result says how line numbers moved.',
|
|
385
|
+
'Pass `expect` (the text that currently begins at the position) to verify before touching anything; in replace mode it anchors at the START of the replaced range — a position check, which may extend past the range\'s end.',
|
|
341
386
|
inputSchema: {
|
|
342
387
|
file: z.string().describe('Absolute path of the file to paste into'),
|
|
343
388
|
targets: z
|
|
344
389
|
.array(
|
|
345
390
|
z.object({
|
|
346
|
-
line: z.number().int().describe('Line
|
|
347
|
-
char: z.number().int().default(1).describe('Character position
|
|
391
|
+
line: z.number().int().describe('Line of the anchor (1-indexed); in replace mode, the first line of the range being replaced'),
|
|
392
|
+
char: z.number().int().default(1).describe('Character position of the anchor (1-indexed; text is inserted before this character; line length + 1 means end of line). Omit for column 1 — the right choice for whole lines.'),
|
|
393
|
+
end_line: z.number().int().optional().describe('Replace mode: replace from the anchor through this line (1-indexed, inclusive) with the slot contents instead of inserting'),
|
|
394
|
+
end_char: z.number().int().optional().describe('Replace mode: last character replaced on end_line (1-indexed, inclusive). Omit for end-of-line.'),
|
|
395
|
+
whole_lines: z.boolean().optional().describe('Replace mode: replace complete lines line..end_line (end_line defaults to line), INCLUDING the trailing newline. Omit char/end_char.'),
|
|
348
396
|
slot: z.string().min(1).max(64).default('default').describe('Clipboard slot to paste from (defaults to "default")'),
|
|
349
397
|
expect: expectSchema,
|
|
350
398
|
}),
|
|
351
399
|
)
|
|
352
400
|
.min(1)
|
|
353
401
|
.max(20)
|
|
354
|
-
.describe('One or more paste
|
|
402
|
+
.describe('One or more paste/replace targets, all addressed against the file as it is right now — they are applied bottom-up, so earlier targets never invalidate later ones.'),
|
|
355
403
|
},
|
|
356
404
|
},
|
|
357
405
|
async ({ file, targets }) =>
|
|
@@ -359,45 +407,134 @@ server.registerTool(
|
|
|
359
407
|
const text = await readFile(file, 'utf8')
|
|
360
408
|
const lines = indexLines(text)
|
|
361
409
|
const resolved = targets.map((t, idx) => {
|
|
362
|
-
const label = targets.length === 1 ? 'paste
|
|
363
|
-
|
|
364
|
-
if (clip === undefined || clip.length === 0) {
|
|
365
|
-
throw new PositionError(`${label}: slot "${t.slot}" is empty — nothing to paste. ${slots.size > 0 ? `Slots that do hold text: ${slotList()}.` : 'Use copy or cut first.'}`)
|
|
366
|
-
}
|
|
367
|
-
const off = charOffset(text, lines, t.line, t.char, label, 1)
|
|
368
|
-
if (t.expect !== undefined) verifyExpect(text, lines, off, t.expect, label)
|
|
369
|
-
return { ...t, idx, label, off, clip }
|
|
410
|
+
const label = targets.length === 1 ? 'paste target' : `target ${idx + 1}`
|
|
411
|
+
return { ...t, idx, label, ...resolveTarget(text, lines, t, label) }
|
|
370
412
|
})
|
|
413
|
+
const byOff = [...resolved].sort((a, b) => a.off - b.off || a.idx - b.idx)
|
|
414
|
+
for (let i = 1; i < byOff.length; i++) {
|
|
415
|
+
if (byOff[i].off < byOff[i - 1].endOff) {
|
|
416
|
+
throw new PositionError(`${byOff[i].label} falls inside the range replaced by ${byOff[i - 1].label} — targets must not overlap. Nothing was modified.`)
|
|
417
|
+
}
|
|
418
|
+
}
|
|
371
419
|
let out = text
|
|
372
420
|
for (const t of [...resolved].sort((a, b) => b.off - a.off || b.idx - a.idx)) {
|
|
373
|
-
out = out.slice(0, t.off) + t.clip + out.slice(t.
|
|
421
|
+
out = out.slice(0, t.off) + t.clip + out.slice(t.endOff)
|
|
374
422
|
}
|
|
375
423
|
await writeFile(file, out, 'utf8')
|
|
376
424
|
const finalLines = indexLines(out)
|
|
377
|
-
// final coordinates: shift each target by everything
|
|
425
|
+
// final coordinates: shift each target by the net effect of everything applied above it
|
|
378
426
|
const placed = resolved.map((t) => {
|
|
379
|
-
const delta = resolved.reduce((acc, o) => acc + (o.off < t.off || (o.off === t.off && o.idx < t.idx) ? o.clip.length : 0), 0)
|
|
427
|
+
const delta = resolved.reduce((acc, o) => acc + (o.off < t.off || (o.off === t.off && o.idx < t.idx) ? o.clip.length - (o.endOff - o.off) : 0), 0)
|
|
380
428
|
return {
|
|
381
429
|
t,
|
|
430
|
+
removed: text.slice(t.off, t.endOff),
|
|
382
431
|
start: positionOf(finalLines, t.off + delta),
|
|
383
432
|
end: positionOf(finalLines, t.off + delta + t.clip.length),
|
|
384
433
|
}
|
|
385
434
|
})
|
|
386
435
|
if (placed.length === 1) {
|
|
387
|
-
const [{ t, start, end }] = placed
|
|
388
|
-
|
|
436
|
+
const [{ t, removed, start, end }] = placed
|
|
437
|
+
const span = `now spans ${start.line}:${start.char} → ${end.line}:${end.char} (exclusive end)`
|
|
438
|
+
if (t.isReplace) {
|
|
439
|
+
const anchorLine = t.whole_lines || t.end_char === undefined ? (t.end_line ?? t.line) : t.end_line
|
|
440
|
+
return ok(`Replaced ${targetDesc(t)} in ${file} (removed ${describe(removed)} — ${inlinePreview(removed)}) with slot "${t.slot}" (${describe(t.clip)}). The new text ${span}.${shiftNote(anchorLine, newlineCount(t.clip) - newlineCount(removed))}\n\nPreview:\n${preview(t.clip)}`)
|
|
441
|
+
}
|
|
442
|
+
return ok(`Pasted ${describe(t.clip)} from slot "${t.slot}" into ${file} at ${t.line}:${t.char}. The inserted text ${span}.${shiftNote(t.line, newlineCount(t.clip))}\n\nPreview:\n${preview(t.clip)}`)
|
|
389
443
|
}
|
|
390
|
-
const
|
|
444
|
+
const netLines = placed.reduce((acc, { t, removed }) => acc + newlineCount(t.clip) - newlineCount(removed), 0)
|
|
391
445
|
return ok(
|
|
392
|
-
`
|
|
393
|
-
placed.map(({ t, start, end }) =>
|
|
394
|
-
|
|
395
|
-
|
|
446
|
+
`Applied ${placed.length} targets to ${file}, bottom-up (file updated, ${finalLines.length} addressable lines):\n` +
|
|
447
|
+
placed.map(({ t, removed, start, end }) =>
|
|
448
|
+
`- slot "${t.slot}" (${describe(t.clip)}) ${t.isReplace ? `replaced ${targetDesc(t)} (removed ${describe(removed)})` : `at ${t.line}:${t.char}`} → final span ${start.line}:${start.char} → ${end.line}:${end.char}`,
|
|
449
|
+
).join('\n') +
|
|
450
|
+
(netLines !== 0
|
|
451
|
+
? `\nNet line-numbering change: ${netLines > 0 ? '+' : ''}${netLines} — recompute any positions you noted earlier before using them.`
|
|
396
452
|
: ''),
|
|
397
453
|
)
|
|
398
454
|
}),
|
|
399
455
|
)
|
|
400
456
|
|
|
457
|
+
server.registerTool(
|
|
458
|
+
'move',
|
|
459
|
+
{
|
|
460
|
+
title: 'Move ranges (atomic cut + paste)',
|
|
461
|
+
description:
|
|
462
|
+
'Move one or more ranges to a destination in ONE atomic call — the compound of cut and paste. ' +
|
|
463
|
+
'All coordinates, source ranges AND destination, are addressed against the file(s) exactly as they are right now: do not pre-adjust the destination for the lines the cut removes — the server does that arithmetic. ' +
|
|
464
|
+
'The destination may be in a different file. Multiple ranges are concatenated at the destination in listed order, and the moved text is also stored in the clipboard slot(s). ' +
|
|
465
|
+
'The destination may sit between moved ranges (unmoved text keeps its relative order and the payload lands at that point) but never inside one — that is an error. ' +
|
|
466
|
+
'For whole lines, set whole_lines: true on the range and give the destination as a line number only.',
|
|
467
|
+
inputSchema: {
|
|
468
|
+
file: z.string().describe('Absolute path of the source file'),
|
|
469
|
+
ranges: rangesSchema.ranges,
|
|
470
|
+
to: z
|
|
471
|
+
.object({
|
|
472
|
+
file: z.string().optional().describe('Destination file (defaults to the source file)'),
|
|
473
|
+
line: z.number().int().describe('Destination line (1-indexed), in the destination file as it is NOW — do not adjust for the cut'),
|
|
474
|
+
char: z.number().int().default(1).describe('Destination character (1-indexed; the text is inserted before it; line length + 1 means end of line). Omit for column 1 — the right choice for whole lines.'),
|
|
475
|
+
expect: expectSchema,
|
|
476
|
+
})
|
|
477
|
+
.describe('Where the text goes; it is inserted before the character at (line, char)'),
|
|
478
|
+
},
|
|
479
|
+
},
|
|
480
|
+
async ({ file, ranges, to }) =>
|
|
481
|
+
guarded(async () => {
|
|
482
|
+
const text = await readFile(file, 'utf8')
|
|
483
|
+
const lines = indexLines(text)
|
|
484
|
+
const resolved = resolveRanges(text, lines, ranges, true)
|
|
485
|
+
const snippets = resolved.map((r) => text.slice(r.startOff, r.endOff))
|
|
486
|
+
const payload = snippets.join('')
|
|
487
|
+
const sameFile = to.file === undefined || to.file === file
|
|
488
|
+
const destText = sameFile ? text : await readFile(to.file, 'utf8')
|
|
489
|
+
const destLines = sameFile ? lines : indexLines(destText)
|
|
490
|
+
const destOff = charOffset(destText, destLines, to.line, to.char, 'destination', 1)
|
|
491
|
+
if (to.expect !== undefined) verifyExpect(destText, destLines, destOff, to.expect, 'destination')
|
|
492
|
+
if (sameFile) {
|
|
493
|
+
for (const r of resolved) {
|
|
494
|
+
if (destOff > r.startOff && destOff < r.endOff) {
|
|
495
|
+
throw new PositionError(`destination ${to.line}:${to.char} falls inside ${r.label} (${rangeDesc(r)}) — a range cannot be moved into itself. Nothing was modified.`)
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
resolved.forEach((r, i) => slots.set(r.slot, snippets[i]))
|
|
500
|
+
let srcOut = text
|
|
501
|
+
for (const r of [...resolved].sort((a, b) => b.startOff - a.startOff)) {
|
|
502
|
+
srcOut = srcOut.slice(0, r.startOff) + srcOut.slice(r.endOff)
|
|
503
|
+
}
|
|
504
|
+
let start
|
|
505
|
+
let end
|
|
506
|
+
let destFinalCount
|
|
507
|
+
if (sameFile) {
|
|
508
|
+
const adjDest = destOff - resolved.reduce((acc, r) => acc + (r.endOff <= destOff ? r.endOff - r.startOff : 0), 0)
|
|
509
|
+
const out = srcOut.slice(0, adjDest) + payload + srcOut.slice(adjDest)
|
|
510
|
+
await writeFile(file, out, 'utf8')
|
|
511
|
+
const finalLines = indexLines(out)
|
|
512
|
+
destFinalCount = finalLines.length
|
|
513
|
+
start = positionOf(finalLines, adjDest)
|
|
514
|
+
end = positionOf(finalLines, adjDest + payload.length)
|
|
515
|
+
} else {
|
|
516
|
+
// insert into the destination before cutting the source, so a failure
|
|
517
|
+
// between the two writes duplicates text rather than losing it
|
|
518
|
+
const destOut = destText.slice(0, destOff) + payload + destText.slice(destOff)
|
|
519
|
+
await writeFile(to.file, destOut, 'utf8')
|
|
520
|
+
await writeFile(file, srcOut, 'utf8')
|
|
521
|
+
const finalLines = indexLines(destOut)
|
|
522
|
+
destFinalCount = finalLines.length
|
|
523
|
+
start = positionOf(finalLines, destOff)
|
|
524
|
+
end = positionOf(finalLines, destOff + payload.length)
|
|
525
|
+
}
|
|
526
|
+
const destName = sameFile ? file : to.file
|
|
527
|
+
const slotsDesc = resolved.length === 1 ? `slot "${resolved[0].slot}"` : `slots ${resolved.map((r) => `"${r.slot}"`).join(', ')}`
|
|
528
|
+
return ok(
|
|
529
|
+
`Moved ${describe(payload)} from ${file} (${resolved.map((r) => rangeDesc(r)).join(', ')}) to ${destName} ${to.line}:${to.char}. ` +
|
|
530
|
+
`It now spans ${start.line}:${start.char} → ${end.line}:${end.char} (final coordinates, exclusive end; ${sameFile ? 'file' : 'destination file'} has ${destFinalCount} addressable lines${sameFile ? '' : `; source file has ${indexLines(srcOut).length}`}). ` +
|
|
531
|
+
`Also stored in ${slotsDesc}.` +
|
|
532
|
+
'\nLine numbers around both the cut and the insertion have shifted — the span above is authoritative; recompute anything you noted earlier.' +
|
|
533
|
+
`\n\nPreview:\n${preview(payload)}`,
|
|
534
|
+
)
|
|
535
|
+
}),
|
|
536
|
+
)
|
|
537
|
+
|
|
401
538
|
server.registerTool(
|
|
402
539
|
'peek',
|
|
403
540
|
{
|