Copy an image to clipboard (screenshot, browser, etc.) and paste into the Hermes CLI. The image is saved to ~/.hermes/images/, shown as a badge above the input ([📎 Image #1]), and sent to the model as a base64-encoded OpenAI vision multimodal content block. Implementation: - hermes_cli/clipboard.py: clean module with platform-specific extraction - macOS: pngpaste (if installed) → osascript fallback (always available) - Linux: xclip (apt install xclip) - cli.py: BracketedPaste key handler checks clipboard on every paste, image bar widget shows attached images, chat() converts to multimodal content format, Ctrl+C clears attachments Inspired by @m0at's fork (https://github.com/m0at/hermes-agent) which implemented image paste support for local vision models. Reimplemented cleanly as a separate module with tests.
123 lines
4.2 KiB
Python
123 lines
4.2 KiB
Python
"""Clipboard image extraction for macOS and Linux.
|
|
|
|
Provides a single function `save_clipboard_image(dest)` that checks the
|
|
system clipboard for image data, saves it to *dest* as PNG, and returns
|
|
True on success. No external Python dependencies — uses only OS-level
|
|
CLI tools that ship with the platform (or are commonly installed).
|
|
|
|
Platform support:
|
|
macOS — osascript (always available), pngpaste (if installed)
|
|
Linux — xclip (apt install xclip)
|
|
"""
|
|
|
|
import logging
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def save_clipboard_image(dest: Path) -> bool:
|
|
"""Extract an image from the system clipboard and save it as PNG.
|
|
|
|
Returns True if an image was found and saved, False otherwise.
|
|
"""
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
if sys.platform == "darwin":
|
|
return _macos_save(dest)
|
|
return _linux_save(dest)
|
|
|
|
|
|
# ── macOS ────────────────────────────────────────────────────────────────
|
|
|
|
def _macos_save(dest: Path) -> bool:
|
|
"""Try pngpaste first (fast, handles more formats), fall back to osascript."""
|
|
return _macos_pngpaste(dest) or _macos_osascript(dest)
|
|
|
|
|
|
def _macos_pngpaste(dest: Path) -> bool:
|
|
"""Use pngpaste (brew install pngpaste) — fastest, cleanest."""
|
|
try:
|
|
r = subprocess.run(
|
|
["pngpaste", str(dest)],
|
|
capture_output=True, timeout=3,
|
|
)
|
|
if r.returncode == 0 and dest.exists() and dest.stat().st_size > 0:
|
|
return True
|
|
except FileNotFoundError:
|
|
pass # pngpaste not installed
|
|
except Exception as e:
|
|
logger.debug("pngpaste failed: %s", e)
|
|
return False
|
|
|
|
|
|
def _macos_osascript(dest: Path) -> bool:
|
|
"""Use osascript to extract PNG data from clipboard (always available)."""
|
|
# First check if clipboard contains image data
|
|
try:
|
|
info = subprocess.run(
|
|
["osascript", "-e", "clipboard info"],
|
|
capture_output=True, text=True, timeout=3,
|
|
)
|
|
has_image = "«class PNGf»" in info.stdout or "«class TIFF»" in info.stdout
|
|
if not has_image:
|
|
return False
|
|
except Exception:
|
|
return False
|
|
|
|
# Extract as PNG
|
|
script = (
|
|
'try\n'
|
|
' set imgData to the clipboard as «class PNGf»\n'
|
|
f' set f to open for access POSIX file "{dest}" with write permission\n'
|
|
' write imgData to f\n'
|
|
' close access f\n'
|
|
'on error\n'
|
|
' return "fail"\n'
|
|
'end try\n'
|
|
)
|
|
try:
|
|
r = subprocess.run(
|
|
["osascript", "-e", script],
|
|
capture_output=True, text=True, timeout=5,
|
|
)
|
|
if r.returncode == 0 and "fail" not in r.stdout and dest.exists() and dest.stat().st_size > 0:
|
|
return True
|
|
except Exception as e:
|
|
logger.debug("osascript clipboard extract failed: %s", e)
|
|
return False
|
|
|
|
|
|
# ── Linux ────────────────────────────────────────────────────────────────
|
|
|
|
def _linux_save(dest: Path) -> bool:
|
|
"""Use xclip to extract clipboard image."""
|
|
# Check if clipboard has image content
|
|
try:
|
|
targets = subprocess.run(
|
|
["xclip", "-selection", "clipboard", "-t", "TARGETS", "-o"],
|
|
capture_output=True, text=True, timeout=3,
|
|
)
|
|
if "image/png" not in targets.stdout:
|
|
return False
|
|
except FileNotFoundError:
|
|
logger.debug("xclip not installed — clipboard image paste unavailable")
|
|
return False
|
|
except Exception:
|
|
return False
|
|
|
|
# Extract PNG data
|
|
try:
|
|
with open(dest, "wb") as f:
|
|
subprocess.run(
|
|
["xclip", "-selection", "clipboard", "-t", "image/png", "-o"],
|
|
stdout=f, stderr=subprocess.DEVNULL, timeout=5, check=True,
|
|
)
|
|
if dest.exists() and dest.stat().st_size > 0:
|
|
return True
|
|
except Exception as e:
|
|
logger.debug("xclip image extraction failed: %s", e)
|
|
dest.unlink(missing_ok=True)
|
|
return False
|