Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
12 changes: 12 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 68 additions & 0 deletions integration/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
15 changes: 15 additions & 0 deletions internal/cli/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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":
Expand Down
122 changes: 122 additions & 0 deletions internal/fetch/clipboard.go
Original file line number Diff line number Diff line change
@@ -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
}
7 changes: 7 additions & 0 deletions internal/fetch/fetch.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ type Request struct {
ClientCert *tls.Certificate
Clobber bool
ContentType string
Copy bool
Data io.Reader
DNSServer *url.URL
DryRun bool
Expand Down Expand Up @@ -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
Expand All @@ -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
}

Expand Down
1 change: 1 addition & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down