clipto 0.1.0__py3-none-any.whl

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.

Potentially problematic release.


This version of clipto might be problematic. Click here for more details.

clipto/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """Clipto: Instant clipboard, screenshot, and file bridge from browser to terminal."""
2
+
3
+ __version__ = "0.1.0"
4
+ __all__ = ["__version__"]
clipto/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from clipto.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
clipto/cli.py ADDED
@@ -0,0 +1,174 @@
1
+ import argparse
2
+ import os
3
+ import sys
4
+ import threading
5
+ import time
6
+ import webbrowser
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ from clipto import __version__
11
+ from clipto.server import CliptoHTTPServer, CliptoRequestHandler
12
+ from clipto.utils import (
13
+ find_available_port,
14
+ get_local_ip,
15
+ get_tailscale_ip,
16
+ terminal_hyperlink,
17
+ )
18
+
19
+
20
+ def build_parser() -> argparse.ArgumentParser:
21
+ parser = argparse.ArgumentParser(
22
+ prog="clipto",
23
+ description="Instant clipboard, screenshot, and file bridge from browser to terminal.",
24
+ )
25
+ parser.add_argument(
26
+ "-v", "--version",
27
+ action="version",
28
+ version=f"%(prog)s {__version__}",
29
+ )
30
+ parser.add_argument(
31
+ "-p", "--port",
32
+ type=int,
33
+ default=8765,
34
+ help="Port to listen on (default: 8765, auto-increments if occupied; use 0 for random).",
35
+ )
36
+ parser.add_argument(
37
+ "--no-hunt",
38
+ action="store_true",
39
+ help="Disable automatic port hunting if specified port is in use.",
40
+ )
41
+ parser.add_argument(
42
+ "-d", "--dir",
43
+ type=Path,
44
+ default=Path("."),
45
+ help="Target directory where files will be saved (default: current directory).",
46
+ )
47
+ parser.add_argument(
48
+ "-1", "--once",
49
+ action="store_true",
50
+ help="One-shot mode: exit immediately after receiving an upload and print saved file path(s).",
51
+ )
52
+ parser.add_argument(
53
+ "-t", "--title",
54
+ type=str,
55
+ default=None,
56
+ help="Custom session title or prompt shown in the web UI header.",
57
+ )
58
+ parser.add_argument(
59
+ "-o", "--open",
60
+ action="store_true",
61
+ help="Automatically open the web UI in the default browser on launch.",
62
+ )
63
+ parser.add_argument(
64
+ "--host",
65
+ type=str,
66
+ default="0.0.0.0",
67
+ help="Host address to bind to (default: 0.0.0.0).",
68
+ )
69
+ parser.add_argument(
70
+ "--timeout",
71
+ type=int,
72
+ default=None,
73
+ help="Timeout in seconds (useful with --once to prevent hanging indefinitely).",
74
+ )
75
+ return parser
76
+
77
+
78
+ def print_banner(
79
+ port: int,
80
+ target_dir: Path,
81
+ title: Optional[str] = None,
82
+ once: bool = False,
83
+ ):
84
+ local_url = f"http://localhost:{port}"
85
+ lan_ip = get_local_ip()
86
+ tailscale_ip = get_tailscale_ip()
87
+
88
+ lines = [
89
+ f"šŸ“Ž Clipto v{__version__}" + (" [ONE-SHOT MODE]" if once else ""),
90
+ f"Saving to: {target_dir.resolve()}",
91
+ ]
92
+ if title:
93
+ lines.append(f"Session: {title}")
94
+
95
+ lines.append(f"Local: {terminal_hyperlink(local_url)}")
96
+ if lan_ip and lan_ip != "127.0.0.1":
97
+ lines.append(f"Network: {terminal_hyperlink(f'http://{lan_ip}:{port}')}")
98
+ if tailscale_ip:
99
+ lines.append(f"Tailscale: {terminal_hyperlink(f'http://{tailscale_ip}:{port}')}")
100
+
101
+ max_len = max(len(line) for line in lines)
102
+ border = "─" * (max_len + 4)
103
+
104
+ print(f"\nā”Œ{border}┐", file=sys.stderr)
105
+ for line in lines:
106
+ padding = " " * (max_len - len(line))
107
+ print(f"│ {line}{padding} │", file=sys.stderr)
108
+ print(f"ā””{border}ā”˜\n", file=sys.stderr)
109
+ print("Press Cmd+V or drag files into the browser tab. Press Ctrl+C to stop.\n", file=sys.stderr)
110
+
111
+
112
+ def main():
113
+ parser = build_parser()
114
+ args = parser.parse_args()
115
+
116
+ target_dir = args.dir.resolve()
117
+ target_dir.mkdir(parents=True, exist_ok=True)
118
+
119
+ # Determine port
120
+ if args.no_hunt:
121
+ port = args.port
122
+ else:
123
+ try:
124
+ port = find_available_port(args.port)
125
+ except RuntimeError as e:
126
+ print(f"Error: {e}", file=sys.stderr)
127
+ sys.exit(1)
128
+
129
+ try:
130
+ server = CliptoHTTPServer(
131
+ (args.host, port),
132
+ CliptoRequestHandler,
133
+ upload_dir=target_dir,
134
+ title=args.title,
135
+ once=args.once,
136
+ )
137
+ except OSError as e:
138
+ print(f"Error binding to port {port}: {e}", file=sys.stderr)
139
+ sys.exit(1)
140
+
141
+ print_banner(port, target_dir, title=args.title, once=args.once)
142
+
143
+ if args.open:
144
+ webbrowser.open(f"http://localhost:{port}")
145
+
146
+ # Start server thread
147
+ server_thread = threading.Thread(target=server.serve_forever, daemon=True)
148
+ server_thread.start()
149
+
150
+ start_time = time.time()
151
+
152
+ try:
153
+ while not server.shutdown_event.is_set():
154
+ if args.timeout and (time.time() - start_time) > args.timeout:
155
+ print("\nOperation timed out.", file=sys.stderr)
156
+ server.shutdown()
157
+ sys.exit(124)
158
+ time.sleep(0.1)
159
+
160
+ # Shutdown triggered (in once mode)
161
+ server.shutdown()
162
+
163
+ # In once mode, output the uploaded file path(s) to stdout
164
+ for file_path in server.uploaded_files:
165
+ print(str(file_path.resolve()))
166
+
167
+ except KeyboardInterrupt:
168
+ print("\nStopping Clipto server...", file=sys.stderr)
169
+ server.shutdown()
170
+ sys.exit(0)
171
+
172
+
173
+ if __name__ == "__main__":
174
+ main()
clipto/server.py ADDED
@@ -0,0 +1,186 @@
1
+ import json
2
+ import mimetypes
3
+ import socket
4
+ import threading
5
+ import urllib.parse
6
+ from email.parser import BytesParser
7
+ from email.policy import default
8
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
9
+ from pathlib import Path
10
+ from typing import List, Optional
11
+
12
+ from clipto.utils import get_unique_path, sanitize_filename
13
+
14
+
15
+ def get_web_dir() -> Path:
16
+ """Locate the web assets directory."""
17
+ try:
18
+ import importlib.resources as pkg_resources
19
+ return Path(str(pkg_resources.files("clipto").joinpath("web")))
20
+ except Exception:
21
+ return Path(__file__).parent / "web"
22
+
23
+
24
+ class CliptoHTTPServer(ThreadingHTTPServer):
25
+ def __init__(
26
+ self,
27
+ server_address,
28
+ RequestHandlerClass,
29
+ upload_dir: Path,
30
+ title: Optional[str] = None,
31
+ once: bool = False,
32
+ ):
33
+ super().__init__(server_address, RequestHandlerClass)
34
+ self.upload_dir = Path(upload_dir).resolve()
35
+ self.upload_dir.mkdir(parents=True, exist_ok=True)
36
+ self.title = title
37
+ self.once = once
38
+ self.uploaded_files: List[Path] = []
39
+ self.shutdown_event = threading.Event()
40
+
41
+
42
+ class CliptoRequestHandler(BaseHTTPRequestHandler):
43
+ server: CliptoHTTPServer
44
+
45
+ def log_message(self, format, *args):
46
+ # Suppress verbose standard logging to keep terminal clean
47
+ pass
48
+
49
+ def send_json(self, status: int, data: dict):
50
+ body = json.dumps(data).encode("utf-8")
51
+ self.send_response(status)
52
+ self.send_header("Content-Type", "application/json; charset=utf-8")
53
+ self.send_header("Content-Length", str(len(body)))
54
+ self.send_header("Access-Control-Allow-Origin", "*")
55
+ self.end_headers()
56
+ self.wfile.write(body)
57
+
58
+ def serve_file(self, file_path: Path):
59
+ if not file_path.is_file():
60
+ self.send_error(404, "File Not Found")
61
+ return
62
+
63
+ content_type, _ = mimetypes.guess_type(str(file_path))
64
+ if file_path.suffix == ".css":
65
+ content_type = "text/css"
66
+ elif file_path.suffix == ".js":
67
+ content_type = "application/javascript"
68
+
69
+ content = file_path.read_bytes()
70
+ self.send_response(200)
71
+ self.send_header("Content-Type", content_type or "application/octet-stream")
72
+ self.send_header("Content-Length", str(len(content)))
73
+ self.end_headers()
74
+ self.wfile.write(content)
75
+
76
+ def do_GET(self):
77
+ web_dir = get_web_dir()
78
+ parsed_url = urllib.parse.urlparse(self.path)
79
+ path = parsed_url.path
80
+
81
+ if path in ("/", "/index.html"):
82
+ self.serve_file(web_dir / "index.html")
83
+ elif path == "/static/style.css":
84
+ self.serve_file(web_dir / "style.css")
85
+ elif path == "/static/app.js":
86
+ self.serve_file(web_dir / "app.js")
87
+ elif path == "/api/info":
88
+ self.send_json(200, {
89
+ "hostname": socket.gethostname(),
90
+ "dir": str(self.server.upload_dir),
91
+ "title": self.server.title,
92
+ "once": self.server.once,
93
+ })
94
+ elif path == "/api/file":
95
+ query = urllib.parse.parse_qs(parsed_url.query)
96
+ filename = query.get("name", [None])[0]
97
+ if not filename:
98
+ self.send_error(400, "Missing file name")
99
+ return
100
+
101
+ safe_name = sanitize_filename(filename)
102
+ target = (self.server.upload_dir / safe_name).resolve()
103
+
104
+ # Prevent directory traversal attacks
105
+ try:
106
+ target.relative_to(self.server.upload_dir)
107
+ except ValueError:
108
+ self.send_error(403, "Forbidden")
109
+ return
110
+
111
+ if not target.is_file():
112
+ self.send_error(404, "File Not Found")
113
+ return
114
+
115
+ self.serve_file(target)
116
+ elif path == "/api/health":
117
+ self.send_json(200, {"status": "ok"})
118
+ else:
119
+ self.send_error(404, "Not Found")
120
+
121
+ def do_POST(self):
122
+ if self.path != "/api/upload":
123
+ self.send_error(404, "Not Found")
124
+ return
125
+
126
+ content_type = self.headers.get("Content-Type", "")
127
+ if not content_type.startswith("multipart/form-data"):
128
+ self.send_error(400, "Content-Type must be multipart/form-data")
129
+ return
130
+
131
+ try:
132
+ content_length = int(self.headers.get("Content-Length", 0))
133
+ if content_length <= 0:
134
+ self.send_error(400, "Invalid Content-Length")
135
+ return
136
+
137
+ body = self.rfile.read(content_length)
138
+ raw_data = f"Content-Type: {content_type}\r\n\r\n".encode("utf-8") + body
139
+ msg = BytesParser(policy=default).parsebytes(raw_data)
140
+
141
+ saved_results = []
142
+ for part in msg.iter_parts():
143
+ filename = part.get_filename()
144
+ if not filename:
145
+ # Check for note or text parameter
146
+ name = part.get_param("name", header="content-disposition")
147
+ if name == "note":
148
+ text = part.get_payload(decode=True).decode("utf-8", errors="replace").strip()
149
+ if text:
150
+ target_path = get_unique_path(self.server.upload_dir, "note.txt")
151
+ target_path.write_text(text, encoding="utf-8")
152
+ saved_results.append({
153
+ "name": target_path.name,
154
+ "path": str(target_path.resolve()),
155
+ "size": target_path.stat().st_size,
156
+ "is_image": False,
157
+ })
158
+ self.server.uploaded_files.append(target_path)
159
+ continue
160
+
161
+ clean_name = sanitize_filename(filename, default_name="upload.bin")
162
+ target_path = get_unique_path(self.server.upload_dir, clean_name)
163
+ payload = part.get_payload(decode=True)
164
+
165
+ if payload is not None:
166
+ target_path.write_bytes(payload)
167
+ is_img = target_path.suffix.lower() in {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"}
168
+ saved_results.append({
169
+ "name": target_path.name,
170
+ "path": str(target_path.resolve()),
171
+ "size": target_path.stat().st_size,
172
+ "is_image": is_img,
173
+ })
174
+ self.server.uploaded_files.append(target_path)
175
+
176
+ self.send_json(200, {
177
+ "status": "ok",
178
+ "saved_files": saved_results,
179
+ })
180
+
181
+ if self.server.once and self.server.uploaded_files:
182
+ # Trigger shutdown cleanly in a short delay so the response finishes sending
183
+ threading.Timer(0.3, self.server.shutdown_event.set).start()
184
+
185
+ except Exception as e:
186
+ self.send_json(500, {"error": str(e)})
clipto/utils.py ADDED
@@ -0,0 +1,99 @@
1
+ import os
2
+ import re
3
+ import socket
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Optional, Tuple
8
+
9
+
10
+ def find_available_port(preferred_port: int = 8765, max_attempts: int = 50) -> int:
11
+ """
12
+ Find an available TCP port.
13
+ If preferred_port is 0, let the OS allocate an ephemeral port.
14
+ Otherwise, check preferred_port up to preferred_port + max_attempts.
15
+ """
16
+ if preferred_port == 0:
17
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
18
+ s.bind(("", 0))
19
+ return s.getsockname()[1]
20
+
21
+ for port in range(preferred_port, preferred_port + max_attempts):
22
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
23
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
24
+ try:
25
+ s.bind(("", port))
26
+ return port
27
+ except OSError:
28
+ continue
29
+
30
+ raise RuntimeError(f"No available port found in range {preferred_port} - {preferred_port + max_attempts}")
31
+
32
+
33
+ def get_local_ip() -> Optional[str]:
34
+ """Determine the LAN IP address of this machine."""
35
+ try:
36
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
37
+ s.settimeout(0.5)
38
+ # Doesn't actually send packets, but prompts OS to select route
39
+ s.connect(("8.8.8.8", 80))
40
+ return s.getsockname()[0]
41
+ except Exception:
42
+ return None
43
+
44
+
45
+ def get_tailscale_ip() -> Optional[str]:
46
+ """Detect Tailscale IPv4 if available."""
47
+ try:
48
+ result = subprocess.run(
49
+ ["tailscale", "ip", "-4"],
50
+ capture_output=True,
51
+ text=True,
52
+ timeout=1,
53
+ check=False,
54
+ )
55
+ if result.returncode == 0:
56
+ ip = result.stdout.strip().split("\n")[0]
57
+ if ip.startswith("100."):
58
+ return ip
59
+ except Exception:
60
+ pass
61
+ return None
62
+
63
+
64
+ def sanitize_filename(filename: str, default_name: str = "upload") -> str:
65
+ """Sanitize uploaded filenames to prevent path traversal and unsafe characters."""
66
+ filename = os.path.basename(filename).strip()
67
+ # Remove null bytes and control chars
68
+ filename = re.sub(r"[\x00-\x1f\x7f]", "", filename)
69
+ # Replace unsafe characters
70
+ filename = re.sub(r'[\\/:*?"<>|]', "_", filename)
71
+ return filename or default_name
72
+
73
+
74
+ def get_unique_path(directory: Path, filename: str) -> Path:
75
+ """
76
+ Ensure the path does not overwrite an existing file.
77
+ e.g., photo.png -> photo (1).png -> photo (2).png
78
+ """
79
+ target = directory / filename
80
+ if not target.exists():
81
+ return target
82
+
83
+ stem = target.stem
84
+ suffix = target.suffix
85
+ counter = 1
86
+
87
+ while True:
88
+ candidate = directory / f"{stem} ({counter}){suffix}"
89
+ if not candidate.exists():
90
+ return candidate
91
+ counter += 1
92
+
93
+
94
+ def terminal_hyperlink(url: str, text: Optional[str] = None) -> str:
95
+ """Return an OSC 8 terminal hyperlink if terminal is interactive."""
96
+ display_text = text or url
97
+ if sys.stdout.isatty():
98
+ return f"\033]8;;{url}\033\\{display_text}\033]8;;\033\\"
99
+ return display_text