ajhahn.de
← eeco
Go 117 lines
// Package clip writes a string to the host operating system's
// clipboard via the platform's native clipboard tool. It is the
// delivery channel behind `eeco go --copy`: a one-shot paste path for
// an assistant that has no terminal and no filesystem access (a
// chat-only LLM, a web-only Gemini session, an AI Studio prompt).
//
// The package shells out — macOS uses pbcopy, Windows uses clip.exe,
// Linux uses wl-copy on Wayland and falls back to xclip then xsel on
// X11. No third-party dependency is taken; the stdlib suffices. When
// no clipboard tool is reachable on PATH the package returns
// ErrNoClipboardTool so the caller can render a precise install hint
// and exit cleanly under eeco's "blocked (required tool missing)"
// contract.
package clip

import (
	"errors"
	"fmt"
	"os"
	"os/exec"
	"runtime"
	"strings"
)

// ErrNoClipboardTool is returned when no platform clipboard tool was
// found on PATH. Callers should treat this as the workflow contract's
// exit-2 ("blocked: required tool missing").
var ErrNoClipboardTool = errors.New("no clipboard tool found on PATH")

// runner executes the chosen clipboard tool with the given args,
// feeding text to its stdin. The default uses os/exec; tests
// substitute a fake.
var runner = execRunner

// lookPath finds a tool on PATH. Indirected for testability.
var lookPath = exec.LookPath

// getenv reads an environment variable. Indirected for testability.
var getenv = os.Getenv

// goos reports the build target. Indirected for testability.
var goos = func() string { return runtime.GOOS }

// Copy writes text to the platform clipboard. It returns
// ErrNoClipboardTool when no supported tool is on PATH, or a wrapped
// error when the tool exits non-zero.
func Copy(text string) error {
	name, args, ok := detect()
	if !ok {
		return ErrNoClipboardTool
	}
	if err := runner(name, args, text); err != nil {
		return fmt.Errorf("clip: %s: %w", name, err)
	}
	return nil
}

// detect picks a clipboard tool for the current OS and environment.
// The returned (name, args) pair is ready to hand to runner. ok is
// false when no candidate tool resolves on PATH.
func detect() (string, []string, bool) {
	switch goos() {
	case "darwin":
		if _, err := lookPath("pbcopy"); err == nil {
			return "pbcopy", nil, true
		}
	case "windows":
		if _, err := lookPath("clip.exe"); err == nil {
			return "clip.exe", nil, true
		}
		if _, err := lookPath("clip"); err == nil {
			return "clip", nil, true
		}
	case "linux", "freebsd", "openbsd", "netbsd":
		if getenv("WAYLAND_DISPLAY") != "" {
			if _, err := lookPath("wl-copy"); err == nil {
				return "wl-copy", nil, true
			}
		}
		if _, err := lookPath("xclip"); err == nil {
			return "xclip", []string{"-selection", "clipboard"}, true
		}
		if _, err := lookPath("xsel"); err == nil {
			return "xsel", []string{"--clipboard", "--input"}, true
		}
		if _, err := lookPath("wl-copy"); err == nil {
			return "wl-copy", nil, true
		}
	}
	return "", nil, false
}

// InstallHint returns a one-line "install one of these" message tuned
// to the current platform. Callers use it to render a helpful stderr
// message when Copy returns ErrNoClipboardTool.
func InstallHint() string {
	switch goos() {
	case "darwin":
		return "install pbcopy (ships with macOS — check that /usr/bin is on PATH)"
	case "windows":
		return "install clip.exe (ships with Windows — check that the System32 directory is on PATH)"
	case "linux", "freebsd", "openbsd", "netbsd":
		return "install one of: wl-copy (Wayland), xclip, or xsel"
	default:
		return "no clipboard tool is bundled for this platform"
	}
}

func execRunner(name string, args []string, stdin string) error {
	// #nosec G204 — name and args are chosen by detect() from a fixed
	// allow-list of platform clipboard tools; stdin carries the brief
	// text and is not interpolated into the command line.
	cmd := exec.Command(name, args...)
	cmd.Stdin = strings.NewReader(stdin)
	return cmd.Run()
}