How I Future-Proofed My Go Clipboard Tool for SSH and X Forwarding

I've been working on a handy Go utility that scans a project directory, builds a file tree, concatenates specified source files, and then copies the whole thing to the system clipboard. It's incredibly useful for quickly feeding an entire codebase into an AI assistant, or for sharing project context.

Initially, I built it using github.com/atotto/clipboard, a popular Go library for clipboard operations. It worked great locally. But then came the remote development workflow – SSH with X forwarding (ssh -X).

The xclip Conundrum 😩

When I ran my tool over ssh -X, it suddenly stopped working. The error messages pointed to xclip not being found. Ah, the age-old problem! The atotto/clipboard library, for X11 environments, typically shells out to external command-line tools like xclip or xsel. This means for my tool to work on a remote server, that server also needed xclip installed.

This was a deal-breaker for me. I wanted a truly self-contained, portable Go binary. Relying on system-level command-line tools on potentially diverse remote environments (where I might not have sudo access or the ability to install packages) defeated the purpose of a standalone executable.

The Search for a Better Way: Pure Go to the Rescue ✨

I needed a solution that would directly speak the X11 protocol, just like a native X application, rather than relying on external utilities. That's when I discovered golang.design/x/clipboard. This library is a game-changer: it's a pure Go implementation of clipboard operations for various platforms, including X11. This means it doesn't need xclip installed; it interacts directly with the X server that ssh -X forwards to your local machine.

The Upgrade & Refactor Process

Migrating to golang.design/x/clipboard wasn't just about swapping out one import for another. It provided a perfect opportunity to refactor the codebase for better organization and maintainability.

  1. Modularizing the Codebase (Refactor) 🏗️

The first step was to break down the monolithic main.go into smaller, focused packages within an internal directory.

copytree/
├── internal/
│   ├── collector/    # Handles file discovery, filtering, and exclusions.
│   ├── gpt/          # Manages GPT-specific logic like text chunking and prompts.
│   ├── summary/      # For printing colored summaries of statistics.
│   └── tree/         # Builds and renders the directory tree structure.
├── go.mod
├── go.sum
└── main.go           # The orchestrator: parses args, calls internal packages.

This structure makes it much easier to understand, test, and expand specific functionalities without impacting the whole application.

  1. Switching Clipboard Libraries (Upgrade) ⬆️

The core change involved updating go.mod and replacing atotto/clipboard with golang.design/x/clipboard.

First, main.go:

// main.go
import (
    // ... other imports
    "golang.design/x/clipboard" // <-- New pure-Go clipboard library
)

func main() {
    // Crucial: Initialize the new clipboard library early.
    err := clipboard.Init()
    if err != nil {
        log.Fatalf("Failed to initialize clipboard: %v\nThis might happen if you are not in a graphical environment. For SSH, ensure you are using X forwarding (ssh -X).", err)
    }

    // ... rest of the main logic ...

    // When writing to clipboard:
    clipboard.Write(clipboard.FmtText, clipboardData)
}
  1. The Sneaky X11 Race Condition (The Final Fix ⏱️)

After implementing the new library, the tool initialized successfully, but the clipboard still often remained empty, even locally! This was puzzling because the clipboard.Init() call wasn't erroring out.

It turned out to be a classic X11 clipboard behavior: race condition. When a program places data on the X11 clipboard, it doesn't immediately "push" it to other applications. Instead, it announces "I have data for the clipboard!" and waits for a clipboard manager (a background process in your desktop environment) to request it.

If my Go program ran, placed the data, and then exited almost instantaneously, the clipboard manager simply didn't have enough time to hear the announcement and fetch the data before the original program was gone. The solution? A simple time.Sleep(1 * time.Second) before the program exits. This brief pause gives the clipboard manager ample opportunity to grab the data.


// main.go (final fix)
import (
    // ... other imports
    "time" // <-- Don't forget this!
)

func main() {
    // ... setup and data collection ...

    if !gptMode {
        clipboard.Write(clipboard.FmtText, clipboardData)
        // ... print messages ...
        time.Sleep(1 * time.Second) // <-- Crucial delay!
        return
    }

    // ... GPT mode logic ...

    // ... print final messages ...
    time.Sleep(1 * time.Second) // <-- Crucial delay here too!
}

The Result: A Robust, Portable Clipboard Tool 🎉

With these changes, my copytree tool is now:

Truly portable: No external dependencies like xclip are needed on the remote server.

Reliable over SSH: It seamlessly works with ssh -X for X11 forwarding.

Well-organized: The refactored structure makes it easier to maintain and extend.

Bulletproofed: The time.Sleep ensures the clipboard data is always successfully transferred.

This experience highlights the power of pure Go libraries for building cross-platform tools and the subtle complexities of X11 that can sometimes catch you off guard. If you're building CLI tools that interact with the clipboard, especially in remote or containerized environments, consider golang.design/x/clipboard and remember that brief pause before exiting!