diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 82f5d15..eb2231a 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -187,6 +187,23 @@ Overwrite existing output file (default behavior is to fail if file exists). fetch -o output.json --clobber example.com/data ``` +### `--copy` + +Copy the response body to the system clipboard. The response is still printed +to stdout normally. + +Requires a clipboard command to be available on the system: + +- **macOS**: `pbcopy` (built-in) +- **Linux/Wayland**: `wl-copy` +- **Linux/X11**: `xclip` or `xsel` +- **Windows**: `clip.exe` (built-in) + +```sh +fetch --copy example.com/api/data +fetch --copy -o response.json example.com/api/data +``` + ## Formatting Options ### `--format OPTION` diff --git a/docs/configuration.md b/docs/configuration.md index f7661ab..a777a50 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -66,6 +66,18 @@ auto-update = 1d ### Output Control Options +#### `copy` + +**Type**: Boolean +**Default**: `false` + +Copy the response body to the system clipboard. + +```ini +copy = true +copy = false +``` + #### `color` / `colour` **Type**: String diff --git a/integration/integration_test.go b/integration/integration_test.go index 89ad659..bcfd70c 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -1464,6 +1464,74 @@ func TestMain(t *testing.T) { assertBufContains(t, res.stderr, "session") }) }) + + t.Run("copy", func(t *testing.T) { + t.Run("stdout still has body", func(t *testing.T) { + server := startServer(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + io.WriteString(w, `{"hello":"world"}`) + }) + defer server.Close() + + res := runFetch(t, fetchPath, "--copy", "--no-pager", "--format=off", server.URL) + assertExitCode(t, 0, res) + assertBufEquals(t, res.stdout, `{"hello":"world"}`) + }) + + t.Run("works with output flag", func(t *testing.T) { + const data = "file and clipboard data" + server := startServer(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + io.WriteString(w, data) + }) + defer server.Close() + + dir, err := os.MkdirTemp("", "") + if err != nil { + t.Fatalf("unable to create temp dir: %s", err.Error()) + } + defer os.RemoveAll(dir) + + path := filepath.Join(dir, "output") + res := runFetch(t, fetchPath, "--copy", "-o", path, server.URL) + assertExitCode(t, 0, res) + + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("unable to read output file: %s", err.Error()) + } + if string(raw) != data { + t.Fatalf("unexpected data in output file: %q", raw) + } + }) + + t.Run("head request with copy", func(t *testing.T) { + server := startServer(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + }) + defer server.Close() + + res := runFetch(t, fetchPath, "--copy", "-m", "HEAD", server.URL) + assertExitCode(t, 0, res) + assertBufEmpty(t, res.stdout) + }) + + t.Run("copy with silent mode", func(t *testing.T) { + server := startServer(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(200) + io.WriteString(w, "silent copy") + }) + defer server.Close() + + res := runFetch(t, fetchPath, "--copy", "-s", "--no-pager", server.URL) + assertExitCode(t, 0, res) + assertBufEquals(t, res.stdout, "silent copy") + // In silent mode, stderr should not contain response metadata. + assertBufNotContains(t, res.stderr, "200 OK") + }) + }) } type runResult struct { diff --git a/internal/cli/app.go b/internal/cli/app.go index bb49eb8..7518321 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -315,6 +315,21 @@ func (a *App) CLI() *CLI { return nil }, }, + { + Short: "", + Long: "copy", + Args: "", + Description: "Copy the response body to clipboard", + Default: "", + IsSet: func() bool { + return a.Cfg.Copy != nil + }, + Fn: func(value string) error { + v := true + a.Cfg.Copy = &v + return nil + }, + }, { Short: "d", Long: "data", diff --git a/internal/config/config.go b/internal/config/config.go index 43e1e65..21f2f28 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -26,6 +26,7 @@ type Config struct { CertData []byte CertPath string Color core.Color + Copy *bool DNSServer *url.URL Format core.Format Headers []core.KeyVal[string] @@ -62,6 +63,9 @@ func (c *Config) Merge(c2 *Config) { if c.Color == core.ColorUnknown { c.Color = c2.Color } + if c.Copy == nil { + c.Copy = c2.Copy + } if c.DNSServer == nil { c.DNSServer = c2.DNSServer } @@ -131,6 +135,8 @@ func (c *Config) Set(key, val string) error { err = c.ParseCert(val) case "color", "colour": err = c.ParseColor(val) + case "copy": + err = c.ParseCopy(val) case "dns-server": err = c.ParseDNSServer(val) case "format": @@ -251,6 +257,15 @@ func (c *Config) ParseCert(value string) error { return nil } +func (c *Config) ParseCopy(value string) error { + v, err := strconv.ParseBool(value) + if err != nil { + return core.NewValueError("copy", value, "must be a boolean", c.isFile) + } + c.Copy = &v + return nil +} + func (c *Config) ParseColor(value string) error { switch value { case "auto": diff --git a/internal/fetch/clipboard.go b/internal/fetch/clipboard.go new file mode 100644 index 0000000..eb3e72f --- /dev/null +++ b/internal/fetch/clipboard.go @@ -0,0 +1,122 @@ +package fetch + +import ( + "bytes" + "fmt" + "io" + "net/http" + "os/exec" + "runtime" + + "github.com/ryanfowler/fetch/internal/core" +) + +const clipboardLimit = 1 << 24 // 16MB + +// clipboardCopier handles capturing the raw response body and copying it +// to the system clipboard. Use newClipboardCopier to set up body wrapping, +// then call finish after the response has been consumed. +type clipboardCopier struct { + cmd *clipboardCmd + buf *bytes.Buffer +} + +// newClipboardCopier sets up clipboard copying for the response. If copying +// is not enabled or not possible, it returns nil and resp is left unchanged. +// When non-nil is returned, resp.Body has been wrapped with a TeeReader +// that captures raw bytes into an internal buffer. +func newClipboardCopier(r *Request, resp *http.Response) *clipboardCopier { + if !r.Copy { + return nil + } + + cmd := findClipboard() + if cmd == nil { + p := r.PrinterHandle.Stderr() + var msg string + switch runtime.GOOS { + case "darwin": + msg = "no clipboard command found; install pbcopy" + case "windows": + msg = "no clipboard command found; install clip.exe" + default: + msg = "no clipboard command found; install xclip, xsel, or wl-copy" + } + core.WriteWarningMsg(p, msg) + return nil + } + + contentType := getContentType(resp.Header) + if contentType == TypeSSE || contentType == TypeNDJSON { + p := r.PrinterHandle.Stderr() + core.WriteWarningMsg(p, "--copy is not supported for streaming responses") + return nil + } + + buf := &bytes.Buffer{} + resp.Body = readCloserTee{ + Reader: io.TeeReader(io.LimitReader(resp.Body, clipboardLimit), buf), + Closer: resp.Body, + } + return &clipboardCopier{cmd: cmd, buf: buf} +} + +// finish copies the captured bytes to the system clipboard. It writes a +// warning to stderr on failure but never returns an error. +func (cc *clipboardCopier) finish(p *core.Printer) { + if cc == nil || cc.buf.Len() == 0 { + return + } + if err := copyToClipboard(cc.cmd, cc.buf.Bytes()); err != nil { + core.WriteWarningMsg(p, "unable to copy to clipboard: "+err.Error()) + } +} + +type clipboardCmd struct { + path string + args []string +} + +func findClipboard() *clipboardCmd { + switch runtime.GOOS { + case "darwin": + if path, err := exec.LookPath("pbcopy"); err == nil { + return &clipboardCmd{path: path} + } + case "windows": + if path, err := exec.LookPath("clip.exe"); err == nil { + return &clipboardCmd{path: path} + } + if path, err := exec.LookPath("clip"); err == nil { + return &clipboardCmd{path: path} + } + default: + if path, err := exec.LookPath("wl-copy"); err == nil { + return &clipboardCmd{path: path} + } + if path, err := exec.LookPath("xclip"); err == nil { + return &clipboardCmd{path: path, args: []string{"-selection", "clipboard"}} + } + if path, err := exec.LookPath("xsel"); err == nil { + return &clipboardCmd{path: path, args: []string{"--clipboard", "--input"}} + } + } + return nil +} + +func copyToClipboard(clip *clipboardCmd, data []byte) error { + cmd := exec.Command(clip.path, clip.args...) + cmd.Stdin = bytes.NewReader(data) + if err := cmd.Run(); err != nil { + return fmt.Errorf("clipboard command failed: %w", err) + } + return nil +} + +// readCloserTee wraps a Reader and a separate Closer, allowing the +// underlying reader to be replaced (e.g. with a TeeReader) while +// preserving the original Closer. +type readCloserTee struct { + io.Reader + io.Closer +} diff --git a/internal/fetch/fetch.go b/internal/fetch/fetch.go index c20e60a..7c84fee 100644 --- a/internal/fetch/fetch.go +++ b/internal/fetch/fetch.go @@ -56,6 +56,7 @@ type Request struct { ClientCert *tls.Certificate Clobber bool ContentType string + Copy bool Data io.Reader DNSServer *url.URL DryRun bool @@ -314,6 +315,9 @@ func makeRequest(ctx context.Context, r *Request, c *client.Client, req *http.Re p.Flush() } + // If --copy is requested, wrap the response body to capture raw bytes. + cc := newClipboardCopier(r, resp) + body, err := formatResponse(ctx, r, resp) if err != nil { return 0, err @@ -327,6 +331,9 @@ func makeRequest(ctx context.Context, r *Request, c *client.Client, req *http.Re } } + // Copy captured bytes to clipboard. + cc.finish(r.PrinterHandle.Stderr()) + return exitCode, nil } diff --git a/main.go b/main.go index cfadeaa..d681efc 100644 --- a/main.go +++ b/main.go @@ -139,6 +139,7 @@ func main() { ClientCert: clientCert, Clobber: app.Clobber, ContentType: app.ContentType, + Copy: getValue(app.Cfg.Copy), Data: app.Data, DNSServer: app.Cfg.DNSServer, DryRun: app.DryRun,