Skip to content

georgemandis/copycat

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

68 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

copycat

Build

A cross-platform clipboard CLI and library written in Zig. Reads and writes arbitrary clipboard formats by their native identifier (UTI on macOS, MIME type on Linux, format ID on Windows) — not just text and images.

Ships as both a CLI executable and a C ABI shared library, so it's usable directly from a shell or via FFI from any language that can load a shared library (Bun, Node, Python, Rust, etc.).

Install

Homebrew (macOS / Linux)

brew install georgemandis/tap/copycat

Scoop (Windows)

scoop bucket add georgemandis https://github.com/georgemandis/scoop-bucket
scoop install georgemandis/copycat

Debian / Ubuntu

# Download the .deb for your architecture (amd64 or arm64)
curl -LO https://github.com/georgemandis/copycat/releases/download/v0.4.4/copycat_0.4.4_amd64.deb
sudo dpkg -i copycat_0.4.4_amd64.deb

Pre-built binaries

Download the latest release from GitHub Releases. Archives are available for macOS (aarch64, x86_64), Linux (aarch64, x86_64), and Windows (x86_64).

Status

Platform Status
macOS ✅ Implemented (NSPasteboard via Objective-C runtime)
Windows ✅ Implemented (Win32 clipboard API)
Linux ✅ Implemented (X11 + Wayland via wlr-data-control)

Built and tested against Zig 0.16.0.

Why

