# Djinnvim

> Edit files with the djinnvim CLI — vim-style, pattern-anchored keyhole editing that never reads whole files. Use for any file editing when djinnvim is installed, especially when file/shell tools are restricted or files are large.

- Skill: `anschnapp/djinnvim` (Agent Skill)
- Install (CLI): `npx skillmds@latest add anschnapp/djinnvim`
- Raw SKILL.md: https://api.skillmd.com/api/skills/anschnapp/djinnvim/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: anschnapp (https://skillmd.com/u/anschnapp)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/anschnapp/djinnvim

---


# djinnvim: keyhole editing from the shell

You edit through small viewports, the way a vim user does: search by
pattern, look at the echo, edit. Never read a whole file — every command
returns the few lines around what it did, and that echo IS your
verification (no re-reads needed).

Reach for it when finding or changing something would otherwise mean
reading a whole file, when a change repeats across many sites, or when
file and shell tools are restricted. Creating a file or rewriting one
wholesale is not its job.

## Setup — every command, two rules

1. **Pin the sandbox root in the same shell command** (shell env does not
   persist between your calls):

   ```
   export DJINNVIM_ROOTS=/abs/path/to/project; djinnvim open src/app.py
   ```

2. **Pass each editor command as ONE quoted argument.** Single-quote it;
   switch to double quotes only when the text itself contains a single
   quote:

   ```
   djinnvim edit 'at /old_name/ ciw new_name'
   djinnvim substitute ":%s/'eu-west'/'us-east'/"
   ```

3. **`-f PATH` names the file to work on**, opening it if needed, so
   `edit`, `substitute`, `print`, `matches` and `write` need no separate
   `open` call: `djinnvim edit -f src/app.py 'at /old/ ciw new'`. Without
   it they use the active buffer. A switch is announced in the echo
   (`[now on ... ]`).

State (open buffers, cursor, registers, undo) persists across your shell
calls via an auto-spawned per-session daemon — nothing to start or manage.
`djinnvim status` shows it, `djinnvim shutdown` stops it (unwritten buffers
die with it; only `write` touches disk). Exit codes: 1 = the editor said
`error: ...` (read it — the buffer is untouched), 2 = usage/daemon problem.

## The seven verbs

- `djinnvim open PATH` — open/switch the active buffer and show its head.
  Optional, given `-f` above; use it when you want the file's metadata.
  Relative paths resolve against the root, not your cwd.
- `djinnvim motion CMD` — move the cursor, one motion per call:
  `/pattern` (regex, forward), `?pattern` (back), `n`/`N` (next/prev —
  n is ALWAYS forward, N ALWAYS backward, unlike vim), `:80` (line),
  `gg`/`G`, `fx`/`Fx` (char on the cursor line). Search is strictly after
  the cursor and wraps, reporting `match i of n (wrapped)`.
- `djinnvim matches PATTERN [-C 1]` — grep-style listing of every match
  (capped at 50). **Call this before any rename-like edit** to see all
  sites and decoys.
- `djinnvim edit CMD` — vim normal-mode edit (details below).
- `djinnvim substitute CMD` — ex command (details below).
- `djinnvim print [CMD]` — read-only window print (ed/vim `:p`), the
  reading tool: `'p'` (current line, cursor stays), `':80 p'` /
  `':/def load/ p'` (cursor MOVES there, prints it), `':10,25 p'`
  (range; cursor to its last line). Widen with `above`/`below`/`around`
  + `tiny` (8) / `middle` (25) / `long` (50) or a number —
  `':/def load/ p around middle'`; `around` counts EACH side. Max ~100
  lines per call; page by re-addressing a gutter line number.
- `djinnvim write` — save the active buffer; reports lines changed.
  `write --preview` shows the full pending buffer-vs-disk ±diff without
  writing — the final review before committing.

## edit

Anchored form (preferred): `at /pattern/ <cmd>` (regex) or
`at "literal text" <cmd>` (matched literally — no escaping, use it when
the anchor has parens, dots or other regex punctuation; it cannot contain
a double quote). Ordinal and offset work on both: `at 2nd /pat/ <cmd>`.
**The anchor lands at the START of the match — anchor on the
exact text to change:** `at /15\)/ ciw 60` changes the 15 in
`retries(15)`; `at /retries=15/ ciw 60` would change `retries`.
A `+N`/`-N` after the closing slash moves the anchor N whole lines
(cursor at column 0): `at /# Merge logic/-1 O text` inserts above the
line ABOVE the match — e.g. above a comment banner the match sits inside.

`at each /pattern/ <cmd>` applies one edit command at EVERY match
(transactional: any failure changes nothing; one undo step; returns a
±diff). Text objects make it structural: `at each /# obsolete/ dap`
deletes every marked paragraph whole — no line counting. To go
match-by-match instead, reissue the same `at /pat/ <cmd>`; it anchors on
the NEXT match each time.

