oscopy 0.1.0__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.
- oscopy-0.1.0/PKG-INFO +122 -0
- oscopy-0.1.0/README.md +113 -0
- oscopy-0.1.0/pyproject.toml +17 -0
- oscopy-0.1.0/src/oscopy/__init__.py +2 -0
- oscopy-0.1.0/src/oscopy/cli.py +365 -0
oscopy-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: oscopy
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python script to copy from STD to your clipboard with OSC 52.
|
|
5
|
+
Author: hammerill
|
|
6
|
+
Author-email: hammerill <kyrylo@hammerill.com>
|
|
7
|
+
Requires-Python: >=3.12
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# oscopy
|
|
11
|
+
|
|
12
|
+
Python tool to copy text to your local clipboard over OSC 52, and record shell command transcripts.
|
|
13
|
+
|
|
14
|
+
## How to Use It
|
|
15
|
+
|
|
16
|
+
### Piping
|
|
17
|
+
|
|
18
|
+
You simply pipe to `oscopy`, then paste anywhere:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
echo coucou_yopta | oscopy
|
|
22
|
+
# now you can paste "coucou_yopta" everywhere
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
> [!IMPORTANT]
|
|
26
|
+
> This works even if you're connected to a remote SSH server. The content travels through SSH and arrives in your local system clipboard.
|
|
27
|
+
|
|
28
|
+
`echo` adds a trailing newline. Strip it with `-s` (or `-x`):
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
echo precision | oscopy -s
|
|
32
|
+
# now you can paste "precision" knowing it won't add a newline
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Why not piping file contents?
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
cat ~/.ssh/id_rsa.pub | oscopy
|
|
39
|
+
# then paste your SSH pubkey on the website...
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Record a Command
|
|
43
|
+
|
|
44
|
+
If you need to execute something and copy both the command you typed and whatever it returned back, e.g.:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
$ ls -la
|
|
48
|
+
total 112
|
|
49
|
+
drwxr-xr-x@ 11 hammerill staff 352 Mar 10 12:48 .
|
|
50
|
+
drwxr-xr-x 12 hammerill staff 384 Mar 2 11:59 ..
|
|
51
|
+
drwxr-xr-x@ 15 hammerill staff 480 Mar 10 15:36 .git
|
|
52
|
+
-rw-r--r--@ 1 hammerill staff 109 Feb 5 09:31 .gitignore
|
|
53
|
+
-rw-r--r--@ 1 hammerill staff 5 Feb 5 09:31 .python-version
|
|
54
|
+
drwxr-xr-x@ 8 hammerill staff 256 Feb 5 09:31 .venv
|
|
55
|
+
-rw-r--r--@ 1 hammerill staff 35149 Feb 5 09:31 LICENSE
|
|
56
|
+
-rw-r--r--@ 1 hammerill staff 390 Feb 5 09:31 pyproject.toml
|
|
57
|
+
-rw-r--r--@ 1 hammerill staff 1753 Mar 10 13:16 README.md
|
|
58
|
+
drwxr-xr-x@ 3 hammerill staff 96 Feb 5 09:31 src
|
|
59
|
+
-rw-r--r--@ 1 hammerill staff 127 Feb 5 09:31 uv.lock
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
...you can use oscopy to do this quickly:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
oscopy run ls -la
|
|
66
|
+
# now you can paste the thing from above
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
You can try any other Shell command:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
oscopy run -- git status --short
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`--` is optional, but useful to avoid ambiguity in edge cases with CLI args.
|
|
76
|
+
|
|
77
|
+
### Record a Shell Session
|
|
78
|
+
|
|
79
|
+
Start a temporary recording shell:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
oscopy record
|
|
83
|
+
# or `oscopy start`
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Session mode currently runs in a temporary `zsh` shell.
|
|
87
|
+
|
|
88
|
+
Then run commands normally. When done:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
oscopy stop
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
This copies a transcript like:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
$ ls *.md
|
|
98
|
+
-rw-r--r--@ 1 hammerill staff 1.1K Mar 10 12:48 README.md
|
|
99
|
+
|
|
100
|
+
$ uname -a
|
|
101
|
+
Darwin pommier 25.3.0 Darwin Kernel Version 25.3.0 ...
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The transcript uses `$ ` as the command prefix, includes both stdout and stderr, and inserts clean blank lines between commands.
|
|
105
|
+
|
|
106
|
+
## Install
|
|
107
|
+
|
|
108
|
+
Install as a [uv tool](https://docs.astral.sh/uv) from this GitHub repo:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
uv tool install git+https://github.com/hammerill/oscopy
|
|
112
|
+
|
|
113
|
+
# and, if you have OhMyZsh installed, quickly setup recommended aliases orun, orec and ostop:
|
|
114
|
+
oscopy aliases > ~/.oh-my-zsh/custom/oscopy-aliases.zsh
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Or local dev install:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
# in oscopy project folder
|
|
121
|
+
uv tool install -e .
|
|
122
|
+
```
|
oscopy-0.1.0/README.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# oscopy
|
|
2
|
+
|
|
3
|
+
Python tool to copy text to your local clipboard over OSC 52, and record shell command transcripts.
|
|
4
|
+
|
|
5
|
+
## How to Use It
|
|
6
|
+
|
|
7
|
+
### Piping
|
|
8
|
+
|
|
9
|
+
You simply pipe to `oscopy`, then paste anywhere:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
echo coucou_yopta | oscopy
|
|
13
|
+
# now you can paste "coucou_yopta" everywhere
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
> [!IMPORTANT]
|
|
17
|
+
> This works even if you're connected to a remote SSH server. The content travels through SSH and arrives in your local system clipboard.
|
|
18
|
+
|
|
19
|
+
`echo` adds a trailing newline. Strip it with `-s` (or `-x`):
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
echo precision | oscopy -s
|
|
23
|
+
# now you can paste "precision" knowing it won't add a newline
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Why not piping file contents?
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
cat ~/.ssh/id_rsa.pub | oscopy
|
|
30
|
+
# then paste your SSH pubkey on the website...
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Record a Command
|
|
34
|
+
|
|
35
|
+
If you need to execute something and copy both the command you typed and whatever it returned back, e.g.:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
$ ls -la
|
|
39
|
+
total 112
|
|
40
|
+
drwxr-xr-x@ 11 hammerill staff 352 Mar 10 12:48 .
|
|
41
|
+
drwxr-xr-x 12 hammerill staff 384 Mar 2 11:59 ..
|
|
42
|
+
drwxr-xr-x@ 15 hammerill staff 480 Mar 10 15:36 .git
|
|
43
|
+
-rw-r--r--@ 1 hammerill staff 109 Feb 5 09:31 .gitignore
|
|
44
|
+
-rw-r--r--@ 1 hammerill staff 5 Feb 5 09:31 .python-version
|
|
45
|
+
drwxr-xr-x@ 8 hammerill staff 256 Feb 5 09:31 .venv
|
|
46
|
+
-rw-r--r--@ 1 hammerill staff 35149 Feb 5 09:31 LICENSE
|
|
47
|
+
-rw-r--r--@ 1 hammerill staff 390 Feb 5 09:31 pyproject.toml
|
|
48
|
+
-rw-r--r--@ 1 hammerill staff 1753 Mar 10 13:16 README.md
|
|
49
|
+
drwxr-xr-x@ 3 hammerill staff 96 Feb 5 09:31 src
|
|
50
|
+
-rw-r--r--@ 1 hammerill staff 127 Feb 5 09:31 uv.lock
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
...you can use oscopy to do this quickly:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
oscopy run ls -la
|
|
57
|
+
# now you can paste the thing from above
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
You can try any other Shell command:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
oscopy run -- git status --short
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`--` is optional, but useful to avoid ambiguity in edge cases with CLI args.
|
|
67
|
+
|
|
68
|
+
### Record a Shell Session
|
|
69
|
+
|
|
70
|
+
Start a temporary recording shell:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
oscopy record
|
|
74
|
+
# or `oscopy start`
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Session mode currently runs in a temporary `zsh` shell.
|
|
78
|
+
|
|
79
|
+
Then run commands normally. When done:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
oscopy stop
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
This copies a transcript like:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
$ ls *.md
|
|
89
|
+
-rw-r--r--@ 1 hammerill staff 1.1K Mar 10 12:48 README.md
|
|
90
|
+
|
|
91
|
+
$ uname -a
|
|
92
|
+
Darwin pommier 25.3.0 Darwin Kernel Version 25.3.0 ...
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The transcript uses `$ ` as the command prefix, includes both stdout and stderr, and inserts clean blank lines between commands.
|
|
96
|
+
|
|
97
|
+
## Install
|
|
98
|
+
|
|
99
|
+
Install as a [uv tool](https://docs.astral.sh/uv) from this GitHub repo:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
uv tool install git+https://github.com/hammerill/oscopy
|
|
103
|
+
|
|
104
|
+
# and, if you have OhMyZsh installed, quickly setup recommended aliases orun, orec and ostop:
|
|
105
|
+
oscopy aliases > ~/.oh-my-zsh/custom/oscopy-aliases.zsh
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Or local dev install:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
# in oscopy project folder
|
|
112
|
+
uv tool install -e .
|
|
113
|
+
```
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "oscopy"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Python script to copy from STD to your clipboard with OSC 52."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "hammerill", email = "kyrylo@hammerill.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
dependencies = []
|
|
11
|
+
|
|
12
|
+
[project.scripts]
|
|
13
|
+
oscopy = "oscopy.cli:main"
|
|
14
|
+
|
|
15
|
+
[build-system]
|
|
16
|
+
requires = ["uv_build>=0.9.18,<0.10.0"]
|
|
17
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import base64
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import re
|
|
6
|
+
import shlex
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
OSC_SELECTION = "c"
|
|
14
|
+
"""The system clipboard target OSC selection."""
|
|
15
|
+
|
|
16
|
+
RECORD_OUTPUT_LOG_ENV = "OSCOPY_RECORD_OUTPUT_LOG"
|
|
17
|
+
RECORD_COMMAND_LOG_ENV = "OSCOPY_RECORD_CMD_LOG"
|
|
18
|
+
RECORD_OFFSET_LOG_ENV = "OSCOPY_RECORD_OFFSET_LOG"
|
|
19
|
+
RECORD_DONE_FILE_ENV = "OSCOPY_RECORD_DONE_FILE"
|
|
20
|
+
|
|
21
|
+
_ANSI_ESCAPE_RE = re.compile(
|
|
22
|
+
r"\x1b(?:\][^\x07]*(?:\x07|\x1b\\)|\[[0-?]*[ -/]*[@-~]|[@-Z\\-_])"
|
|
23
|
+
)
|
|
24
|
+
_CTRL_CHARS_RE = re.compile(r"[\x00-\x08\x0b-\x1f\x7f]")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _osc52(data: bytes) -> bytes:
|
|
28
|
+
# OSC 52: ESC ] 52 ; <selection> ; <base64> BEL
|
|
29
|
+
payload = base64.b64encode(data)
|
|
30
|
+
return b"\x1b]52;" + OSC_SELECTION.encode("ascii") + b";" + payload + b"\x07"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _write_clipboard(data: bytes) -> None:
|
|
34
|
+
payload = _osc52(data)
|
|
35
|
+
|
|
36
|
+
# Prefer the controlling TTY so clipboard writes still work even when
|
|
37
|
+
# stdout is redirected (for example in session recording mode).
|
|
38
|
+
try:
|
|
39
|
+
with open("/dev/tty", "wb", buffering=0) as tty:
|
|
40
|
+
tty.write(payload)
|
|
41
|
+
tty.flush()
|
|
42
|
+
return
|
|
43
|
+
except OSError:
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
sys.stdout.buffer.write(payload)
|
|
47
|
+
sys.stdout.buffer.flush()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _render_transcript(entries: list[tuple[str, str]]) -> str:
|
|
51
|
+
blocks: list[str] = []
|
|
52
|
+
for command, output in entries:
|
|
53
|
+
cleaned = output.rstrip("\n")
|
|
54
|
+
if cleaned:
|
|
55
|
+
blocks.append(f"$ {command}\n{cleaned}")
|
|
56
|
+
else:
|
|
57
|
+
blocks.append(f"$ {command}")
|
|
58
|
+
return "\n\n".join(blocks)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _cmd_copy(args: argparse.Namespace) -> int:
|
|
62
|
+
data = sys.stdin.buffer.read()
|
|
63
|
+
if args.strip_trailing_newline and data.endswith(b"\n"):
|
|
64
|
+
data = data[:-1]
|
|
65
|
+
|
|
66
|
+
_write_clipboard(data)
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _cmd_run(args: argparse.Namespace) -> int:
|
|
71
|
+
tokens = list(args.cmd)
|
|
72
|
+
if tokens and tokens[0] == "--":
|
|
73
|
+
tokens = tokens[1:]
|
|
74
|
+
if not tokens:
|
|
75
|
+
print("oscopy run: missing command", file=sys.stderr)
|
|
76
|
+
return 2
|
|
77
|
+
|
|
78
|
+
command = shlex.join(tokens)
|
|
79
|
+
shell = os.environ.get("SHELL") or "/bin/sh"
|
|
80
|
+
|
|
81
|
+
process = subprocess.Popen(
|
|
82
|
+
[shell, "-lc", command],
|
|
83
|
+
stdout=subprocess.PIPE,
|
|
84
|
+
stderr=subprocess.STDOUT,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
captured = bytearray()
|
|
88
|
+
assert process.stdout is not None
|
|
89
|
+
while True:
|
|
90
|
+
chunk = process.stdout.read(8192)
|
|
91
|
+
if not chunk:
|
|
92
|
+
break
|
|
93
|
+
captured.extend(chunk)
|
|
94
|
+
sys.stdout.buffer.write(chunk)
|
|
95
|
+
sys.stdout.buffer.flush()
|
|
96
|
+
|
|
97
|
+
exit_code = process.wait()
|
|
98
|
+
|
|
99
|
+
output_text = _clean_terminal_text(captured.decode("utf-8", errors="replace"))
|
|
100
|
+
transcript = _render_transcript([(command, output_text)])
|
|
101
|
+
_write_clipboard(transcript.encode("utf-8"))
|
|
102
|
+
return exit_code
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _resolve_record_shell() -> str | None:
|
|
106
|
+
shell = os.environ.get("SHELL")
|
|
107
|
+
if shell and Path(shell).name == "zsh" and Path(shell).exists():
|
|
108
|
+
return shell
|
|
109
|
+
return shutil.which("zsh")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _zsh_record_rc() -> str:
|
|
113
|
+
return """if [[ -f \"${HOME}/.zshrc\" ]]; then
|
|
114
|
+
source \"${HOME}/.zshrc\"
|
|
115
|
+
fi
|
|
116
|
+
|
|
117
|
+
typeset -g OSCOPY_SAVED_STDOUT_FD=-1
|
|
118
|
+
typeset -g OSCOPY_SAVED_STDERR_FD=-1
|
|
119
|
+
typeset -g OSCOPY_CAPTURE_ACTIVE=0
|
|
120
|
+
|
|
121
|
+
_oscopy_preexec() {
|
|
122
|
+
builtin print -r -- \"$1\" >> \"$OSCOPY_RECORD_CMD_LOG\"
|
|
123
|
+
local start_offset
|
|
124
|
+
start_offset=$(command wc -c < \"$OSCOPY_RECORD_OUTPUT_LOG\" 2>/dev/null || builtin echo 0)
|
|
125
|
+
builtin printf '%s\\n' \"$start_offset\" >> \"$OSCOPY_RECORD_OFFSET_LOG\"
|
|
126
|
+
|
|
127
|
+
exec {OSCOPY_SAVED_STDOUT_FD}>&1
|
|
128
|
+
exec {OSCOPY_SAVED_STDERR_FD}>&2
|
|
129
|
+
exec > >(tee -a \"$OSCOPY_RECORD_OUTPUT_LOG\") 2>&1
|
|
130
|
+
OSCOPY_CAPTURE_ACTIVE=1
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
_oscopy_precmd() {
|
|
134
|
+
if [[ \"$OSCOPY_CAPTURE_ACTIVE\" != \"1\" ]]; then
|
|
135
|
+
return
|
|
136
|
+
fi
|
|
137
|
+
|
|
138
|
+
exec 1>&$OSCOPY_SAVED_STDOUT_FD 2>&$OSCOPY_SAVED_STDERR_FD
|
|
139
|
+
exec {OSCOPY_SAVED_STDOUT_FD}>&-
|
|
140
|
+
exec {OSCOPY_SAVED_STDERR_FD}>&-
|
|
141
|
+
OSCOPY_CAPTURE_ACTIVE=0
|
|
142
|
+
|
|
143
|
+
local end_offset
|
|
144
|
+
end_offset=$(command wc -c < \"$OSCOPY_RECORD_OUTPUT_LOG\" 2>/dev/null || builtin echo 0)
|
|
145
|
+
builtin printf '%s\\n' \"$end_offset\" >> \"$OSCOPY_RECORD_OFFSET_LOG\"
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
autoload -Uz add-zsh-hook
|
|
149
|
+
add-zsh-hook preexec _oscopy_preexec
|
|
150
|
+
add-zsh-hook precmd _oscopy_precmd
|
|
151
|
+
|
|
152
|
+
oscopy() {
|
|
153
|
+
command oscopy \"$@\"
|
|
154
|
+
local code=$?
|
|
155
|
+
if [[ \"$1\" == \"stop\" ]]; then
|
|
156
|
+
exit \"$code\"
|
|
157
|
+
fi
|
|
158
|
+
return \"$code\"
|
|
159
|
+
}
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _clean_terminal_text(text: str) -> str:
|
|
164
|
+
# Remove OSC/CSI escapes and non-printable control chars for clean transcripts.
|
|
165
|
+
text = _ANSI_ESCAPE_RE.sub("", text)
|
|
166
|
+
text = text.replace("\r", "")
|
|
167
|
+
text = _CTRL_CHARS_RE.sub("", text)
|
|
168
|
+
return text
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _cmd_record() -> int:
|
|
172
|
+
if os.environ.get(RECORD_OUTPUT_LOG_ENV):
|
|
173
|
+
print("oscopy: recording is already active in this shell", file=sys.stderr)
|
|
174
|
+
return 1
|
|
175
|
+
|
|
176
|
+
shell = _resolve_record_shell()
|
|
177
|
+
if shell is None:
|
|
178
|
+
print("oscopy record currently requires zsh", file=sys.stderr)
|
|
179
|
+
return 1
|
|
180
|
+
|
|
181
|
+
session_dir = Path(tempfile.mkdtemp(prefix="oscopy-record-"))
|
|
182
|
+
output_log = session_dir / "output.log"
|
|
183
|
+
command_log = session_dir / "commands.log"
|
|
184
|
+
offset_log = session_dir / "offsets.log"
|
|
185
|
+
done_file = session_dir / "done"
|
|
186
|
+
zdotdir = session_dir / "zdotdir"
|
|
187
|
+
zdotdir.mkdir(parents=True, exist_ok=True)
|
|
188
|
+
|
|
189
|
+
output_log.touch()
|
|
190
|
+
command_log.touch()
|
|
191
|
+
offset_log.touch()
|
|
192
|
+
(zdotdir / ".zshrc").write_text(_zsh_record_rc(), encoding="utf-8")
|
|
193
|
+
|
|
194
|
+
env = os.environ.copy()
|
|
195
|
+
env[RECORD_OUTPUT_LOG_ENV] = str(output_log)
|
|
196
|
+
env[RECORD_COMMAND_LOG_ENV] = str(command_log)
|
|
197
|
+
env[RECORD_OFFSET_LOG_ENV] = str(offset_log)
|
|
198
|
+
env[RECORD_DONE_FILE_ENV] = str(done_file)
|
|
199
|
+
env["ZDOTDIR"] = str(zdotdir)
|
|
200
|
+
|
|
201
|
+
print("Recording session started. Run `oscopy stop` to finish and copy.", file=sys.stderr)
|
|
202
|
+
shell_code = subprocess.call([shell, "-i"], env=env)
|
|
203
|
+
|
|
204
|
+
finished = done_file.exists()
|
|
205
|
+
if not finished:
|
|
206
|
+
print("Recording ended without `oscopy stop`; nothing copied.", file=sys.stderr)
|
|
207
|
+
|
|
208
|
+
shutil.rmtree(session_dir, ignore_errors=True)
|
|
209
|
+
if finished:
|
|
210
|
+
return 0
|
|
211
|
+
return shell_code
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _parse_record_entries(output_log: Path, command_log: Path, offset_log: Path) -> list[tuple[str, str]]:
|
|
215
|
+
output_bytes = output_log.read_bytes()
|
|
216
|
+
commands = command_log.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
217
|
+
offset_values: list[int] = []
|
|
218
|
+
for raw in offset_log.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
219
|
+
line = raw.strip()
|
|
220
|
+
if not line:
|
|
221
|
+
continue
|
|
222
|
+
try:
|
|
223
|
+
offset = int(line)
|
|
224
|
+
except ValueError:
|
|
225
|
+
continue
|
|
226
|
+
offset_values.append(offset)
|
|
227
|
+
|
|
228
|
+
ranges: list[tuple[int, int]] = []
|
|
229
|
+
size = len(output_bytes)
|
|
230
|
+
for i in range(0, len(offset_values) - 1, 2):
|
|
231
|
+
start = max(0, min(size, offset_values[i]))
|
|
232
|
+
end = max(0, min(size, offset_values[i + 1]))
|
|
233
|
+
if end < start:
|
|
234
|
+
start, end = end, start
|
|
235
|
+
ranges.append((start, end))
|
|
236
|
+
|
|
237
|
+
entries: list[tuple[str, str]] = []
|
|
238
|
+
for idx, (start, end) in enumerate(ranges):
|
|
239
|
+
command = commands[idx] if idx < len(commands) else ""
|
|
240
|
+
stripped = command.strip()
|
|
241
|
+
if not stripped:
|
|
242
|
+
continue
|
|
243
|
+
if stripped.startswith("oscopy stop"):
|
|
244
|
+
continue
|
|
245
|
+
segment = _clean_terminal_text(output_bytes[start:end].decode("utf-8", errors="replace"))
|
|
246
|
+
entries.append((stripped, segment))
|
|
247
|
+
|
|
248
|
+
return entries
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _cmd_stop() -> int:
|
|
252
|
+
output_log = os.environ.get(RECORD_OUTPUT_LOG_ENV)
|
|
253
|
+
command_log = os.environ.get(RECORD_COMMAND_LOG_ENV)
|
|
254
|
+
offset_log = os.environ.get(RECORD_OFFSET_LOG_ENV)
|
|
255
|
+
done_file = os.environ.get(RECORD_DONE_FILE_ENV)
|
|
256
|
+
|
|
257
|
+
if not output_log or not command_log or not offset_log:
|
|
258
|
+
print("oscopy stop: no active recording session", file=sys.stderr)
|
|
259
|
+
return 1
|
|
260
|
+
|
|
261
|
+
output_path = Path(output_log)
|
|
262
|
+
command_path = Path(command_log)
|
|
263
|
+
offset_path = Path(offset_log)
|
|
264
|
+
if not output_path.exists() or not command_path.exists() or not offset_path.exists():
|
|
265
|
+
print("oscopy stop: recording session data is missing", file=sys.stderr)
|
|
266
|
+
return 1
|
|
267
|
+
|
|
268
|
+
entries = _parse_record_entries(output_path, command_path, offset_path)
|
|
269
|
+
if not entries:
|
|
270
|
+
print("oscopy stop: no recorded commands found; clipboard unchanged", file=sys.stderr)
|
|
271
|
+
if done_file:
|
|
272
|
+
Path(done_file).touch()
|
|
273
|
+
return 1
|
|
274
|
+
transcript = _render_transcript(entries)
|
|
275
|
+
_write_clipboard(transcript.encode("utf-8"))
|
|
276
|
+
|
|
277
|
+
if done_file:
|
|
278
|
+
Path(done_file).touch()
|
|
279
|
+
|
|
280
|
+
return 0
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _cmd_aliases() -> int:
|
|
284
|
+
print("alias orun='oscopy run --'")
|
|
285
|
+
print("alias orec='oscopy record'")
|
|
286
|
+
print("alias ostop='oscopy stop'")
|
|
287
|
+
return 0
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
291
|
+
parser = argparse.ArgumentParser(
|
|
292
|
+
prog="oscopy",
|
|
293
|
+
description="Copy text to clipboard via OSC 52, or record shell commands and output.",
|
|
294
|
+
)
|
|
295
|
+
subparsers = parser.add_subparsers(dest="mode")
|
|
296
|
+
|
|
297
|
+
copy_parser = subparsers.add_parser(
|
|
298
|
+
"copy",
|
|
299
|
+
help="copy stdin to clipboard",
|
|
300
|
+
description="Copy stdin to clipboard via OSC 52.",
|
|
301
|
+
)
|
|
302
|
+
copy_parser.add_argument(
|
|
303
|
+
"-x",
|
|
304
|
+
"-s",
|
|
305
|
+
"--strip-trailing-newline",
|
|
306
|
+
action="store_true",
|
|
307
|
+
help="strip the trailing newline (useful with echo)",
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
run_parser = subparsers.add_parser(
|
|
311
|
+
"run",
|
|
312
|
+
help="run one command, copy `$ command + output`",
|
|
313
|
+
description="Run one command and copy its transcript to clipboard.",
|
|
314
|
+
)
|
|
315
|
+
run_parser.add_argument(
|
|
316
|
+
"cmd",
|
|
317
|
+
nargs=argparse.REMAINDER,
|
|
318
|
+
help="command to execute (prefix with -- if needed)",
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
subparsers.add_parser(
|
|
322
|
+
"record",
|
|
323
|
+
help="start a temporary recording shell",
|
|
324
|
+
description="Start a recording shell session. Stop with `oscopy stop`.",
|
|
325
|
+
)
|
|
326
|
+
subparsers.add_parser("start", help="alias for record")
|
|
327
|
+
subparsers.add_parser(
|
|
328
|
+
"stop",
|
|
329
|
+
help="stop recording and copy transcript",
|
|
330
|
+
description="Stop an active recording session and copy its transcript.",
|
|
331
|
+
)
|
|
332
|
+
subparsers.add_parser(
|
|
333
|
+
"aliases",
|
|
334
|
+
help="print shell aliases",
|
|
335
|
+
description="Print convenience aliases for oscopy.",
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
return parser
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def main(argv: list[str] | None = None) -> int:
|
|
342
|
+
raw_argv = list(sys.argv[1:] if argv is None else argv)
|
|
343
|
+
|
|
344
|
+
known_modes = {"copy", "run", "record", "start", "stop", "aliases", "-h", "--help"}
|
|
345
|
+
if not raw_argv:
|
|
346
|
+
raw_argv = ["copy"]
|
|
347
|
+
elif raw_argv[0] not in known_modes:
|
|
348
|
+
raw_argv = ["copy", *raw_argv]
|
|
349
|
+
|
|
350
|
+
parser = _build_parser()
|
|
351
|
+
args = parser.parse_args(raw_argv)
|
|
352
|
+
|
|
353
|
+
if args.mode == "copy":
|
|
354
|
+
return _cmd_copy(args)
|
|
355
|
+
if args.mode == "run":
|
|
356
|
+
return _cmd_run(args)
|
|
357
|
+
if args.mode in {"record", "start"}:
|
|
358
|
+
return _cmd_record()
|
|
359
|
+
if args.mode == "stop":
|
|
360
|
+
return _cmd_stop()
|
|
361
|
+
if args.mode == "aliases":
|
|
362
|
+
return _cmd_aliases()
|
|
363
|
+
|
|
364
|
+
parser.print_help()
|
|
365
|
+
return 2
|