Most clipboard libraries expose a fixed set of types — text, image, files. But the system clipboard is actually a generic key-value store: applications routinely put a dozen representations of the same data on it (plain text, RTF, HTML, app-specific binary formats like Google Docs' com.google.docs.clipboard, etc.). This library treats the clipboard as what it is: a map from format identifiers to raw bytes, which you can list, read, and write directly.

Building from source

Zig 0.16.0 required.

zig build

This produces two artifacts:

  • zig-out/bin/copycat — the CLI executable
  • zig-out/lib/libcopycat.dylib (macOS) / .so (Linux) / .dll (Windows) — the C ABI shared library

Building from source is the recommended path if you want the shared library for FFI use. The package manager installs (Homebrew, Scoop, .deb) include only the CLI binary.

CLI Usage

Running copycat with no arguments prints a formatted overview of everything currently on the clipboard:

$ copycat
Clipboard contents (3 formats, changeCount: 142):

  public.utf8-plain-text    28 bytes
  "Hello, world! This is a test"

  public.html               1204 bytes
  "<div style=\"font-family:sans-serif\">Hello..."

  public.png                24381 bytes
  [89 50 4E 47 0D 0A 1A 0A] ... (PNG image)

Subcommands

Command Description
copycat Show clipboard contents (default)
copycat list List format names, one per line
copycat read <format> Read raw bytes for <format> to stdout
copycat read <format> --out <file> Write raw bytes to a file
copycat write <format> Read data from stdin, write to clipboard
copycat write <format> --data "text" Write inline string data
copycat write <format> --osc52 Write via OSC 52 escape (for SSH)
copycat clear Clear the clipboard
copycat watch Print on every clipboard change (default 500ms poll)
copycat watch --interval <ms> Poll with custom interval
copycat help, --help, -h Show usage

Global flags

  • --json — output structured JSON instead of human-readable text (works with the default introspection and list)

Shell completions

Completion scripts for fish, bash, and zsh live in completions/. They include dynamic completion for format identifiers: typing copycat read <TAB> will complete against whatever is currently on your clipboard (by shelling out to copycat list).

Note: Dynamic format completion requires copycat to be on your $PATH. After zig build, either copy or symlink zig-out/bin/copycat into a directory on $PATH (e.g. ~/.local/bin).

# fish
cp completions/copycat.fish ~/.config/fish/completions/

# bash (user)
echo "source $PWD/completions/copycat.bash" >> ~/.bashrc

# zsh — place _copycat on your $fpath, e.g.:
mkdir -p ~/.zfunc
cp completions/_copycat ~/.zfunc/
# then ensure ~/.zshrc has: fpath=(~/.zfunc $fpath) && autoload -Uz compinit && compinit

Pipe-friendly examples

# Save HTML from clipboard to a file
copycat read public.html > page.html

# Copy a file's contents to clipboard as HTML
cat page.html | copycat write public.html

# Find the format you want
copycat list | grep html

# Diff what an app puts on the clipboard between two states
copycat --json > before.json
# ... do the thing ...
copycat --json > after.json
diff before.json after.json

Remote clipboard (OSC 52)

When working over SSH, you can use the --osc52 flag to copy data to your local machine's clipboard via an OSC 52 terminal escape sequence. Most modern terminals (iTerm2, kitty, alacritty, WezTerm, foot, tmux with set-clipboard on) support this.

# On a remote machine over SSH:
copycat write --osc52 public.utf8-plain-text --data "hello from the server"

# Or via environment variable (useful in .bashrc / .zshrc):
export COPYCAT_OSC52=1
echo "hello" | copycat write public.utf8-plain-text

If you try to write to the clipboard on a headless Linux box without --osc52, copycat will suggest it:

Error: no display server available (is $WAYLAND_DISPLAY or $DISPLAY set?)

Tip: To copy to your local terminal's clipboard over SSH, use:
  copycat write --osc52 <format>

C ABI

The shared library exposes a small, never-panicking C ABI. All errors are reported through return values — there are no aborts or signals raised by the library itself. This is critical for embedding in host processes (Bun, Electron, etc.) where a panic would take down the host.

typedef struct {
    const uint8_t* data;  // NULL on error or format-not-found
    size_t len;           // 0 on error or format-not-found
    int32_t status;       // 0 = success, 1 = format not found, -1 = error
} ClipboardData;

typedef struct {
    const char* format;
    const uint8_t* data;
    size_t len;
} ClipboardFormatPair;

// List formats. Returns a JSON array string. NULL on error.
// Caller must clipboard_free() the returned pointer.
const char* clipboard_list_formats(void);

// Read bytes for a format.
// status 0:  success — caller must clipboard_free(.data)
// status 1:  format not present
// status -1: error
ClipboardData clipboard_read_format(const char* format);

// Write a single format. Clears the clipboard first.
// When len == 0, data may be any value (it is not dereferenced).
int32_t clipboard_write_format(const char* format, const uint8_t* data, size_t len);

// Write multiple formats atomically. Clears once, then writes all.
int32_t clipboard_write_multiple(const ClipboardFormatPair* pairs, uint32_t count);

int32_t clipboard_clear(void);

// Monotonically increments on every clipboard modification.
// Useful for polling-based change detection. Returns -1 if unavailable.
int64_t clipboard_change_count(void);

// Free a pointer previously returned by this library. Safe with NULL.
void clipboard_free(void* ptr);

Bun FFI example

The shared library can be loaded from any language with FFI support. Here's a complete example using Bun:

import { dlopen, FFIType, suffix, ptr, toBuffer, CString } from "bun:ffi";

const lib = dlopen(`./zig-out/lib/libcopycat.${suffix}`, {
  clipboard_list_formats: {
    args: [],
    returns: FFIType.ptr,
  },
  clipboard_read_format_ex: {
    args: [FFIType.cstring, FFIType.ptr, FFIType.ptr, FFIType.ptr],
    returns: FFIType.void,
  },
  clipboard_write_format: {
    args: [FFIType.cstring, FFIType.ptr, FFIType.u64],
    returns: FFIType.i32,
  },
  clipboard_clear: {
    args: [],
    returns: FFIType.i32,
  },
  clipboard_change_count: {
    args: [],
    returns: FFIType.i64,
  },
  clipboard_free: {
    args: [FFIType.ptr],
    returns: FFIType.void,
  },
});

const { symbols: clip } = lib;

// List all formats currently on the clipboard
function listFormats(): string[] {
  const rawPtr = clip.clipboard_list_formats();
  if (!rawPtr) return [];
  const json = new CString(rawPtr);
  const formats = JSON.parse(json.toString());
  clip.clipboard_free(rawPtr);
  return formats;
}

// Read raw bytes for a specific format
function readFormat(format: string): Buffer | null {
  const outData = new BigInt64Array(1);
  const outLen = new BigInt64Array(1);
  const outStatus = new Int32Array(1);

  clip.clipboard_read_format_ex(
    Buffer.from(format + "\0"),
    ptr(outData),
    ptr(outLen),
    ptr(outStatus),
  );

  if (outStatus[0] !== 0) return null;
  const len = Number(outLen[0]);
  if (len === 0) return Buffer.alloc(0);

  const dataPtr = Number(outData[0]);
  const buf = Buffer.from(toBuffer(dataPtr, 0, len));
  clip.clipboard_free(dataPtr);
  return buf;
}

// Write data to the clipboard under a given format
function writeFormat(format: string, data: string | Buffer): boolean {
  const buf = typeof data === "string" ? Buffer.from(data) : data;
  return clip.clipboard_write_format(
    Buffer.from(format + "\0"),
    buf.length > 0 ? ptr(buf) : 0,
    buf.length,
  ) === 0;
}

// --- Usage ---

// Show what's on the clipboard
console.log("Formats:", listFormats());
console.log("Change count:", Number(clip.clipboard_change_count()));

// Read plain text
const text = readFormat("public.utf8-plain-text");
if (text) console.log("Text:", text.toString());

// Write plain text
writeFormat("public.utf8-plain-text", "Hello from Bun FFI!");
console.log("After write:", readFormat("public.utf8-plain-text")?.toString());

The _ex variant of clipboard_read_format uses out-pointers instead of returning a struct, which is more compatible with Bun's FFI. The regular clipboard_read_format returns a struct by value and works better with languages that support that calling convention (C, Rust, etc.).

Node.js FFI example

Node.js doesn't have built-in FFI, but koffi makes it straightforward (npm install koffi — no C compiler needed):

import koffi from "koffi";

const lib = koffi.load("./zig-out/lib/libcopycat.dylib");

const clipboard_list_formats = lib.func("clipboard_list_formats", "str", []);
const clipboard_write_format = lib.func("clipboard_write_format", "int32", [
  "str", "const void *", "uint64",
]);
const clipboard_change_count = lib.func("clipboard_change_count", "int64", []);

// List formats
const formats = JSON.parse(clipboard_list_formats());
console.log(formats); // ["public.utf8-plain-text", "public.html", ...]

// Write text
const msg = Buffer.from("Hello from Node!");
clipboard_write_format("public.utf8-plain-text", msg, msg.length);

Runnable examples

Complete, runnable versions of both examples live in examples/:

zig build                              # build the shared library first

bun run examples/bun-ffi.ts            # Bun (built-in FFI, zero deps)

npm install koffi                      # Node.js (install koffi first)
node examples/node-ffi.mjs

Project Structure

src/
├── clipboard.zig         # Public Zig API; dispatches to platform backend
├── lib.zig               # C ABI exports for the shared library
├── main.zig              # CLI entry point
├── objc.zig              # Objective-C runtime helpers (msgSend, NSString/NSData/NSArray bridging)
├── osc52.zig             # OSC 52 terminal escape sequence formatting (pure Zig, no OS deps)
├── paths.zig             # File URL / path decoding (pure Zig, no OS deps)
└── platform/
    ├── macos.zig         # NSPasteboard backend
    ├── windows.zig       # Win32 clipboard API backend
    └── linux/
        ├── mod.zig       # Linux dispatcher (X11 vs Wayland)
        └── x11.zig       # X11 selections backend
completions/              # Shell completions (fish, bash, zsh)
examples/
├── bun-ffi.ts            # Bun FFI example (list, read, write)
└── node-ffi.mjs          # Node.js FFI example (using koffi)
build.zig                 # Builds both the CLI executable and the shared library

The platform backend is selected at compile time via builtin.os.tag.

Architecture

   CLI (main.zig) ─┐
                    ├─► clipboard.zig ──► platform/<os>.zig ──► system clipboard API
   FFI (lib.zig) ──┘

Both the CLI and the FFI shim depend only on the public clipboard.zig API. Neither knows or cares which platform backend is in use.

Format identifiers

On macOS, formats are Uniform Type Identifiers (UTIs). Common ones:

Format UTI
Plain text public.utf8-plain-text
HTML public.html
RTF public.rtf
PNG public.png
TIFF public.tiff
File URL public.file-url

Apps may also register custom UTIs (e.g. com.google.docs.clipboard, com.adobe.photoshop.image). Use copycat list to see what's actually on the clipboard at any moment.

On Windows, formats are identified by name. Standard formats use CF_* names:

Format Name
Plain text (ANSI) CF_TEXT
Plain text (Unicode) CF_UNICODETEXT
OEM text CF_OEMTEXT
Bitmap (DIB) CF_DIB
Bitmap (DIBv5) CF_DIBV5
File list CF_HDROP
Locale CF_LOCALE

Apps also register custom named formats (e.g. HTML Format, PNG, Chromium Web Custom MIME Data Format).

Note: CF_DIB and BMP files. When you copy an image on Windows, the clipboard stores it as CF_DIB — a raw Device-Independent Bitmap (a BITMAPINFOHEADER followed by pixel data). This is not a complete .bmp file — BMP files require an additional 14-byte BITMAPFILEHEADER prefix. If you save copycat read CF_DIB --out image.bmp, image viewers won't open it. To create a valid BMP, you'd need to prepend the file header yourself. Some formats like PNG (when available from apps like Chromium) are self-contained and can be saved directly.

On Linux (X11/Wayland), formats are MIME types:

Format MIME type
Plain text text/plain
UTF-8 text text/plain;charset=utf-8 or UTF8_STRING
HTML text/html
PNG image/png
File list text/uri-list

Roadmap

  • macOS backend (NSPasteboard)
  • Windows backend (Win32 clipboard API)
  • Linux backend (X11 + Wayland via wlr-data-control)
  • CLI tool with introspection, list, read, write, clear, watch
  • C ABI shared library
  • Bun and Node.js FFI examples
  • OSC 52 support for remote clipboard access over SSH
  • Multi-item clipboard support (currently reads only the first item)
  • Image format conversion helpers (e.g. TIFF ↔ PNG on macOS)

About

A cross-platform clipboard CLI and C ABI library written in Zig

Resources

License

Stars

Watchers

Forks

Sponsor this project

 

Packages

 
 
 

Contributors