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
5 changes: 4 additions & 1 deletion README
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Windows-compatible `pass` (password-store) replacement in Go. Manages GPG-encryp
- Interactive fuzzy search with TUI (Bubble Tea)
- Git status displayed in TUI
- Cross-platform (Windows, Linux, macOS)
- Copy to clipboard support
- Copy to clipboard support (xclip/xsel on X11, wl-copy on Wayland, pbcopy on macOS, clip.exe on Windows), with tmux paste-buffer integration when running inside tmux
- Edit existing passwords with your favorite editor

### Build
Expand Down Expand Up @@ -104,6 +104,9 @@ When running `pass`, `pass -c`, `pass rm`, or `pass edit` without arguments, an
- Go 1.20+
- GnuPG (GPG4Win on Windows)
- Git (optional, for version control)
- For `pass -c` clipboard support on Linux: `xclip` or `xsel` (X11), or
`wl-clipboard` (Wayland). Not required when running inside tmux, which
uses its own paste buffer as a fallback.

---

Expand Down
38 changes: 36 additions & 2 deletions docs/pass-decision-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ This document tracks design decisions made during the specification and implemen

### [AD-005] Clipboard Implementation

**Status**: Accepted
**Status**: Superseded by [AD-012]
**Date**: 2026-06-05
**Context**: How should clipboard operations be implemented on Windows?

Expand All @@ -177,6 +177,11 @@ This document tracks design decisions made during the specification and implemen
- Will work in any Windows shell context
- For cross-platform, may need platform-specific implementations

**Superseded Note (2026-07-25)**: This Windows-only implementation left
`pass -c` completely broken on Linux (X11/Wayland) and macOS, despite the
tool advertising cross-platform clipboard support (see `README`). See
[AD-012] for the replacement.

**Related**: pass-replacement-spec.md (Section 5)

---
Expand Down Expand Up @@ -409,6 +414,35 @@ This document tracks design decisions made during the specification and implemen

---

### [AD-012] Cross-Platform Clipboard via atotto/clipboard, with tmux Integration

**Status**: Accepted
**Date**: 2026-07-25
**Context**: [AD-005]'s Windows-only `clip`-command implementation made `pass -c` fail unconditionally on Linux and macOS (bug: `exec: "clip": executable file not found in $PATH`), contradicting the tool's advertised cross-platform clipboard support. Additionally, `pass` is frequently run inside tmux, including over SSH sessions with no X11/Wayland display available to a system clipboard utility.

