clipf 0.5.0

Copy file contents to the clipboard, locally or over SSH via OSC 52
clipf-0.5.0 is not a library.

clipf (Rust)

Copy the contents of a file (or any piped output) to the clipboard — from a local desktop or from inside an SSH session. Same CLI as the shell version, byte-identical escape sequence output.

clipf server.conf
grep -v '^#' fw-rules.sh | clipf
clipf -n token.txt          # no trailing newline
clipf --check               # diagnose "why isn't this working"

Install

One command, any Linux/macOS/WSL/Termux machine — downloads the prebuilt binary for your platform, verifies its SHA-256, and puts it on your PATH:

curl -fsSL https://raw.githubusercontent.com/reez455G/clipf/main/install.sh | sh

Native Windows (PowerShell):

irm https://raw.githubusercontent.com/reez455G/clipf/main/install.ps1 | iex

From source, if you have Rust and would rather compile it:

cargo install --git https://github.com/reez455G/clipf --locked

Not yet available: cargo install clipf. The crate isn't published to crates.io yet — publishing needs a CARGO_REGISTRY_TOKEN repo secret that hasn't been configured. .github/workflows/release.yml's publish-crate job is wired to run cargo publish --locked automatically on the next tagged release once that secret exists; until then, use one of the methods above.

install.sh flags: --version v0.5.0 to pin a release, --bin-dir DIR to choose where it lands (default /usr/local/bin as root, $PREFIX/bin on Termux, ~/.local/bin otherwise), --no-path to leave your shell rc files alone, and --build to compile from source instead of downloading. CLIPF_VERSION and CLIPF_BIN_DIR work as environment equivalents.

Deploy to a remote host

./install.sh --remote user@host

The binary is downloaded and checksum-verified locally, then copied over the SSH connection you already have. The remote host needs no internet access, no compiler, and no clipboard tooling — the Linux builds are static musl binaries, so they run on anything from CentOS 7 to Alpine.

Platform support

Platform Prebuilt Default backend
Linux x86_64 / aarch64 yes (static musl) wl-copy/xclip/xsel on a desktop, OSC 52 headless
macOS x86_64 / arm64 yes pbcopy
Windows x86_64 yes clip.exe
WSL use the Linux x86_64 build clip.exe
Android / Termux use the Linux build for your device termux-clipboard-set
Other Unix (BSD, illumos, …) build from source OSC 52, or xclip/xsel

Shell completions

Bash, zsh, and fish completions ship attached to every release (clipf.bash/clipf.zsh/clipf.fish), generated at release time — no compiler or clipf install needed just to get them. Or generate them yourself from an installed clipf:

clipf --completions bash | sudo tee /etc/bash_completion.d/clipf
clipf --completions zsh > "${fpath[1]}/_clipf"    # any dir on your $fpath
clipf --completions fish > ~/.config/fish/completions/clipf.fish

What it needs

To build: Rust 1.70 or newer. Nothing else — zero dependencies, std only. base64, argument parsing and tty handling are all hand-rolled, so cargo build works on an air-gapped machine and there is no supply chain to audit.

To run: nothing. A single binary, ~415 KB stripped.

Optional, only for local desktop use: xclip/xsel (X11), wl-clipboard (Wayland), termux-clipboard-set (Android/Termux). macOS and WSL already have pbcopy/clip.exe.

For the SSH path: nothing on the server, but the local terminal emulator must speak OSC 52. --check will tell you whether yours does.

Build

cargo build --release        # target/release/clipf
cargo test                   # 101 unit tests

Building it yourself

The published Linux assets are already static musl binaries, so old distros (CentOS 7 and its glibc 2.17 included) work straight from the installer above. To reproduce that build locally: rustup target add x86_64-unknown-linux-musl && cargo build --release --target x86_64-unknown-linux-musl — the result has no dynamic dependencies at all (ldd says "not a dynamic executable").

Pushing to a fleet, without the installer:

- name: install clipf
  copy:
    src: target/x86_64-unknown-linux-musl/release/clipf
    dest: /usr/local/bin/clipf
    mode: '0755'

Layout

File Role
src/main.rs input reading, dispatch, size guard
src/cli.rs argument parsing, help text
src/base64.rs RFC 4648 encoder (+ decoder used by tests)
src/backend.rs backend enum, auto-detection, local helper tools
src/osc52.rs escape sequence construction, tmux/screen wrapping
src/term.rs tty opening, multiplexer and emulator detection
src/secret.rs self-wiping byte buffer
src/check.rs --check diagnostics
src/exit.rs exit codes and the ClipfError type
src/json.rs hand-rolled JSON writer for --json
src/scan.rs secret-pattern scan for the copy warning
src/completions.rs --completions shell scripts

What this version does that the shell version doesn't

Secrets never touch the disk. The shell version staged input in a temp file under /tmp — meaning .env contents and private keys hit the filesystem, where they survive a crash and may outlive the process. Here the payload stays in memory, in a buffer that overwrites itself on drop with volatile writes the optimiser cannot elide. File reads preallocate the exact size so the buffer never reallocates; stdin reads (unknown length up front) are read in fixed-size chunks that also never reallocate, concatenated into one exact-size buffer once the total is known — so, as of 0.5.0, neither path leaves a stale, unwiped copy behind from a buffer growing mid-read.

This is still best-effort, not a guarantee: nothing here defends against the OS paging a page out to swap, a core dump, or a process with ptrace rights. It is strictly better than a temp file, not a secrets manager.