Commands: `ciw`/`caw TEXT`, `ci(`/`{`/`[`/`"`/`' TEXT` (`di`/`da`
delete), `dip`/`dap` (paragraph, delete only), `dd`, `cc TEXT`, `D`,
`C TEXT`, `x`, `r<char>`, `o`/`O TEXT` (line below/above; multi-line OK),
`A`/`I TEXT` (line end/start), `i`/`a TEXT` (before/after the cursor
char), `cs"'` / `ds"` / `ysiw"` (surround). Changes need TEXT, deletes
take none; everything after the first space is TEXT, verbatim. Newlines
in TEXT must be **real newline characters** (one Enter each, vim-exact) —
the two characters backslash-n are not translated, they stay as typed, so
source like `print("a\nb")` inserts correctly. (This is the opposite of
`substitute`, where `\n` in the *replacement* does produce a newline.) `o`/`O` are line-wise — to
insert below a multi-line statement, anchor on its LAST line, not its
first. `dd` deletes the cursor line; address it by pattern
(`at /pattern/ dd`) — `edit` takes no ex addresses.

**Indentation is vim autoindent.** The line-wise inserts `o`, `O` and `cc`
take the reference line's indent and your TEXT's own leading whitespace
stacks on top, so pass only the indent BEYOND the anchor's: `o  x = 1`
after a 4-space line lands at 6 spaces, and `cc members = sorted(raw)`
inside an 8-space block stays at 8. `o!`/`O!`/`cc!` opt out and insert
TEXT literally, for a block that is already absolutely indented.
(`substitute` replacements are the other way round — always literal, so
capture the indent there with `^( +)` and `\1`.) `o`/`O` also echo
pre-edit blank-line counts next to where they landed (`2 blank line(s)
above insertion point, 0 below`) — read it to match a file's blank-line
convention (e.g. 2 lines between top-level defs) without counting from
the viewport.

Blank lines are ordinary edits, one call each: bare `o`/`O` with no TEXT
inserts exactly one empty line, and `at /pattern/ dd` on a blank line
removes one. Don't reach for `substitute` (`:/pat/s/^/\n/`, `:N,Nd`) to
fix spacing — take the counts from the `o`/`O` echo and correct with a
single anchored call.

Registers: `yy` / `y<i|a><obj>` yank, `p`/`P` paste. `"name` prefix
composes with the anchor: `at /def helper/ "fn dap` cuts the function,
`"fn p` pastes it (works across files). Only "name-prefixed deletes write
registers; a wrong name on `p` lists them all.

`u` undoes the last buffer change (repeat to go further; crosses writes;
no redo). Any bad echo → `u` reverts it whole.

## substitute

Ex forms: `:%s/old/new/g` (file), `:s/old/new/` (cursor line),
`:10,40s/foo/bar/`, `:/start/,/end/s/x/y/g`, `:g/pat/d` (delete matching
lines). Flags `g`, `i`. **Regex and replacement are Python `re` syntax**
(`\1` groups): escape parens in the PATTERN (`send_request\(x\)`) but
write them plainly in the replacement. Both range addresses are
inclusive; any address takes `+N`/`-N` — end on `/pat/-1` for "up to but
not including". Zero matches is a loud error, never a silent no-op.
Numeric addresses go stale after every edit; prefer pattern addresses.
To rewrite one line into several without retyping its indentation,
capture it: `:s/^( +)old_tail/\1new\n\1    second line/` (`\n` in the
replacement inserts a line break). Replacements are always literal here —
this capture is only needed in `substitute`; `edit`'s `o`/`O`/`cc` supply
the indent for you.

Line-shaped only: to remove whole blocks at every match use
`edit 'at each /pat/ dap'`, not hand-counted ranges. Register ranges for
blocks text objects can't grab (function with internal blank lines):
`:/def helper/,/^def /-1d fn`, then paste with `at /def target/ "fn P`.

## Workflow

1. `open` the file (echo shows size — you never need more than that).
2. `matches` the symbols you'll touch: counts expose decoys
   (`fetch_records` vs `fetch_records_cached` → use `\b...\b`) and plan
   scoping before any edit.
3. Edit smallest-first tool: one-site → anchored `edit`; many-line regex
   → `substitute`; many-site structural → `at each`.
4. **Read every echo.** The diff/viewport is the verification; a wrong
   echo → `edit u` immediately. (The cursor line's `→ ` prefix is exactly
   as wide as other lines' two-space prefix — indentation shown is exact.
   Where the caret appears it also states the line's indentation relative
   to the line above — read that instead of counting leading spaces.)
5. `write --preview` for a final buffer-vs-disk diff, then `write`, and
   check the reported changed-line count against what you expect.
6. **Write before running anything against the file.** Tests, linters,
   and file reads see only the disk — never unwritten buffer state. (A
   disk change under an open buffer fails loudly on the next edit/write;
   re-open to continue.)