**Decision**: Replace the hand-rolled Windows-only implementation with [`github.com/atotto/clipboard`](https://github.com/atotto/clipboard) for the system clipboard (`clip.exe`/`powershell.exe` on Windows, `pbcopy`/`pbpaste` on macOS, `wl-copy`/`xclip`/`xsel`/Termux on Linux and other Unix). Additionally, when the `TMUX` environment variable is set, also load the password into a dedicated tmux paste buffer via `tmux load-buffer -w -b pass -`, so pasting works via tmux itself even when no system clipboard utility can reach a display server.

**Rationale**:
- `atotto/clipboard` was already present in `go.sum` as an indirect, transitive dependency, so promoting it to direct adds no new supply-chain surface.
- It already implements the exact platform-detection logic (Wayland vs. X11 vs. Windows vs. macOS vs. Termux) that would otherwise need to be hand-rolled and maintained.
- tmux's `load-buffer -w` forwards the buffer to the terminal via the OSC 52 escape sequence when `set-clipboard` is enabled, which lets a password copied inside a *remote* tmux session (over SSH, no `DISPLAY`) end up on the *local* machine's clipboard without any X11/Wayland forwarding.
- Reusing a single named tmux buffer (`pass`) avoids polluting tmux's auto-named buffer list on repeated copies.

**Alternatives Considered**:
1. **Hand-roll platform detection (`xclip`/`xsel`/`wl-copy`/`pbcopy`) ourselves**: Duplicates `atotto/clipboard`'s already-tested logic for no benefit.
2. **tmux buffer only, no system clipboard**: Would regress Windows/macOS and non-tmux Linux users.
3. **Require `DISPLAY`/Wayland forwarding over SSH instead of using tmux buffers**: Pushes a configuration burden onto users; many SSH setups do not forward a display.

**Consequences**:
- New direct dependency: `github.com/atotto/clipboard`.
- `pass -c` now works out of the box on Linux (X11 and Wayland) and macOS, in addition to Windows.
- Inside tmux, `pass -c` also works over SSH without display forwarding, pasteable via `prefix` + `]`, and via the system clipboard automatically if `set-clipboard` and OSC 52 are supported.
- If neither a system clipboard utility nor tmux is available, `pass -c` fails with an actionable error message ("install xclip, xsel, or wl-clipboard") instead of a confusing "executable file not found" message.

**Related**: specs/008-linux-clipboard-fix/spec.md, [AD-005] (superseded)

---

## Design Decisions

### [DD-001] Command Syntax Compatibility
Expand Down Expand Up @@ -749,7 +783,7 @@ These decisions have not yet been finalized and need further discussion:

## Superseded Decisions

None yet.
- [AD-005] Clipboard Implementation - superseded by [AD-012] Cross-Platform Clipboard via atotto/clipboard, with tmux Integration (2026-07-25). The Windows-only `clip`-command implementation left `pass -c` broken on Linux and macOS.

---

Expand Down
7 changes: 7 additions & 0 deletions pass/docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ All notable changes to the pass tool are documented in this file.
- **Edit command**: `pass edit <path>` allows editing existing passwords in your favorite editor
- **Fuzzy search for edit**: `pass edit` without arguments opens fuzzy search to select a password to edit
- **Edit mode in TUI**: Added support for edit mode in the Bubble Tea TUI
- **tmux clipboard integration**: When running inside tmux, `pass -c` (and `pass rm -c`/fuzzy clip mode) also loads the password into a dedicated tmux paste buffer (`pass`), pasteable with `prefix` + `]`. If tmux's `set-clipboard` option and the terminal support OSC 52, the password is also forwarded to the local terminal's clipboard, which works even over SSH without X11/Wayland forwarding

### Fixed
- **Linux/macOS clipboard support**: `pass -c` previously only worked on Windows (hard-coded `clip` command) and failed on every other platform with `exec: "clip": executable file not found in $PATH`. Clipboard copy/clear now uses the cross-platform `atotto/clipboard` library, supporting `xclip`/`xsel`/`wl-copy` on Linux and `pbcopy` on macOS, in addition to Windows
- **Insert overwrite prevention**: `pass insert <path>` now fails with error `pass: <path>: Already exists` if the file already exists
- **List command**: `pass ls` now only lists secret files (`.gpg` files), not directories

Expand Down Expand Up @@ -53,9 +55,13 @@ All notable changes to the pass tool are documented in this file.
- `cmd/fuzzy.go` - Added `FuzzyModeEdit` constant and support
- `cmd/tui/fuzzy.go` - Added `FuzzyModeEdit` constant
- `cmd/tui/models.go` - Updated getTitle and getPrompt for edit mode
- `pkg/filesystem/fs.go` - Replaced Windows-only `clip` clipboard implementation with cross-platform `atotto/clipboard`; added tmux paste buffer integration
- `pkg/filesystem/fs_test.go` - Updated clipboard availability check; added tmux detection/integration tests
- `go.mod` / `go.sum` - Promoted `github.com/atotto/clipboard` from indirect to direct dependency
- `README.md` - Updated with edit command documentation
- `docs/tui.md` - Updated with edit mode
- `docs/edit-command.md` - New file with detailed edit command docs
- `docs/pass-decision-log.md` - Superseded AD-005, added AD-012 (cross-platform clipboard + tmux)

### Files Added
- `cmd/edit.go`
Expand All @@ -65,6 +71,7 @@ All notable changes to the pass tool are documented in this file.
- `docs/edit-command.md`
- `specs/pass-edit/spec.md`
- `specs/pass-edit/tasks.md`
- `specs/008-linux-clipboard-fix/spec.md`

## Testing

Expand Down
2 changes: 1 addition & 1 deletion pass/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module github.com/mandu/tools/pass
go 1.24.2

require (
github.com/atotto/clipboard v0.1.4
github.com/charmbracelet/bubbles v0.16.1
github.com/charmbracelet/bubbletea v0.25.0
github.com/charmbracelet/lipgloss v0.11.0
Expand All @@ -11,7 +12,6 @@ require (
)

require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
Expand Down
81 changes: 66 additions & 15 deletions pass/pkg/filesystem/fs.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,15 @@ import (
"path/filepath"
"strings"
"time"

"github.com/atotto/clipboard"
)

// tmuxBufferName is the name of the dedicated tmux paste buffer that pass
// uses, so repeated copies overwrite the same buffer instead of
// accumulating auto-named buffers.
const tmuxBufferName = "pass"

// NormalizePath converts a path to use the OS-specific separator.
// On Windows, converts / to \. On Unix, converts \ to /. Also handles path normalization.
func NormalizePath(path string) string {
Expand Down Expand Up @@ -82,33 +89,77 @@ func SecureDelete(filePath string) error {
}

// CopyToClipboard copies text to the system clipboard.
// On Windows, uses the built-in `clip` command.
// Uses clip.exe on Windows, pbcopy on macOS, and wl-copy/xclip/xsel on
// Linux (Wayland or X11), via the cross-platform atotto/clipboard library.
//
// When running inside tmux (the TMUX environment variable is set), the text
// is also loaded into a dedicated tmux paste buffer so it can be pasted with
// tmux itself (prefix + ]) even when no system clipboard utility is
// available, such as over an SSH connection without X11/Wayland forwarding.
// If tmux's set-clipboard option is enabled and the terminal supports OSC 52,
// tmux additionally forwards the buffer to the terminal's clipboard.
func CopyToClipboard(text string) error {
cmd := exec.Command("clip")
insideTmux := isInsideTmux()

var tmuxErr error
if insideTmux {
tmuxErr = copyToTmuxBuffer(text)
}

sysErr := writeToSystemClipboard(text)
if sysErr == nil {
return nil
}
if insideTmux && tmuxErr == nil {
// System clipboard unavailable (common over SSH without a
// forwarded display), but the tmux buffer was updated
// successfully, so pasting via tmux still works.
return nil
}
if insideTmux {
return fmt.Errorf("failed to write to clipboard: %v (tmux buffer also failed: %v)", sysErr, tmuxErr)
}
return fmt.Errorf("failed to write to clipboard: %v", sysErr)
}

// writeToSystemClipboard writes text to the OS-level clipboard.
func writeToSystemClipboard(text string) error {
if clipboard.Unsupported {
return fmt.Errorf("no clipboard utility available (install xclip, xsel, or wl-clipboard)")
}
return clipboard.WriteAll(text)
}

// isInsideTmux reports whether pass is currently running inside a tmux
// session.
func isInsideTmux() bool {
return os.Getenv("TMUX") != ""
}

// copyToTmuxBuffer loads text into a dedicated tmux paste buffer via
// `tmux load-buffer`. The -w flag additionally asks tmux to forward the
// buffer to the terminal's clipboard using the OSC 52 escape sequence, if
// tmux's set-clipboard option and the terminal support it.
func copyToTmuxBuffer(text string) error {
cmd := exec.Command("tmux", "load-buffer", "-w", "-b", tmuxBufferName, "-")
stdin, err := cmd.StdinPipe()
if err != nil {
return fmt.Errorf("failed to create stdin pipe: %v", err)
return fmt.Errorf("failed to create stdin pipe for tmux: %v", err)
}

// Start the command

if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to start clip command: %v", err)
return fmt.Errorf("failed to start tmux load-buffer: %v", err)
}

// Write text to stdin

if _, err := io.WriteString(stdin, text); err != nil {
stdin.Close()
return fmt.Errorf("failed to write to clipboard: %v", err)
return fmt.Errorf("failed to write to tmux buffer: %v", err)
}

// Close stdin
stdin.Close()

// Wait for command to complete

if err := cmd.Wait(); err != nil {
return fmt.Errorf("clip command failed: %v", err)
return fmt.Errorf("tmux load-buffer failed: %v", err)
}

return nil
}

Expand Down
59 changes: 55 additions & 4 deletions pass/pkg/filesystem/fs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"path/filepath"
"strings"
"testing"

"github.com/atotto/clipboard"
)

func TestNormalizePath(t *testing.T) {
Expand Down Expand Up @@ -139,6 +141,52 @@ func TestCopyToClipboard(t *testing.T) {
t.Log("Clipboard copy test passed (visual verification needed)")
}

func TestIsInsideTmux(t *testing.T) {
orig, wasSet := os.LookupEnv("TMUX")
defer func() {
if wasSet {
os.Setenv("TMUX", orig)
} else {
os.Unsetenv("TMUX")
}
}()

os.Unsetenv("TMUX")
if isInsideTmux() {
t.Errorf("isInsideTmux() = true, want false when TMUX is unset")
}

os.Setenv("TMUX", "/tmp/tmux-1000/default,1234,0")
if !isInsideTmux() {
t.Errorf("isInsideTmux() = false, want true when TMUX is set")
}
}

func TestCopyToTmuxBuffer(t *testing.T) {
// This is an integration test: it only runs when actually executed
// inside a real tmux session, so it can talk to a live tmux server.
if os.Getenv("TMUX") == "" {
t.Skip("not running inside tmux")
}
if _, err := exec.LookPath("tmux"); err != nil {
t.Skip("tmux binary not available")
}

testText := "test tmux password 123"
if err := copyToTmuxBuffer(testText); err != nil {
t.Fatalf("copyToTmuxBuffer failed: %v", err)
}
defer exec.Command("tmux", "delete-buffer", "-b", tmuxBufferName).Run()

out, err := exec.Command("tmux", "show-buffer", "-b", tmuxBufferName).Output()
if err != nil {
t.Fatalf("failed to read back tmux buffer: %v", err)
}
if got := strings.TrimRight(string(out), "\n"); got != testText {
t.Errorf("tmux buffer = %q, want %q", got, testText)
}
}

func TestEnsurePasswordStore(t *testing.T) {
// Create a temp directory for testing
tempDir, err := os.MkdirTemp("", "pass-test-store")
Expand All @@ -159,9 +207,12 @@ func TestEnsurePasswordStore(t *testing.T) {
}
}

// IsClipboardAvailable is a helper for tests
// IsClipboardAvailable is a helper for tests. It reports whether some
// clipboard mechanism - a system clipboard utility, or (when running
// inside tmux) a tmux paste buffer - is usable in the current environment.
func IsClipboardAvailable() bool {
// Simple check - try to run clip command
cmd := exec.Command("clip")
return cmd.Run() == nil
if !clipboard.Unsupported {
return true
}
return isInsideTmux()
}
Loading
Loading