Types the contents of a local file into whatever window has focus, one simulated keystroke at a time, then tells you how to prove the file arrived byte-for-byte.
It exists for remote sessions where the clipboard does not cross the boundary -- VMware Workspace ONE and Horizon, RDP, browser-based consoles -- and you would otherwise be retyping code by hand.
Read the hazards section before you use this on anything that matters. The dangerous outcome is not a run that fails. It is a run that looks like it succeeded and quietly mangled your indentation.
- Python 3.10+
pynput, supplied per-run byuv(see below)- On macOS: Accessibility permission for the terminal app you launch this from
pynput is not installed system-wide, so every invocation goes through uv:
uv run --with pynput python -m type_me FILE [options]Worth an alias in your shell profile:
alias type-me='uv run --with pynput python -m type_me'The rest of this document writes out the full uv run form so the commands are
copy-pasteable as-is.
System Settings > Privacy & Security > Accessibility, and grant it to the terminal application that runs the command -- Terminal, iTerm2, Ghostty, VS Code, whichever one you actually launch from. The permission follows the app, not the script.
Without it, pynput types nothing and raises no error. The run will count
down, report success, and produce an empty target window. type-me preflights
for this and refuses to start, but if you ever see a silent no-op, this is why.
Try it on the included fixture first, into a scratch file, before you point it at real work.
uv run --with pynput python -m type_me examples/sample.py --dry-runThat prints the preflight findings and a plan summary and types nothing. When it
looks right, drop the --dry-run, put the cursor where you want the text, and
let the countdown run:
uv run --with pynput python -m type_me examples/sample.py --mode heredoc --dest /tmp/sample.py --verifyexamples/sample.py is a deliberate torture test: deep nesting, a !, a
$VAR, a backtick, a line containing the heredoc delimiter, trailing
whitespace, and a tab-indented block. If that file survives a round trip
intact, the channel is trustworthy.
This is the failure to worry about.
If the target window is vim in insert mode with autoindent (or smartindent,
or a filetype plugin, or anything else that indents for you), vim adds its own
leading whitespace to each new line, and then your typed leading whitespace
arrives on top of it. Line 2 is indented once too far, line 3 twice, and the
error compounds all the way down the file.
The result is Python that is structurally wrong -- bodies attached to the wrong
block, or an IndentationError a hundred lines from where the drift began --
but which reads as plausible at a glance and diffs badly against nothing,
because you have nothing to diff it against.
Use --mode vim whenever the target is vim. It sends Esc :set paste and
enters insert mode before typing, and Esc :set nopaste afterwards. paste
mode is exactly the switch that turns off autoindent, abbreviation expansion,
and anything else that would rewrite keystrokes on the way in.
If you forget, the checksum in --verify will catch it. That is what the
checksum is for.
At an interactive bash prompt, ! is history expansion, and it fires inside a
heredoc body. A line containing print("done!") can silently become something
else entirely, or abort the command. This is not theoretical; it is the default
behaviour of an interactive bash.
--mode heredoc emits set +H before the heredoc for exactly this reason.
A heredoc written <<EOF performs parameter and command substitution on its
body. $HOME becomes your home directory, and a backtick pair is executed.
--mode heredoc always quotes the delimiter -- <<'TYPEOUT_EOF' -- which
disables both, so the body is written literally.
The delimiter is also chosen so that it does not collide with any line already
present in your file. A file that happens to contain the word TYPEOUT_EOF on
a line of its own would otherwise terminate the heredoc early and dump the rest
of your source into the shell as commands.
Workspace ONE, Horizon, RDP and the browser-based consoles all funnel keystrokes through a network protocol that will coalesce or drop them under load. The defaults here are slow on purpose.
Runs of identical characters are the most commonly coalesced, and the most
common run of identical characters in source code is indentation. Losing
two spaces out of eight is a silent, structural corruption. That is what
--repeat-delay exists for: extra delay whenever a character is the same as the
one before it.
--jitter breaks up the perfectly uniform keystroke stream, which some remote
input paths handle poorly.
Tabs are expanded to spaces by default, at --tab-width 4. The reason is that a
literal tab at a shell prompt triggers completion rather than inserting a tab
character -- you would get a list of filenames, or a mangled line, instead of
indentation.
--keep-tabs sends real tab characters. Only use it when the target is a real
editor, and only when you actually need tabs preserved (a Makefile, or a
tab-indented codebase). It will also change the checksum relative to a
tab-expanded run, which is the point.
Preflight reports every non-ASCII character it finds, with line numbers. These are the characters most likely to map wrong across a remote keyboard channel: smart quotes pasted in from a document, en-dashes, non-breaking spaces, accented letters, emoji. They may arrive as the wrong character, or as nothing at all.
The report is informational -- the run proceeds -- but if your file is meant to be plain ASCII and preflight finds a U+00A0 where you expected a space, fix the source file rather than the transmission.
Press F9. It stops immediately.
F9 was chosen because type-me never injects that key. Every other candidate
key is one the tool might itself type as part of your file's contents, and the
abort listener cannot distinguish a synthetic keystroke from a real one. F9 is
never in the output stream, so a press is unambiguously you.
Ctrl-C will not work. While typing is in progress the target window has focus, not the terminal you launched from, so the interrupt goes to the wrong process. Do not go hunting for the source terminal mid-run -- clicking around changes focus and the remaining keystrokes land wherever you clicked. Press F9.
If a run is aborted, the far side holds a partial file. In heredoc mode the
heredoc is left unterminated, so the shell is sitting at a continuation prompt;
type the delimiter to close it, or Ctrl-C in the remote shell to discard. In
every case, verify or delete the partial result. Never assume an aborted run
left nothing behind.
Types the file exactly as-is, nothing added, nothing wrapped. Use it anywhere: an editor in paste mode, a text field, a web form, a chat box.
uv run --with pynput python -m type_me notes.txtraw into a shell prompt means each line is executed as a command. That is
sometimes what you want, and it is why raw is the default -- but if what you
want is a file on the far side, use heredoc.
raw carries the vim autoindent hazard in full. If the target is vim, use
vim mode.
For a shell prompt on the far side. Wraps your file so that the remote shell writes it to disk:
set +H
cat > /path/on/remote <<'TYPEOUT_EOF'
...your file, verbatim...
TYPEOUT_EOF
Three protections, all described above: set +H disables history expansion so
! survives, the quoted delimiter disables $VAR and backtick expansion, and
the delimiter itself is chosen not to collide with a line in your file.
This is the safest mode. It is also the only one that can verify itself, because there is a file on the far side to checksum -- so it types the checksum command for you once the heredoc closes.
uv run --with pynput python -m type_me deploy.py --mode heredoc --dest /srv/app/deploy.py --verify--dest is required here. Without a destination there is nothing to redirect
into.
Prefer this mode even when your eventual target is an editor: land the file on disk at a shell, checksum it, then open it. It removes the editor from the trusted path entirely.
For typing into vim. Sends Esc, :set paste, i before the content and
Esc, :set nopaste after.
uv run --with pynput python -m type_me model.py --mode vimPut the cursor where you want the text and leave vim in normal or insert mode
-- the leading Esc normalises either one. The file is typed into the buffer;
saving it is yours to do.
This mode prevents the compounding-indentation failure described above. It does
not prevent everything: if your vim has mappings or an InsertLeave autocmd
that rewrites the buffer, paste mode will not save you. When in doubt, use
heredoc at a shell and open the file afterwards.
A run that reports success has proved only that keystrokes were sent. It has not proved they arrived. For anything you care about, verify.
The recommended workflow, end to end:
uv run --with pynput python -m type_me deploy.py --mode heredoc --dest /srv/app/deploy.py --verify- The tool preflights the file, prints the plan, and asks you to confirm.
- You confirm, then have
--start-delayseconds (5 by default) to click into the remote window and put the cursor at a shell prompt. - It types the heredoc, then types the checksum command on the far side and presses Enter. The remote shell prints a hash.
- Back in your local terminal, the tool is waiting at a prompt. Type the hash the remote side printed.
- It compares against the digest of what was sent, and prints
MATCHorMISMATCH.
Expected sha256 3f0a1c9b8e2d47a5c6109fbb4e7d2a83c5f1e09d7b4a6c28e35f81d0a9c47b6e
Expected md5 8e2d47a5c6109fbb4e7d2a83c5f1e09d
CHECKSUM MATCH (sha256) 3f0a1c9b8e2d47a5c6109fbb4e7d2a83c5f1e09d7b4a6c28e35f81d0a9c47b6e
The preflight prints src sha256 / src md5. The postflight prints
Expected sha256 / Expected md5. Compare the far side against the
Expected pair, never the src pair.
They are not always the same number, because what lands on the far side is not always what is on your local disk:
- tabs are expanded to spaces (unless
--keep-tabs) - CRLF and lone-CR line endings become LF
heredocmode forces a trailing newline, because a heredoc whose terminator is glued to the last body line never closes
When any of these apply, the tool says so under the expected digests:
Expected sha256 b203770cae481081c939e97190fe0899...
Expected md5 b203770cae481081c939e97190fe0899
(the far-side file is not byte-identical to your local copy, which is expected:)
- 2 tabs expanded to spaces (tab width 4)
This distinction is the difference between a check that works and one you learn to ignore. Comparing a tab-indented file against its source digest reports MISMATCH on a perfectly successful transfer; do that twice and you stop reading the output, which is worse than having no check at all.
On MISMATCH, do not try to patch the file by hand. Delete it on the far side
and run again more slowly -- raise --delay and --repeat-delay first, since
dropped keystrokes are the usual cause.
In raw and vim modes there is no remote file to hash automatically, so
--verify prints the command for you to run yourself once you have saved the
buffer:
sha256sum /path/on/remoteOn a BSD or macOS far side that is:
shasum -a 256 /path/on/remoteA checksum over a file that has not been saved yet proves nothing. Write the buffer first.
Defaults target a remote desktop channel. On a local terminal they are needlessly slow; in a browser-based console they are too fast.
| Target | --delay |
--newline-delay |
--repeat-delay |
|---|---|---|---|
| Local terminal, same machine | 2 |
20 |
0 |
| Workspace ONE / Horizon / RDP | 15 (default) |
80 (default) |
10 (default) |
| Browser console (Guacamole, vSphere, iDRAC) | 40 |
150 |
20 |
Browser-based consoles are the slowest of the three because keystrokes pass through a JavaScript keyboard handler before they reach the protocol. Start at 40 ms and go up, not down.
uv run --with pynput python -m type_me script.sh --delay 2 --repeat-delay 0uv run --with pynput python -m type_me script.sh --delay 40 --newline-delay 150 --repeat-delay 20At the defaults, expect roughly a minute per 3 KB. --dry-run reports the
estimate before you commit to it. If a file is large enough that the estimate
makes you wince, that is a signal to find a real file-transfer path rather than
to turn the delays down.
type-me FILE [options]
--mode {raw,heredoc,vim} what to wrap the content in (default: raw)
--dest PATH destination path on the far side;
REQUIRED when --mode heredoc
--delay MS per-character delay, milliseconds (default: 15)
--newline-delay MS extra settle time after Enter (default: 80)
--repeat-delay MS extra delay when a character repeats (default: 10)
--jitter FRAC randomize delay by +/- this fraction (default: 0.30)
--start-delay SEC countdown before typing begins (default: 5)
--keep-tabs do not expand tabs to spaces
--tab-width N spaces per tab when expanding (default: 4)
--dry-run preflight and plan summary; type nothing
--verify after typing, prompt for the far-side checksum
and compare
--yes skip the interactive confirmation
Notes on a few of these:
--delay,--newline-delayand--repeat-delayare in milliseconds on the command line.--start-delayis in seconds.--jitteris a fraction of--delay, so the default 0.30 with a 15 ms delay gives a range of roughly 10.5 to 19.5 ms per character.--repeat-delayis additive on top of--delay, and applies only when a character is identical to the one before it.--yesskips the confirmation prompt but not the--start-delaycountdown. You still need those seconds to change focus.--dry-runis the right first move on any file you have not typed before.
Four modules, split along one line: the code that decides what to type is pure and fully testable, and the code that touches the keyboard is as thin as it can possibly be.
type_me/actions.py-- the frozen contract.Text,Key,Pause, theOptionsdataclass that holds every default, and theBackendprotocol. Imports nothing, touches nothing. Both sides depend on this and not on each other.type_me/core.py-- pure. Takes file text plusOptionsand returns a list of actions. Tab expansion, heredoc wrapping, delimiter selection, vim'sset pastebracketing, pacing decisions, preflight findings: all of it happens here, and none of it requires a keyboard.type_me/executor.py-- impure, and deliberately dull. Walks the action list and presses keys viapynput. It makes no decisions.type_me/cli.py-- argument parsing, preflight reporting, the confirmation prompt, the countdown, checksum comparison.
The point of the split is that the risky logic -- delimiter collision, tab
expansion, indentation handling -- is exercised in tests against a list of
actions, with no simulator, no focused window and no flakiness. If you change
how a file is turned into keystrokes, core.py is where it goes and where the
test belongs.
uv run --with pytest python -m pytest -qexamples/sample.py is 36 lines of valid, runnable Python containing every
hazard on this page at once:
- nesting six levels deep, so vim autoindent drift is visible immediately
- a string with
!in it, for history expansion - a
$VARand a${HOME}, for parameter expansion - a backtick pair in a comment, for command substitution
- a string containing
TYPEOUT_EOF, for delimiter collision - one line ending in trailing whitespace, which you cannot see and the checksum can
- a tab-indented block at the bottom
It runs, so a round trip can be checked two ways:
python3 examples/sample.pysha256sum examples/sample.pyType it, run both on the far side, compare. If the output matches and the hash matches, the channel is good.