screen payloads are chunked correctly. GNU screen truncates long DCS passthrough strings. The shell version emitted one oversized DCS and silently lost data; this one splits the sequence into 448-byte chunks that screen reassembles. Verified in tests by reassembling a 5000-byte payload.

--check identifies your actual terminal. It reads emulator-specific variables (KITTY_WINDOW_ID, WEZTERM_PANE, WT_SESSION, TERM_PROGRAM, …) rather than trusting TERM, which is xterm-256color on almost everything, and gives a verdict: supported, not supported, or unknown.

No subprocesses on the OSC 52 path. No base64, no tr, no temp file — one open() and one write(). It also means clipf works on a host where coreutils is missing or broken.

Real error handling. Distinct messages for missing file, directory, permission denied, and missing helper binary, with distinct exit codes.

The size limit, and when to avoid OSC 52

OSC 52 pushes the whole file through the terminal as one escape sequence. Terminals and tmux cap that, and they truncate silently — you get a partial file with no error. clipf refuses above 64 KB by default (exit 3) and reports both the raw and base64-encoded size:

$ clipf big-config.conf
clipf: 1048576 bytes (1398104 once base64-encoded) exceeds the OSC 52 guard of 65536 bytes.
clipf: Terminals truncate oversized payloads without reporting an error,
clipf: so this would most likely copy a partial file. Options:
clipf:   - run from your local shell:  ssh HOST 'cat big-config.conf' | clipf
clipf:   - raise the cap:              clipf --max 0 --force big-config.conf

For anything large, invert the direction and run from your local shell:

ssh ovpn1 'cat /etc/openvpn/server.conf' | clipf

No size limit, no terminal support needed, faster.

Recipes: combining with sed/awk/grep/tail

clipf only does one thing — put bytes on the clipboard, safely, on whatever platform you're on. It deliberately has no --last N, --grep PATTERN, or --between flags: tail, grep, sed and awk already do that job better, are on every machine, and compose freely through a pipe. Slicing the input is not clipf's problem to solve.

tail -n 10 app.log | clipf                    # last 10 lines
head -n 20 app.log | clipf                    # first 20 lines
grep ERROR app.log | clipf                     # only matching lines
sed -n '10,20p' app.log | clipf                # a line range
awk '/START/,/END/' app.log | clipf            # everything between two markers

# these compose over SSH exactly the same way — the pipeline runs on the
# remote host, only the final bytes cross the wire to your local clipboard:
ssh host 'tail -n 10 /var/log/app.log' | clipf
ssh host 'journalctl -u myapp -n 50' | grep ERROR | clipf

tmux

set -g set-clipboard on
set -g allow-passthrough on   # only needed if you use --tmux

Existing panes keep the old setting — run tmux kill-server or start a fresh session after changing it.

Exit codes

Changed in 0.5.0: exit codes are now granular — file errors used to collapse into 1 along with usage errors, and --paste under OSC 52 used to return 1 too. Scripts branching on exact codes should be updated; scripts that only checked "zero or non-zero" are unaffected.

Code Meaning
0 copied (or --dry-run/--check/--help/--version completed)
1 usage error: unknown flag, missing/malformed flag value, conflicting flags, or nothing to read (no FILE, stdin is a terminal)
3 refused: payload exceeds the OSC 52 size guard
4 input error: file missing, is a directory, permission denied, or a read failed
5 backend unavailable: helper binary not found, or the backend has no local helper at all (osc52)
6 backend failed: helper spawned but exited non-zero, or a write into the pipeline failed
7 reserved, not currently reachable — see src/exit.rs
8 -O/--paste against a backend that cannot be read back (OSC 52)

Machine-readable output (--json)

New in 0.5.0. Writes a single JSON object to stdout instead of (or on top of) the usual prose, for scripts and agents that need to branch on structured fields rather than parse stderr text. Not combinable with -p/--print or -O/--paste — both already write their own payload to stdout, and --json is rejected at argument-parsing time if either is also given.

clipf --check --json
clipf --json server.conf         # copy, reporting what happened
clipf --json --dry-run server.conf

--check --json:

{
  "schema": 1,
  "clipf": "0.5.0",
  "os": "linux",
  "backend": { "selected": "osc52", "available": ["osc52"], "source": "auto" },
  "multiplexer": null,
  "emulator": null,
  "osc52": "unknown",
  "tty": "stderr",
  "ssh": false,
  "max_bytes": 65536,
  "warnings": [ { "code": "...", "message": "..." } ]
}

--json on a copy (add -v too if you also want the stderr prose):

{ "schema": 1, "clipf": "0.5.0", "ok": true, "source": "server.conf",
  "bytes": 1234, "encoded_bytes": 1648, "backend": "osc52",
  "stripped_newline": false, "dry_run": false }

On failure ok is false and an error object appears: { "code": 4, "kind": "input", "message": "no such file: x.txt" }code matches the process exit code, kind is the machine-stable name from the table above (usage, too_big, input, backend_unavailable, backend_failed, paste_unsupported). bytes/encoded_bytes/backend are null when the failure happened before they were known — except for the too_big refusal (exit 3), where they're always present, since that's exactly the case where knowing the size is useful even though nothing was copied.

Stability contract: schema only increments on a breaking change to an existing field's meaning or type, or a field's removal. Adding a new field does not bump it — code that reads this JSON should ignore keys it doesn't recognise rather than reject the object outright.