A transactional text editor designed specifically for LLM agents.
The model does not see or edit the file directly — it receives numbered fragments from the editor and responds with a batch ("deck") of commands, which is applied to the file atomically in a single pass.
To control integrity and protect against version races, xxhash (xxh64) is used. REV is a 16-character hex hash of the current file contents.
- Installation
- CLI
- Reading lines (GET)
- Creating a file (CREATE)
- Applying a deck (APPLY)
- Deck structure
- Deck operations
- Addressing
- Markers
@and$ SKIPmodifier- Versions, REV and version conflict
- Limits
- Security and atomic writing
- MCP server
- Recommended workflow for LLM agents
- Errors
- Quick start
- Full specification
- Project structure
Requirements:
- Python 3.10+
- A virtual environment is recommended
Steps:
# 1. Clone the repository
git clone https://github.com/teb4/deck.git
cd deck
# 2. Create a virtual environment
python -m venv .venv
source .venv/bin/activate
# 3. Install the package
pip install -e .
# 4. (Optional) Install MCP dependencies
pip install -e '.[mcp]'After installation, the following are available:
| Command | Description |
|---|---|
deck-editor |
CLI for manual use |
deck-editor-mcp |
MCP server for Qwen Code and other MCP clients |
Main commands:
deck-editor get <file> <addr>
deck-editor create <file> [<rev>]
deck-editor apply <file> -In the examples below, the deck is passed via stdin. The - symbol means reading the deck from standard input.
Command:
deck-editor get file.py 1-50Response:
REV: a3f5b7c9d1e2f405
000001:def foo():
000002: pass
000003e
000004:def bar():
Format:
REV: <hash>— 16-character xxh64 hash of the current file state.NNNNNN:text— non-empty line, minimum 6 digits.NNNNNNe— empty line, suffixe.
Line numbers appear only in GET and CREATE responses. There are no line numbers in the actual file on disk.
For an absolutely empty file, GET returns only REV:
REV: a3f5b7c9d1e2f405
CREATE is an external CLI command. It is not part of a deck.
CREATE reads the contents of a new file or a full replacement of an existing file from stdin until EOF.
For a new file, REV must not be specified:
printf 'line one\nline two\n' | deck-editor create newfile.txtResponse:
REV: a3f5b7c9d1e2f406
000001:line one
000002:line two
For overwriting an existing file, REV is mandatory:
printf 'new content\n' | deck-editor create existing.txt a3f5b7c9d1e2f405Response:
REV: a3f5b7c9d1e2f406
000001:new content
Rules:
- if the file does not exist,
REVmust not be specified; - if the file exists,
REVis mandatory; - on a successful
CREATE, the full numbered listing of the result is returned, including the newREV; - the contents of
stdinare not scanned as a deck; - lines like
@END,@REPLACE,$END,$VARinCREATEare safe and treated as regular text.
Example error:
printf 'text\n' | deck-editor create newfile.txt a3f5b7c9d1e2f405Expected error:
ERROR: <rev> must not be specified for new file
A deck is intended to modify an existing file.
Applying a deck to a non-existent file is an error. Use CREATE to create a file.
A deck has three modes:
| Mode | Description | Disk write |
|---|---|---|
@DRY |
preview in unified diff format | no |
@DRY_ALL |
preview of the full numbered listing | no |
@APPLY |
atomic application of changes | yes |
deck-editor apply file.py - <<'EOF'
@DRY a3f5b7c9d1e2f405
@REPLACE 2
1.1 Parameter and file processing
@END
EOFResponse:
REV: a3f5b7c9d1e2f406 (would be new)
Original: a3f5b7c9d1e2f405 → Modified: a3f5b7c9d1e2f406
--- original
+++ modified
@@ -2,1 +2,1 @@
- 1.1 Parameter processing
+ 1.1 Parameter and file processing
Operations applied:
- REPLACE lines 2-2 (1 line replaced with 1 line)
If the modified block is larger than 50 lines, the first 10 lines are shown, then ... hidden ..., then the last 10 lines.
deck-editor apply file.py - <<'EOF'
@DRY_ALL a3f5b7c9d1e2f405
@REPLACE 2
1.1 Parameter and file processing
@END
EOFResponse:
REV: a3f5b7c9d1e2f406 (would be new)
000001:# Plan
000002: 1.1 Parameter and file processing
000003:
000004:## Details
For an absolutely empty result, DRY_ALL returns:
REV: a3f5b7c9d1e2f406 (would be new)
(Empty file: 0 lines)
deck-editor apply file.py - <<'EOF'
@APPLY a3f5b7c9d1e2f405
@REPLACE 2
1.1 Parameter and file processing
@END
EOFResponse:
APPLIED successfully
REV: a3f5b7c9d1e2f406 (new)
Operations applied:
- REPLACE lines 2-2 (1 line replaced with 1 line)
A deck consists of a header, a body, and a terminator.
header := marker ("DRY" | "DRY_ALL" | "APPLY") <rev>
marker := "@" | "$"
body := one or more operations
terminator := marker "END"
Example:
@APPLY a3f5b7c9d1e2f405
@REPLACE 2
1.1 Parameter and file processing
@END
Mandatory rules:
- the header must start with
@DRY,@DRY_ALL, or@APPLY; <rev>is mandatory for an existing file;<rev>must be a 16-character xxh64 hash;- the terminator must be the last line of the deck;
- lines after the terminator are forbidden;
- the terminator marker must match the header marker;
- mixing
@and$markers in a single deck is forbidden.
Example with $ marker:
$APPLY a3f5b7c9d1e2f405
$REPLACE 10
@END
$END
Here the payload contains the line @END, but it is not a command because the deck marker is $.
| Command | Address | Payload | SKIP | Description |
|---|---|---|---|---|
@REPLACE <addr> |
N, N-M, N- |
yes | allowed | Replace lines |
@DELETE <addr> |
N, N-M, N- |
no | forbidden | Delete lines |
@INSERT <N> |
only N |
yes | allowed | Insert after line N |
@INSERT_HEAD |
none | yes | allowed | Insert before line 1 |
Important:
- payload is always passed without line numbers;
- operations in a deck are executed sequentially;
- each subsequent operation works with the result of the previous one;
- line numbers may shift after
REPLACE,INSERT,INSERT_HEAD, andDELETE; DELETEstrictly has no payload;DELETEdoes not supportSKIP.
Example error:
@APPLY a3f5b7c9d1e2f405
@DELETE 2
extra payload
@END
Expected error:
ERROR: unexpected payload after DELETE
| Format | Description |
|---|---|
N |
Single line |
N-M |
Lines from N to M inclusive |
N- |
From line N to the end of the file |
Restrictions:
- line numbering starts from 1;
- for the range
N-M,N ≤ Mmust hold; INSERTaccepts only a single addressN;INSERT_HEADdoes not accept an address.
Example error:
@APPLY a3f5b7c9d1e2f405
@INSERT 2-5
text
@END
Expected error:
ERROR: INSERT requires single line address
The main marker is @.
By default, decks are formed like this:
@APPLY a3f5b7c9d1e2f405
@REPLACE 2
new text
@END
The alternative marker is $.
It is used if the payload contains valid Deck commands with the @ marker in the zeroth column, for example:
@END
@REPLACE 1
@INSERT 5
In this case, you can use a deck with the $ marker:
$APPLY a3f5b7c9d1e2f405
$REPLACE 10
@END
@REPLACE 1
$END
If the payload contains both @ and $ commands in the zeroth column, you should use the main @ marker and put the conflicting operation at the end of the deck with the SKIP modifier.
A line is recognized as a Deck command only if the following conditions are met simultaneously:
- the
@or$marker is in the zeroth column; - a reserved word immediately follows the marker;
- the end of the line or a space immediately follows the word.
Reserved words:
DRYDRY_ALLAPPLYREPLACEDELETEINSERTINSERT_HEADEND
Therefore, regular decorators and annotations are not Deck commands:
@app.route
@property
@staticmethod
@OverrideBash variables are also not commands:
$VAR
$ENDIndented code physically cannot be confused with a command because the marker is not in the zeroth column.
SKIP is used only when the payload contains lines that the parser is obliged to recognize as Deck commands.
For example, if you are generating documentation for Deck and the payload contains literal strings:
@END
@REPLACE 1
@INSERT 5
Then an operation with SKIP is needed.
Rules:
SKIPis a boolean flag;SKIPtakes no arguments;- the operation with
SKIPmust be the last in the deck; - any operation after
SKIPis an error; DELETEdoes not supportSKIP;- regular decorators, annotations, and variables do not require
SKIP.
Example error:
@APPLY a3f5b7c9d1e2f405
@REPLACE 1 SKIP 3
text
@END
Expected error:
ERROR: SKIP takes no arguments
Example error:
@APPLY a3f5b7c9d1e2f405
@INSERT 1 SKIP
text
@INSERT 5
more text
@END
Expected error:
ERROR: operation after SKIP
REV protects against applying edits to an outdated context.
Typical cycle:
- read the file via
GET; - get the current
REV; - form a deck with this
REV; - apply the deck via
DRY,DRY_ALL, orAPPLY.
If the file has changed between reading and applying, Deck will reject the deck:
ERROR: version conflict — file changed
After a version conflict, it is forbidden to:
- guess new line numbers;
- reapply the old deck;
- rewrite the entire file bypassing
GET.
You must:
- perform a new
GET; - get the current
REV; - rebuild the deck.
| Limit | Default value | Description |
|---|---|---|
MAX_DECK_LINES |
5000 | total limit of payload lines in a deck |
MAX_CREATE_LINES |
50000 | limit for external CREATE |
If the limit is exceeded, the deck or CREATE is rejected.
All file operations are restricted to the workspace root.
Rules:
- for CLI, the workspace root is the current working directory;
- for the MCP server, the workspace is set at startup via
--workspaceor theWORKSPACEenvironment variable; - if the workspace is not set, the current working directory of the MCP server process is used;
- attempts to go outside the workspace are rejected;
- symbolic links are resolved to the real absolute path;
- a file cannot be modified outside the workspace via a symlink.
Example error:
ERROR: access denied — path outside working directory
The result of APPLY or CREATE is written atomically.
Safe-write is used:
- a temporary file is created in the same directory as the target;
- data is written and flushed via
fsync; - the access permissions of the original file are preserved;
- the target file is atomically replaced via
os.replace(); - on error, the temporary file is deleted;
- the target file remains untouched.
The file on disk must never end up in a partially written, corrupted, or empty intermediate state.
Deck Editor can work as an MCP server and connect to Qwen Code and other MCP clients.
Add to .qwen/settings.json:
{
"mcpServers": {
"deck-editor": {
"command": "/path/to/deck/.venv/bin/deck-editor-mcp",
"args": [
"--workspace",
"/path/to/project"
]
}
}
}Or use a relative path from the project:
{
"mcpServers": {
"deck-editor": {
"command": ".venv/bin/deck-editor-mcp",
"args": [
"--workspace",
"."
]
}
}
}Alternatively, the workspace can be set via an environment variable:
{
"mcpServers": {
"deck-editor": {
"command": ".venv/bin/deck-editor-mcp",
"env": {
"WORKSPACE": "/path/to/project"
}
}
}
}| Tool | Description |
|---|---|
get |
Read lines of a file. Returns REV and a numbered listing |
create |
Create a new file or overwrite an existing one |
apply |
Apply a deck to a file. Supports @DRY, @DRY_ALL, @APPLY |
Request:
{
"name": "get",
"arguments": {
"file": "src/main.py",
"addr": "1-50"
}
}Response:
REV: a3f5b7c9d1e2f405
000001:def main():
000002: config = load_config()
000003: result = process(config)
000004: return result
000005e
000006:def process(config):
...
According to the specification, the workspace is set at the MCP server startup level, not in the arguments of an individual tool. For another project, launch a separate MCP server with a different workspace:
{
"mcpServers": {
"deck-editor-other-project": {
"command": ".venv/bin/deck-editor-mcp",
"args": [
"--workspace",
"/home/user/projects/other_project"
]
}
}
}For CLI, simply run the command from the desired working directory:
cd /home/user/projects/other_project
deck-editor get src/main.py 1-50Deck is designed with the Unix-way in mind and delegates search to external tools.
Recommended cycle:
- Text search via
ripgrep:rg -n "calculate_total" src/ - Structural search via
ast-grepif you need to find a function, class, or other syntactic block entirely. - Reading context via Deck:
deck-editor get src/main.py 120-160
- Applying the deck:
deck-editor apply src/main.py - <<'EOF' @APPLY a3f5b7c9d1e2f405 @REPLACE 125-130 new code @END EOF
- never apply
REPLACEorDELETEto a file that has not been read viaGETin the current session; - never omit
REVin the deck header; - never guess line numbers;
- on
version conflict, a newGETis mandatory; - do not use
SKIPfor regular decorators like@app.route; - use
SKIPonly if the payload contains literal Deck commands; - place the operation with
SKIPlast in the deck; - always pass the payload without line numbers.
On any error, the file on disk is not modified. The deck is rolled back entirely.
Main errors:
| Error | Cause |
|---|---|
ERROR: deck not terminated by END |
The last line of the deck is not @END or $END |
ERROR: trailing lines after END |
There are lines after the terminator |
ERROR: terminator does not match deck marker |
The header uses @ and the terminator uses $, or vice versa |
ERROR: deck must start with DRY, DRY_ALL or APPLY |
Invalid deck header |
ERROR: <rev> is mandatory for existing files |
REV is missing in the deck header |
ERROR: unexpected payload after DELETE |
Payload was passed after DELETE |
ERROR: SKIP takes no arguments |
SKIP was specified with arguments |
ERROR: operation after SKIP |
There is another operation after the operation with SKIP |
ERROR: INSERT requires single line address |
INSERT was called with a range |
ERROR: INSERT_HEAD takes no address |
INSERT_HEAD was called with an address |
ERROR: version conflict — file changed |
REV in the deck does not match the current file hash |
ERROR: invalid address range — start > end |
In the address N-M, the value M is less than N |
ERROR: address out of file range |
The address is out of the file bounds |
ERROR: file does not exist, use CREATE |
APPLY was applied to a non-existent file |
ERROR: deck size limit exceeded |
MAX_DECK_LINES was exceeded |
ERROR: access denied — path outside working directory |
The path is outside the workspace |
ERROR: file exists, <rev> required to overwrite |
CREATE on an existing file without REV |
ERROR: <rev> must not be specified for new file |
CREATE on a non-existent file with REV |
The full table of errors and validation stages are described in spec.md.
# Install
pip install -e .
# Read file
deck-editor get src/main.py 1-20Example response:
REV: a3f5b7c9d1e2f405
000001:def main():
000002: config = load_config()
000003: result = process(config)
000004: return result
Preview changes:
deck-editor apply src/main.py - <<'EOF'
@DRY a3f5b7c9d1e2f405
@REPLACE 3
result = process(config, strict=True)
@END
EOFExample response:
REV: a3f5b7c9d1e2f406 (would be new)
Original: a3f5b7c9d1e2f405 → Modified: a3f5b7c9d1e2f406
--- original
+++ modified
@@ -3,1 +3,1 @@
- result = process(config)
+ result = process(config, strict=True)
Operations applied:
- REPLACE lines 3-3 (1 line replaced with 1 line)
Apply changes:
deck-editor apply src/main.py - <<'EOF'
@APPLY a3f5b7c9d1e2f405
@REPLACE 3
result = process(config, strict=True)
@END
EOFExample response:
APPLIED successfully
REV: a3f5b7c9d1e2f406 (new)
Operations applied:
- REPLACE lines 3-3 (1 line replaced with 1 line)
Check the result:
deck-editor get src/main.py 1-20Configure the MCP server in .qwen/settings.json.
Specify the workspace via --workspace or WORKSPACE.
The LLM agent will get access to the get, create, and apply tools.
The agent reads the file via get, records the REV, forms a deck, and applies it via apply.
Minimal safe scenario:
get→ getREVand line numbersapply @DRY→ check the diffapply @APPLY→ apply changesget→ check the result
The full specification of the deck language, architecture, and implementation details are in spec.md.
Additional documentation for LLM agents is in agents.md.
deck/
├── deck_editor/
│ ├── __init__.py — package version
│ ├── __main__.py — CLI (get, create, apply)
│ ├── mcp_server.py — MCP server
│ ├── parser.py — deck parser
│ ├── cmd_get.py — GET command
│ ├── cmd_create.py — CREATE command
│ ├── operations.py — REPLACE, DELETE, INSERT
│ ├── apply.py — DRY, DRY_ALL, APPLY
│ └── utils.py — xxhash, atomic_write, errors
├── tests/ — tests
├── spec.md — full specification
├── agents.md — rules for LLM agents
├── pyproject.toml — package configuration
└── README.md — this file