tacklebox-cli 0.2.0__tar.gz → 0.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {tacklebox_cli-0.2.0 → tacklebox_cli-0.2.1}/PKG-INFO +9 -7
- {tacklebox_cli-0.2.0 → tacklebox_cli-0.2.1}/README.md +7 -5
- tacklebox_cli-0.2.1/tacklebox/commands/clipboard.py +318 -0
- {tacklebox_cli-0.2.0 → tacklebox_cli-0.2.1}/tacklebox/version.py +2 -2
- tacklebox_cli-0.2.0/tacklebox/commands/clipboard.py +0 -237
- {tacklebox_cli-0.2.0 → tacklebox_cli-0.2.1}/.gitignore +0 -0
- {tacklebox_cli-0.2.0 → tacklebox_cli-0.2.1}/LICENSE.md +0 -0
- {tacklebox_cli-0.2.0 → tacklebox_cli-0.2.1}/pyproject.toml +0 -0
- {tacklebox_cli-0.2.0 → tacklebox_cli-0.2.1}/tacklebox/__init__.py +0 -0
- {tacklebox_cli-0.2.0 → tacklebox_cli-0.2.1}/tacklebox/commands/__init__.py +0 -0
- {tacklebox_cli-0.2.0 → tacklebox_cli-0.2.1}/tacklebox/commands/spectacle.py +0 -0
- {tacklebox_cli-0.2.0 → tacklebox_cli-0.2.1}/tacklebox/commands/version.py +0 -0
- {tacklebox_cli-0.2.0 → tacklebox_cli-0.2.1}/tacklebox/entrypoint.py +0 -0
- {tacklebox_cli-0.2.0 → tacklebox_cli-0.2.1}/tacklebox/sync.py +0 -0
- {tacklebox_cli-0.2.0 → tacklebox_cli-0.2.1}/tacklebox/utils.py +0 -0
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tacklebox-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: A small collection of CLI utilities.
|
|
5
5
|
Project-URL: Homepage, https://c.csw.im/cswimr/tacklebox
|
|
6
6
|
Project-URL: Issues, https://c.csw.im/cswimr/tacklebox/issues
|
|
7
|
-
Project-URL: source_archive, https://c.csw.im/cswimr/tacklebox/archive/
|
|
7
|
+
Project-URL: source_archive, https://c.csw.im/cswimr/tacklebox/archive/e7f84520d063e5273054b26ac6056179a83ea77d.tar.gz
|
|
8
8
|
Author-email: cswimr <seaswimmerthefsh@gmail.com>
|
|
9
9
|
License: The MIT License (MIT)
|
|
10
10
|
=====================
|
|
@@ -54,12 +54,13 @@ tacklebox-cli offers a suite of useful CLI tools.
|
|
|
54
54
|
|
|
55
55
|
## Usage
|
|
56
56
|
|
|
57
|
-
### tacklebox
|
|
57
|
+
### tacklebox copy / paste
|
|
58
58
|
|
|
59
|
-
Cross-platform clipboard
|
|
59
|
+
Cross-platform clipboard management tool. Uses system tools such as `wl-copy` on Linux Wayland or `clip.exe` on Windows, and [OSC 52](https://www.reddit.com/r/vim/comments/k1ydpn/a_guide_on_how_to_copy_text_from_anywhere/) escape codes when **copying** over SSH or when no other tools are available. See [`copy_with_tooling()`](https://c.csw.im/cswimr/tacklebox/src/branch/main/tacklebox/commands/clipboard.py) for all supported tools.
|
|
60
60
|
|
|
61
61
|
```bash
|
|
62
|
-
echo "a" |
|
|
62
|
+
$ echo "a" | tacklebox copy --trim && tacklebox paste
|
|
63
|
+
a
|
|
63
64
|
```
|
|
64
65
|
|
|
65
66
|
### tacklebox spectacle (Linux only)
|
|
@@ -67,10 +68,11 @@ echo "a" | tr -d '\n' | tacklebox clip
|
|
|
67
68
|
Uses the [zipline.py](https://pypi.org/project/zipline-py/) library alongside KDE's [Spectacle](https://invent.kde.org/plasma/spectacle) application to take a screenshot or screen recording and automatically upload it to a [Zipline](https://github.com/diced/zipline) instance. This automatically reads Spectacle's configuration files to determine file formats.
|
|
68
69
|
|
|
69
70
|
```bash
|
|
70
|
-
|
|
71
|
+
# assuming that your zipline token is located in a file at `./zipline-token`
|
|
72
|
+
$ tacklebox spectacle --server https://zipline.example.com --token $(cat ./zipline-token) | tacklebox copy --trim
|
|
71
73
|
|
|
72
74
|
# or to record a video
|
|
73
|
-
tacklebox spectacle --
|
|
75
|
+
$ tacklebox spectacle --server https://zipline.example.com --token $(cat ./zipline-token) --record | tacklebox copy --trim
|
|
74
76
|
```
|
|
75
77
|
|
|
76
78
|
### tacklebox zipline
|
|
@@ -8,12 +8,13 @@ tacklebox-cli offers a suite of useful CLI tools.
|
|
|
8
8
|
|
|
9
9
|
## Usage
|
|
10
10
|
|
|
11
|
-
### tacklebox
|
|
11
|
+
### tacklebox copy / paste
|
|
12
12
|
|
|
13
|
-
Cross-platform clipboard
|
|
13
|
+
Cross-platform clipboard management tool. Uses system tools such as `wl-copy` on Linux Wayland or `clip.exe` on Windows, and [OSC 52](https://www.reddit.com/r/vim/comments/k1ydpn/a_guide_on_how_to_copy_text_from_anywhere/) escape codes when **copying** over SSH or when no other tools are available. See [`copy_with_tooling()`](https://c.csw.im/cswimr/tacklebox/src/branch/main/tacklebox/commands/clipboard.py) for all supported tools.
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
|
-
echo "a" |
|
|
16
|
+
$ echo "a" | tacklebox copy --trim && tacklebox paste
|
|
17
|
+
a
|
|
17
18
|
```
|
|
18
19
|
|
|
19
20
|
### tacklebox spectacle (Linux only)
|
|
@@ -21,10 +22,11 @@ echo "a" | tr -d '\n' | tacklebox clip
|
|
|
21
22
|
Uses the [zipline.py](https://pypi.org/project/zipline-py/) library alongside KDE's [Spectacle](https://invent.kde.org/plasma/spectacle) application to take a screenshot or screen recording and automatically upload it to a [Zipline](https://github.com/diced/zipline) instance. This automatically reads Spectacle's configuration files to determine file formats.
|
|
22
23
|
|
|
23
24
|
```bash
|
|
24
|
-
|
|
25
|
+
# assuming that your zipline token is located in a file at `./zipline-token`
|
|
26
|
+
$ tacklebox spectacle --server https://zipline.example.com --token $(cat ./zipline-token) | tacklebox copy --trim
|
|
25
27
|
|
|
26
28
|
# or to record a video
|
|
27
|
-
tacklebox spectacle --
|
|
29
|
+
$ tacklebox spectacle --server https://zipline.example.com --token $(cat ./zipline-token) --record | tacklebox copy --trim
|
|
28
30
|
```
|
|
29
31
|
|
|
30
32
|
### tacklebox zipline
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import platform
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from base64 import b64encode
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import Annotated, Literal
|
|
9
|
+
|
|
10
|
+
from typer import Argument, Exit, Option, Typer, echo
|
|
11
|
+
|
|
12
|
+
from tacklebox.utils import get_environment
|
|
13
|
+
|
|
14
|
+
app = Typer()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ClipboardMode(str, Enum):
|
|
18
|
+
COPY = "Copied"
|
|
19
|
+
PASTE = "Pasted"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _try_command(mode: ClipboardMode, cmd: list[str], verbose: bool = False, data: str | None = None) -> tuple[bool, str | None]:
|
|
23
|
+
"""Attempt to run a command using subprocess.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
mode (ClipboardMode): Determines some stdout stuff.
|
|
27
|
+
cmd (list[str]): The command to invoke.
|
|
28
|
+
verbose (bool, optional): Print some additional information during execution. Defaults to False.
|
|
29
|
+
data (str, optional): The data to send the process after it is invoked.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
bool: Whether or not the command was successful.
|
|
33
|
+
"""
|
|
34
|
+
if shutil.which(cmd[0]) is None:
|
|
35
|
+
if verbose:
|
|
36
|
+
echo(f"{cmd[0]} not found in PATH.", err=True)
|
|
37
|
+
return False, None
|
|
38
|
+
|
|
39
|
+
if cmd[0] == "clip.exe":
|
|
40
|
+
encoder = "utf-16le"
|
|
41
|
+
else:
|
|
42
|
+
encoder = "utf-8"
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
result = subprocess.run(
|
|
46
|
+
cmd,
|
|
47
|
+
input=data.encode(encoder) if data else None,
|
|
48
|
+
stderr=None if verbose else subprocess.DEVNULL,
|
|
49
|
+
env=get_environment(),
|
|
50
|
+
timeout=30,
|
|
51
|
+
)
|
|
52
|
+
if result.returncode == 0:
|
|
53
|
+
if verbose:
|
|
54
|
+
echo(f"{mode.value} using {' '.join(cmd)}", err=True)
|
|
55
|
+
return True, result.stdout.decode(encoder) if result.stdout else None
|
|
56
|
+
except subprocess.TimeoutExpired:
|
|
57
|
+
if verbose:
|
|
58
|
+
echo(f"{cmd[0]} timed out", err=True)
|
|
59
|
+
except OSError as e:
|
|
60
|
+
if verbose:
|
|
61
|
+
echo(f"{cmd[0]} execution failed: {e}", err=True)
|
|
62
|
+
return False, None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def use_tooling(
|
|
66
|
+
mode: ClipboardMode, command: list[str] | None = None, verbose: bool = False, data: str | None = None
|
|
67
|
+
) -> tuple[bool, str | None]:
|
|
68
|
+
"""Attempt to manipulate the system clipboard using system tools.
|
|
69
|
+
|
|
70
|
+
This function uses the following tools, and will try them in the order stated:
|
|
71
|
+
- The content of the `command` argument.
|
|
72
|
+
- If `mode == ClipboardMode.COPY`:
|
|
73
|
+
- Linux (Wayland):
|
|
74
|
+
- `wl-copy`
|
|
75
|
+
- `copyq add -`
|
|
76
|
+
- Linux (X11):
|
|
77
|
+
- `xclip -selection clipboard`
|
|
78
|
+
- `xsel --clipboard --input`
|
|
79
|
+
- `copyq add -`
|
|
80
|
+
- MacOS:
|
|
81
|
+
- `reattach-to-user-namespace pbcopy`
|
|
82
|
+
- `pbcopy`
|
|
83
|
+
- Windows:
|
|
84
|
+
- `win32yank.exe -i`
|
|
85
|
+
- `clip.exe`
|
|
86
|
+
- If `mode == ClipboardMode.PASTE`:
|
|
87
|
+
- Linux (Wayland):
|
|
88
|
+
- `wl-paste --no-newline`
|
|
89
|
+
- `copyq read 0`
|
|
90
|
+
- Linux (X11):
|
|
91
|
+
- `xclip -selection clipboard -o`
|
|
92
|
+
- `xsel --clipboard --output`
|
|
93
|
+
- `copyq read 0`
|
|
94
|
+
- MacOS:
|
|
95
|
+
- `reattach-to-user-namespace pbpaste`
|
|
96
|
+
- `pbpaste`
|
|
97
|
+
- Windows:
|
|
98
|
+
- `win32yank.exe -o`
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
mode (ClipboardMode): Whether or not to copy or paste.
|
|
102
|
+
command (list[str] | None): A user-provided command to try before running anything else.
|
|
103
|
+
verbose (bool, optional): Prints some extra information during execution. Defaults to False.
|
|
104
|
+
data (str, optional): The data to copy to the system clipboard, when `mode == ClipboardMode.COPY`.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
bool: Whether or not copying was successful.
|
|
108
|
+
str: The content that was in the clipboard.
|
|
109
|
+
"""
|
|
110
|
+
if command:
|
|
111
|
+
if success := _try_command(mode, command, verbose, data):
|
|
112
|
+
return success
|
|
113
|
+
|
|
114
|
+
system = platform.system().lower()
|
|
115
|
+
|
|
116
|
+
tools: dict[ClipboardMode, dict[str, list[list[str]]]] = {
|
|
117
|
+
ClipboardMode.COPY: {
|
|
118
|
+
"wayland": [["wl-copy"], ["copyq", "add", "-"]],
|
|
119
|
+
"x11": [["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"], ["copyq", "add", "-"]],
|
|
120
|
+
"darwin": [["reattach-to-user-namespace", "pbcopy"], ["pbcopy"]],
|
|
121
|
+
"windows": [["win32yank.exe", "-i"], ["clip.exe"]],
|
|
122
|
+
},
|
|
123
|
+
ClipboardMode.PASTE: {
|
|
124
|
+
"wayland": [["wl-paste", "--no-newline"], ["copyq", "read", "0"]],
|
|
125
|
+
"x11": [["xclip", "-selection", "clipboard", "-o"], ["xsel", "--clipboard", "--output"], ["copyq", "read", "0"]],
|
|
126
|
+
"darwin": [["reattach-to-user-namespace", "pbpaste"], ["pbpaste"]],
|
|
127
|
+
"windows": [["win32yank.exe", "-o"]],
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
commands: list[list[str]] = []
|
|
132
|
+
match system:
|
|
133
|
+
case "linux":
|
|
134
|
+
in_wsl = False
|
|
135
|
+
try:
|
|
136
|
+
with open("/proc/version", "r") as pv:
|
|
137
|
+
in_wsl = "microsoft" in pv.read().lower()
|
|
138
|
+
except Exception:
|
|
139
|
+
in_wsl = False
|
|
140
|
+
|
|
141
|
+
if in_wsl:
|
|
142
|
+
if verbose:
|
|
143
|
+
echo("Detected Windows Subsystem for Linux.", err=True)
|
|
144
|
+
commands.extend(tools[mode]["windows"])
|
|
145
|
+
|
|
146
|
+
protocol: Literal["wayland", "x11"] | None = (
|
|
147
|
+
"wayland" if "WAYLAND_DISPLAY" in get_environment() else ("x11" if "DISPLAY" in get_environment() else None)
|
|
148
|
+
)
|
|
149
|
+
if verbose:
|
|
150
|
+
echo(f"Detected display protocol: {protocol}", err=True)
|
|
151
|
+
|
|
152
|
+
match protocol:
|
|
153
|
+
case "wayland":
|
|
154
|
+
commands.extend(tools[mode]["wayland"])
|
|
155
|
+
case "x11":
|
|
156
|
+
commands.extend(tools[mode]["x11"])
|
|
157
|
+
case _:
|
|
158
|
+
if not in_wsl:
|
|
159
|
+
if verbose:
|
|
160
|
+
echo(
|
|
161
|
+
"Unknown display protocol: neither WAYLAND_DISPLAY nor DISPLAY set.",
|
|
162
|
+
err=True,
|
|
163
|
+
)
|
|
164
|
+
return False, None
|
|
165
|
+
|
|
166
|
+
case "darwin":
|
|
167
|
+
commands.extend(tools[mode]["darwin"])
|
|
168
|
+
case "windows":
|
|
169
|
+
commands.extend(tools[mode]["windows"])
|
|
170
|
+
case _:
|
|
171
|
+
if verbose:
|
|
172
|
+
echo(f"No suitable clipboard tool known for platform '{system}'", err=True)
|
|
173
|
+
return False, None
|
|
174
|
+
|
|
175
|
+
if verbose:
|
|
176
|
+
echo("\nAttempting commands:\n" + "\n".join(" ".join(cmd) for cmd in commands) + "\n", err=True)
|
|
177
|
+
for cmd in commands:
|
|
178
|
+
if (success := _try_command(mode, cmd, verbose, data))[0]:
|
|
179
|
+
return success
|
|
180
|
+
return False, None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def encode_osc52(data: str, verbose: bool = False) -> str:
|
|
184
|
+
"""Encode a string into an [OCS 52](https://www.reddit.com/r/vim/comments/k1ydpn/a_guide_on_how_to_copy_text_from_anywhere/) string, supporting tmux and screen as well.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
data (str): The data to encode.
|
|
188
|
+
verbose (bool, optional): Print additional information during execution. Defaults to False.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
str: The OSC 52 (& base64) encoded data.
|
|
192
|
+
"""
|
|
193
|
+
b64_data = b64encode(data.encode("utf-8")).decode("ascii")
|
|
194
|
+
osc_seq = f"\x1b]52;c;{b64_data}\x07"
|
|
195
|
+
|
|
196
|
+
if "TMUX" in os.environ:
|
|
197
|
+
if verbose:
|
|
198
|
+
echo("Wrapping OSC 52 for tmux.")
|
|
199
|
+
return f"\x1bPtmux;\x1b{osc_seq}\x1b\\"
|
|
200
|
+
elif os.environ.get("TERM", "").startswith("screen"):
|
|
201
|
+
if verbose:
|
|
202
|
+
echo("Wrapping OSC 52 for screen.")
|
|
203
|
+
return f"\x1bP{osc_seq}\x1b\\"
|
|
204
|
+
else:
|
|
205
|
+
if verbose:
|
|
206
|
+
echo("Using plain OSC 52.")
|
|
207
|
+
return osc_seq
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _maybe_print_environment_information(verbose: bool) -> None:
|
|
211
|
+
if verbose:
|
|
212
|
+
echo(
|
|
213
|
+
(
|
|
214
|
+
f"Platform: {platform.system()}\n"
|
|
215
|
+
f"TERM: {os.environ.get('TERM')}\n"
|
|
216
|
+
f"TMUX: {'present' if 'TMUX' in os.environ else 'absent'}\n"
|
|
217
|
+
f"SCREEN: {'present' if os.environ.get('TERM', '').startswith('screen') else 'absent'}"
|
|
218
|
+
),
|
|
219
|
+
err=True,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@app.command("copy")
|
|
224
|
+
@app.command("clip", deprecated=True, hidden=True, epilog="Consider using `tacklebox copy` instead.")
|
|
225
|
+
def copy(
|
|
226
|
+
data: Annotated[
|
|
227
|
+
str | None,
|
|
228
|
+
Argument(
|
|
229
|
+
help="The data to copy to the clipboard. Reads from stdin if this is not provided.",
|
|
230
|
+
),
|
|
231
|
+
] = None,
|
|
232
|
+
trim: Annotated[
|
|
233
|
+
bool, Option(..., "--trim", "-t", help="Remove trailing newlines from the input before copying it to the clipboard.")
|
|
234
|
+
] = False,
|
|
235
|
+
copy_command: Annotated[
|
|
236
|
+
str | None, Option(..., "--copy-command", "-c", help="A command to try first instead of the hardcoded system defaults.")
|
|
237
|
+
] = None,
|
|
238
|
+
verbose: Annotated[
|
|
239
|
+
bool,
|
|
240
|
+
Option(
|
|
241
|
+
...,
|
|
242
|
+
"--verbose",
|
|
243
|
+
"-v",
|
|
244
|
+
help="Print some additional information during execution.",
|
|
245
|
+
),
|
|
246
|
+
] = False,
|
|
247
|
+
) -> None:
|
|
248
|
+
"""Copy to the system clipboard."""
|
|
249
|
+
if not data:
|
|
250
|
+
data = sys.stdin.read()
|
|
251
|
+
|
|
252
|
+
if not data:
|
|
253
|
+
echo("No input received from stdin.", err=True)
|
|
254
|
+
raise Exit(code=1)
|
|
255
|
+
|
|
256
|
+
if trim:
|
|
257
|
+
data = data.rstrip("\r\n")
|
|
258
|
+
|
|
259
|
+
_maybe_print_environment_information(verbose)
|
|
260
|
+
|
|
261
|
+
command = None
|
|
262
|
+
if copy_command:
|
|
263
|
+
command = copy_command.split(" ")
|
|
264
|
+
|
|
265
|
+
if use_tooling(ClipboardMode.COPY, command, verbose, data):
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
if verbose:
|
|
269
|
+
echo("Clipboard tools failed; trying OSC 52...", err=True)
|
|
270
|
+
|
|
271
|
+
osc = encode_osc52(data, verbose)
|
|
272
|
+
try:
|
|
273
|
+
with open("/dev/tty", "w") as tty:
|
|
274
|
+
tty.write(osc)
|
|
275
|
+
if verbose:
|
|
276
|
+
echo("Copied using OSC 52.")
|
|
277
|
+
except Exception as e:
|
|
278
|
+
echo(f"OSC 52 failed: {e}", err=True)
|
|
279
|
+
raise Exit(code=1)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@app.command("paste")
|
|
283
|
+
def paste(
|
|
284
|
+
paste_command: Annotated[
|
|
285
|
+
str | None, Option(..., "--paste-command", "-c", help="A command to try first instead of the hardcoded system defaults.")
|
|
286
|
+
] = None,
|
|
287
|
+
trim: Annotated[bool, Option(..., "--trim", "-t", help="Remove trailing newlines from the clipboard entry before pasting it.")] = False,
|
|
288
|
+
verbose: Annotated[
|
|
289
|
+
bool,
|
|
290
|
+
Option(
|
|
291
|
+
...,
|
|
292
|
+
"--verbose",
|
|
293
|
+
"-v",
|
|
294
|
+
help=("Print some additional information during execution. This WILL mangle the output, so only use this for debugging."),
|
|
295
|
+
),
|
|
296
|
+
] = False,
|
|
297
|
+
) -> None:
|
|
298
|
+
"""Paste from the system clipboard."""
|
|
299
|
+
_maybe_print_environment_information(verbose)
|
|
300
|
+
|
|
301
|
+
command = None
|
|
302
|
+
if paste_command:
|
|
303
|
+
command = paste_command.split(" ")
|
|
304
|
+
|
|
305
|
+
if (output := use_tooling(ClipboardMode.PASTE, command, verbose))[0]:
|
|
306
|
+
string = output[1]
|
|
307
|
+
if trim and string is not None:
|
|
308
|
+
string = string.rstrip("\r\n")
|
|
309
|
+
|
|
310
|
+
echo(string)
|
|
311
|
+
return
|
|
312
|
+
|
|
313
|
+
echo(f"Paste command failed!\n{output}", err=True)
|
|
314
|
+
exit(code=1)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
if __name__ == "__main__":
|
|
318
|
+
app()
|
|
@@ -1,237 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
import platform
|
|
3
|
-
import shutil
|
|
4
|
-
import subprocess
|
|
5
|
-
import sys
|
|
6
|
-
from base64 import b64encode
|
|
7
|
-
from typing import Annotated, Literal
|
|
8
|
-
|
|
9
|
-
from typer import Argument, Exit, Option, Typer, echo
|
|
10
|
-
|
|
11
|
-
from tacklebox.utils import get_environment
|
|
12
|
-
|
|
13
|
-
app = Typer()
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def _try_command(data: str, cmd: list[str], verbose: bool = False) -> bool:
|
|
17
|
-
"""Attempt to run a command using subprocess.
|
|
18
|
-
|
|
19
|
-
Args:
|
|
20
|
-
data (str): The data to send the process after it is invoked.
|
|
21
|
-
cmd (list[str]): The command to invoke.
|
|
22
|
-
verbose (bool, optional): Print some additional information during execution. Defaults to False.
|
|
23
|
-
|
|
24
|
-
Returns:
|
|
25
|
-
bool: Whether or not the command was successful.
|
|
26
|
-
"""
|
|
27
|
-
if shutil.which(cmd[0]) is None:
|
|
28
|
-
if verbose:
|
|
29
|
-
echo(f"{cmd[0]} not found in PATH.", err=True)
|
|
30
|
-
return False
|
|
31
|
-
|
|
32
|
-
if cmd[0] == "clip.exe":
|
|
33
|
-
encoder = "utf-16le"
|
|
34
|
-
else:
|
|
35
|
-
encoder = "utf-8"
|
|
36
|
-
|
|
37
|
-
try:
|
|
38
|
-
result = subprocess.run(
|
|
39
|
-
cmd,
|
|
40
|
-
input=data.encode(encoder),
|
|
41
|
-
stdout=(None if verbose else subprocess.DEVNULL),
|
|
42
|
-
stderr=(None if verbose else subprocess.DEVNULL),
|
|
43
|
-
env=get_environment(),
|
|
44
|
-
timeout=30,
|
|
45
|
-
)
|
|
46
|
-
if result.returncode == 0:
|
|
47
|
-
if verbose:
|
|
48
|
-
echo(f"Copied using {' '.join(cmd)}")
|
|
49
|
-
return True
|
|
50
|
-
except subprocess.TimeoutExpired:
|
|
51
|
-
if verbose:
|
|
52
|
-
echo(f"{cmd[0]} timed out", err=True)
|
|
53
|
-
except OSError as e:
|
|
54
|
-
if verbose:
|
|
55
|
-
echo(f"{cmd[0]} execution failed: {e}", err=True)
|
|
56
|
-
return False
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def copy_with_tooling(data: str, copy_command: list[str] | None = None, verbose: bool = False) -> bool:
|
|
60
|
-
"""Attempt to copy the input data to the system clipboard using system tools.
|
|
61
|
-
|
|
62
|
-
This function uses the following tools, and will try them in the order stated:
|
|
63
|
-
- The content of the `copy_command` argument.
|
|
64
|
-
- Linux (Wayland):
|
|
65
|
-
- `wl-copy`
|
|
66
|
-
- `copyq add -`
|
|
67
|
-
- Linux (X11):
|
|
68
|
-
- `xclip -selection clipboard`
|
|
69
|
-
- `xsel --clipboard --input`
|
|
70
|
-
- `copyq add -`
|
|
71
|
-
- MacOS:
|
|
72
|
-
- `reattach-to-user-namespace pbcopy`
|
|
73
|
-
- `pbcopy`
|
|
74
|
-
- Windows:
|
|
75
|
-
- `clip.exe`
|
|
76
|
-
|
|
77
|
-
Args:
|
|
78
|
-
data (str): The data to copy to the system clipboard.
|
|
79
|
-
copy_command (list[str] | None): A user-provided command to try before running anything else.
|
|
80
|
-
verbose (bool, optional): Prints some extra information during execution. Defaults to False.
|
|
81
|
-
|
|
82
|
-
Returns:
|
|
83
|
-
bool: Whether or not copying was successful.
|
|
84
|
-
"""
|
|
85
|
-
if copy_command:
|
|
86
|
-
if success := _try_command(data, copy_command, verbose):
|
|
87
|
-
return success
|
|
88
|
-
|
|
89
|
-
system = platform.system().lower()
|
|
90
|
-
|
|
91
|
-
tools: dict[str, list[list[str]]] = {
|
|
92
|
-
"wayland": [["wl-copy"], ["copyq", "add", "-"]],
|
|
93
|
-
"x11": [["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"], ["copyq", "add", "-"]],
|
|
94
|
-
"darwin": [["reattach-to-user-namespace", "pbcopy"], ["pbcopy"]],
|
|
95
|
-
"windows": [["clip.exe"]],
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
commands: list[list[str]] = []
|
|
99
|
-
match system:
|
|
100
|
-
case "linux":
|
|
101
|
-
in_wsl = False
|
|
102
|
-
try:
|
|
103
|
-
with open("/proc/version", "r") as pv:
|
|
104
|
-
in_wsl = "microsoft" in pv.read().lower()
|
|
105
|
-
except Exception:
|
|
106
|
-
in_wsl = False
|
|
107
|
-
|
|
108
|
-
if in_wsl:
|
|
109
|
-
if verbose:
|
|
110
|
-
echo("Detected Windows Subsystem for Linux.")
|
|
111
|
-
commands.extend(tools["windows"])
|
|
112
|
-
|
|
113
|
-
protocol: Literal["wayland", "x11"] | None = (
|
|
114
|
-
"wayland" if "WAYLAND_DISPLAY" in get_environment() else ("x11" if "DISPLAY" in get_environment() else None)
|
|
115
|
-
)
|
|
116
|
-
if verbose:
|
|
117
|
-
echo(f"Detected display protocol: {protocol}")
|
|
118
|
-
|
|
119
|
-
match protocol:
|
|
120
|
-
case "wayland":
|
|
121
|
-
commands.extend(tools["wayland"])
|
|
122
|
-
case "x11":
|
|
123
|
-
commands.extend(tools["x11"])
|
|
124
|
-
case _:
|
|
125
|
-
if verbose:
|
|
126
|
-
echo(
|
|
127
|
-
"Unknown display protocol: neither WAYLAND_DISPLAY nor DISPLAY set.",
|
|
128
|
-
err=True,
|
|
129
|
-
)
|
|
130
|
-
return False
|
|
131
|
-
case "darwin":
|
|
132
|
-
commands.extend(tools["darwin"])
|
|
133
|
-
case "windows":
|
|
134
|
-
commands.extend(tools["windows"])
|
|
135
|
-
case _:
|
|
136
|
-
if verbose:
|
|
137
|
-
echo(f"No suitable clipboard tool found for platform '{system}'", err=True)
|
|
138
|
-
return False
|
|
139
|
-
|
|
140
|
-
for cmd in commands:
|
|
141
|
-
if success := _try_command(data, cmd, verbose):
|
|
142
|
-
return success
|
|
143
|
-
return False
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
def encode_osc52(data: str, verbose: bool = False) -> str:
|
|
147
|
-
"""Encode a string into an [OCS 52](https://www.reddit.com/r/vim/comments/k1ydpn/a_guide_on_how_to_copy_text_from_anywhere/) string, supporting tmux and screen as well.
|
|
148
|
-
|
|
149
|
-
Args:
|
|
150
|
-
data (str): The data to encode.
|
|
151
|
-
verbose (bool, optional): Print additional information during execution. Defaults to False.
|
|
152
|
-
|
|
153
|
-
Returns:
|
|
154
|
-
str: The OSC 52 (& base64) encoded data.
|
|
155
|
-
"""
|
|
156
|
-
b64_data = b64encode(data.encode("utf-8")).decode("ascii")
|
|
157
|
-
osc_seq = f"\x1b]52;c;{b64_data}\x07"
|
|
158
|
-
|
|
159
|
-
if "TMUX" in os.environ:
|
|
160
|
-
if verbose:
|
|
161
|
-
echo("Wrapping OSC 52 for tmux.")
|
|
162
|
-
return f"\x1bPtmux;\x1b{osc_seq}\x1b\\"
|
|
163
|
-
elif os.environ.get("TERM", "").startswith("screen"):
|
|
164
|
-
if verbose:
|
|
165
|
-
echo("Wrapping OSC 52 for screen.")
|
|
166
|
-
return f"\x1bP{osc_seq}\x1b\\"
|
|
167
|
-
else:
|
|
168
|
-
if verbose:
|
|
169
|
-
echo("Using plain OSC 52.")
|
|
170
|
-
return osc_seq
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
@app.command("clip")
|
|
174
|
-
def clipboard(
|
|
175
|
-
data: Annotated[
|
|
176
|
-
str | None,
|
|
177
|
-
Argument(
|
|
178
|
-
help="The data to copy to the clipboard. Reads from stdin if this is not provided.",
|
|
179
|
-
),
|
|
180
|
-
] = None,
|
|
181
|
-
copy_command: Annotated[str | None, Option(..., "--copy-command", "-c", help="A command to try first instead of the hardcoded system defaults.")] = None,
|
|
182
|
-
verbose: Annotated[
|
|
183
|
-
bool,
|
|
184
|
-
Option(
|
|
185
|
-
...,
|
|
186
|
-
"--verbose",
|
|
187
|
-
"-v",
|
|
188
|
-
help="Print some additional information during execution.",
|
|
189
|
-
),
|
|
190
|
-
] = False,
|
|
191
|
-
) -> None:
|
|
192
|
-
"""Read from stdin and copy to the system clipboard using wl-copy or OSC 52."""
|
|
193
|
-
if not data:
|
|
194
|
-
data = sys.stdin.read()
|
|
195
|
-
|
|
196
|
-
if not data:
|
|
197
|
-
echo("No input received from stdin.", err=True)
|
|
198
|
-
raise Exit(code=1)
|
|
199
|
-
|
|
200
|
-
if verbose:
|
|
201
|
-
echo(
|
|
202
|
-
message="\n".join(
|
|
203
|
-
(
|
|
204
|
-
f"Platform: {platform.system()}",
|
|
205
|
-
f"TERM: {os.environ.get('TERM')}",
|
|
206
|
-
f"TMUX: {'present' if 'TMUX' in os.environ else 'absent'}",
|
|
207
|
-
f"SCREEN: {'present' if os.environ.get('TERM', '').startswith('screen') else 'absent'}",
|
|
208
|
-
)
|
|
209
|
-
)
|
|
210
|
-
)
|
|
211
|
-
|
|
212
|
-
command = None
|
|
213
|
-
if copy_command:
|
|
214
|
-
command = copy_command.split(" ")
|
|
215
|
-
|
|
216
|
-
if success := copy_with_tooling(data, command, verbose):
|
|
217
|
-
return
|
|
218
|
-
|
|
219
|
-
ssh = "SSH_CONNECTION" in os.environ
|
|
220
|
-
|
|
221
|
-
if ssh or not success:
|
|
222
|
-
if verbose:
|
|
223
|
-
echo("Clipboard tools failed; trying OSC 52...", err=True)
|
|
224
|
-
|
|
225
|
-
osc = encode_osc52(data, verbose)
|
|
226
|
-
try:
|
|
227
|
-
with open("/dev/tty", "w") as tty:
|
|
228
|
-
tty.write(osc)
|
|
229
|
-
if verbose:
|
|
230
|
-
echo("Copied using OSC 52.")
|
|
231
|
-
except Exception as e:
|
|
232
|
-
echo(f"OSC 52 failed: {e}", err=True)
|
|
233
|
-
raise Exit(code=1)
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
if __name__ == "__main__":
|
|
237
|
-
app()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|