ghostbit-cli 1.2.0__tar.gz → 1.3.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.
- {ghostbit_cli-1.2.0 → ghostbit_cli-1.3.0}/PKG-INFO +1 -1
- ghostbit_cli-1.3.0/__init__.py +551 -0
- ghostbit_cli-1.3.0/_api.py +61 -0
- ghostbit_cli-1.3.0/_completion.py +160 -0
- ghostbit_cli-1.3.0/_config.py +75 -0
- ghostbit_cli-1.3.0/_crypto.py +67 -0
- ghostbit_cli-1.3.0/_history.py +36 -0
- {ghostbit_cli-1.2.0 → ghostbit_cli-1.3.0}/ghostbit_cli.egg-info/PKG-INFO +1 -1
- {ghostbit_cli-1.2.0 → ghostbit_cli-1.3.0}/ghostbit_cli.egg-info/SOURCES.txt +13 -1
- {ghostbit_cli-1.2.0 → ghostbit_cli-1.3.0}/pyproject.toml +7 -2
- ghostbit_cli-1.2.0/cli.py +0 -827
- {ghostbit_cli-1.2.0 → ghostbit_cli-1.3.0}/README.md +0 -0
- {ghostbit_cli-1.2.0 → ghostbit_cli-1.3.0}/ghostbit_cli.egg-info/dependency_links.txt +0 -0
- {ghostbit_cli-1.2.0 → ghostbit_cli-1.3.0}/ghostbit_cli.egg-info/entry_points.txt +0 -0
- {ghostbit_cli-1.2.0 → ghostbit_cli-1.3.0}/ghostbit_cli.egg-info/requires.txt +0 -0
- {ghostbit_cli-1.2.0 → ghostbit_cli-1.3.0}/ghostbit_cli.egg-info/top_level.txt +0 -0
- {ghostbit_cli-1.2.0 → ghostbit_cli-1.3.0}/setup.cfg +0 -0
- {ghostbit_cli-1.2.0 → ghostbit_cli-1.3.0}/setup.py +0 -0
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
"""Ghostbit CLI entry point + public re-exports.
|
|
2
|
+
|
|
3
|
+
The CLI is laid out as a small package so the crypto, config, history,
|
|
4
|
+
HTTP, and completion concerns each live in their own focused module.
|
|
5
|
+
This `__init__` hosts the command handlers (`cmd_paste`, `cmd_view`,
|
|
6
|
+
`cmd_delete`, `cmd_list`) and the argparse wiring in `main()`.
|
|
7
|
+
|
|
8
|
+
Underscore-prefixed names at the bottom of this file are re-exports
|
|
9
|
+
kept for backwards compatibility with `tests/test_cli_crypto.py`, which
|
|
10
|
+
imports `_encrypt`/`_decrypt`/etc. directly from the `cli` module.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import base64
|
|
17
|
+
import getpass
|
|
18
|
+
import json
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
import urllib.error
|
|
22
|
+
import urllib.request
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from ._api import SSL_CTX, USER_AGENT, api_create
|
|
26
|
+
from ._completion import cmd_completion as _cmd_completion_raw
|
|
27
|
+
from ._config import DEFAULT_SERVER, cmd_config, load_config
|
|
28
|
+
from ._crypto import (
|
|
29
|
+
decrypt,
|
|
30
|
+
derive_key,
|
|
31
|
+
encrypt,
|
|
32
|
+
gen_key,
|
|
33
|
+
gen_salt,
|
|
34
|
+
key_to_fragment,
|
|
35
|
+
require_crypto,
|
|
36
|
+
)
|
|
37
|
+
from ._history import HISTORY_PATH, history_append, history_load
|
|
38
|
+
|
|
39
|
+
# Version used by --version output. Read from installed package metadata
|
|
40
|
+
# so bumping cli/pyproject.toml is the single knob.
|
|
41
|
+
try:
|
|
42
|
+
from importlib.metadata import PackageNotFoundError
|
|
43
|
+
from importlib.metadata import version as _pkg_version
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
__version__ = _pkg_version("ghostbit-cli")
|
|
47
|
+
except PackageNotFoundError:
|
|
48
|
+
__version__ = "dev"
|
|
49
|
+
except ImportError:
|
|
50
|
+
__version__ = "dev"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
LANGUAGES = [
|
|
54
|
+
"python", "javascript", "typescript", "go", "rust", "ruby", "php",
|
|
55
|
+
"java", "c", "cpp", "csharp", "bash", "powershell", "html", "css",
|
|
56
|
+
"sql", "json", "yaml", "toml", "xml", "markdown", "dockerfile",
|
|
57
|
+
"kotlin", "swift", "lua", "r", "diff",
|
|
58
|
+
] # fmt: skip
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ── gbit paste ───────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def cmd_paste(args) -> None:
|
|
65
|
+
cfg = load_config()
|
|
66
|
+
server = args.server or cfg.get("server", DEFAULT_SERVER)
|
|
67
|
+
|
|
68
|
+
if args.file:
|
|
69
|
+
path = Path(args.file)
|
|
70
|
+
if not path.exists():
|
|
71
|
+
print(f"File not found: {args.file}", file=sys.stderr)
|
|
72
|
+
sys.exit(1)
|
|
73
|
+
content = path.read_text(errors="replace")
|
|
74
|
+
# Infer language from extension if not specified.
|
|
75
|
+
if args.lang is None:
|
|
76
|
+
ext_map = {
|
|
77
|
+
".py": "python", ".js": "javascript", ".ts": "typescript",
|
|
78
|
+
".go": "go", ".rs": "rust", ".rb": "ruby", ".php": "php",
|
|
79
|
+
".java": "java", ".c": "c", ".cpp": "cpp", ".cs": "csharp",
|
|
80
|
+
".sh": "bash", ".bash": "bash", ".zsh": "bash",
|
|
81
|
+
".html": "html", ".css": "css", ".sql": "sql",
|
|
82
|
+
".json": "json", ".yaml": "yaml", ".yml": "yaml",
|
|
83
|
+
".toml": "toml", ".xml": "xml", ".md": "markdown",
|
|
84
|
+
".dockerfile": "dockerfile", ".kt": "kotlin", ".swift": "swift",
|
|
85
|
+
".lua": "lua", ".r": "r", ".diff": "diff", ".patch": "diff",
|
|
86
|
+
} # fmt: skip
|
|
87
|
+
# Handle extensionless files by name (e.g. Dockerfile, Makefile).
|
|
88
|
+
name_map = {"dockerfile": "dockerfile", "makefile": "makefile"}
|
|
89
|
+
args.lang = ext_map.get(path.suffix.lower()) or name_map.get(path.name.lower())
|
|
90
|
+
else:
|
|
91
|
+
if sys.stdin.isatty():
|
|
92
|
+
print("Reading from stdin… (Ctrl+D to finish)", file=sys.stderr)
|
|
93
|
+
content = sys.stdin.read()
|
|
94
|
+
|
|
95
|
+
if not content.strip():
|
|
96
|
+
print("Error: content is empty.", file=sys.stderr)
|
|
97
|
+
sys.exit(1)
|
|
98
|
+
|
|
99
|
+
require_crypto()
|
|
100
|
+
|
|
101
|
+
# Encrypt client-side (mirrors browser e2e.js).
|
|
102
|
+
# If --password was given without a value, prompt interactively.
|
|
103
|
+
password = args.password
|
|
104
|
+
if password is True or password == "":
|
|
105
|
+
password = getpass.getpass("Password: ")
|
|
106
|
+
if not password:
|
|
107
|
+
print("Error: password cannot be empty.", file=sys.stderr)
|
|
108
|
+
sys.exit(1)
|
|
109
|
+
|
|
110
|
+
if password:
|
|
111
|
+
kdf_salt = gen_salt()
|
|
112
|
+
key = derive_key(password, kdf_salt)
|
|
113
|
+
else:
|
|
114
|
+
key = gen_key()
|
|
115
|
+
kdf_salt = None
|
|
116
|
+
|
|
117
|
+
ciphertext, nonce = encrypt(content, key)
|
|
118
|
+
|
|
119
|
+
payload = {
|
|
120
|
+
"content": ciphertext,
|
|
121
|
+
"nonce": nonce,
|
|
122
|
+
"kdf_salt": kdf_salt,
|
|
123
|
+
"language": args.lang,
|
|
124
|
+
"expires_in": args.expires,
|
|
125
|
+
"burn": args.burn,
|
|
126
|
+
"max_views": args.max_views,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
result = api_create(server, payload)
|
|
130
|
+
|
|
131
|
+
# Build full URL with fragment (key + delete token).
|
|
132
|
+
if password:
|
|
133
|
+
fragment = f"~{result['delete_token']}"
|
|
134
|
+
else:
|
|
135
|
+
fragment = f"{key_to_fragment(key)}~{result['delete_token']}"
|
|
136
|
+
|
|
137
|
+
full_url = f"{result['url']}#{fragment}"
|
|
138
|
+
|
|
139
|
+
# Append to local history (best-effort, privacy-first — stays on disk only).
|
|
140
|
+
if not args.no_history:
|
|
141
|
+
history_append(
|
|
142
|
+
{
|
|
143
|
+
"id": result["id"],
|
|
144
|
+
"url": result["url"],
|
|
145
|
+
"full_url": full_url,
|
|
146
|
+
"created_at": int(time.time()),
|
|
147
|
+
"language": args.lang,
|
|
148
|
+
"expires_at": result.get("expires_at"),
|
|
149
|
+
}
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
if args.json:
|
|
153
|
+
result["full_url"] = full_url
|
|
154
|
+
print(json.dumps(result, indent=2))
|
|
155
|
+
elif args.quiet:
|
|
156
|
+
print(full_url)
|
|
157
|
+
else:
|
|
158
|
+
print(full_url)
|
|
159
|
+
if sys.stdout.isatty():
|
|
160
|
+
parts = []
|
|
161
|
+
if result.get("expires_at"):
|
|
162
|
+
delta = result["expires_at"] - int(time.time())
|
|
163
|
+
if delta < 3600:
|
|
164
|
+
parts.append(f"expires in {delta // 60}m")
|
|
165
|
+
elif delta < 86400:
|
|
166
|
+
parts.append(f"expires in {delta // 3600}h")
|
|
167
|
+
else:
|
|
168
|
+
parts.append(f"expires in {delta // 86400}d")
|
|
169
|
+
if result.get("burn"):
|
|
170
|
+
parts.append("burn after read")
|
|
171
|
+
if result.get("max_views"):
|
|
172
|
+
parts.append(f"max {result['max_views']} views")
|
|
173
|
+
if password:
|
|
174
|
+
parts.append("password protected")
|
|
175
|
+
if parts:
|
|
176
|
+
print(" " + " · ".join(parts), file=sys.stderr)
|
|
177
|
+
if not password:
|
|
178
|
+
print(
|
|
179
|
+
" Share the full URL — the decryption key is in the #fragment.",
|
|
180
|
+
file=sys.stderr,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# ── gbit view ────────────────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _print_highlighted(content: str, language: str | None) -> None:
|
|
188
|
+
"""Print content with terminal syntax highlighting.
|
|
189
|
+
|
|
190
|
+
Markdown: rendered via rich if available.
|
|
191
|
+
Other languages: Pygments with a true-color terminal formatter.
|
|
192
|
+
Fallback: plain text.
|
|
193
|
+
"""
|
|
194
|
+
if language == "markdown":
|
|
195
|
+
try:
|
|
196
|
+
from rich.console import Console
|
|
197
|
+
from rich.markdown import Markdown
|
|
198
|
+
|
|
199
|
+
Console().print(Markdown(content))
|
|
200
|
+
return
|
|
201
|
+
except ImportError:
|
|
202
|
+
pass
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
from pygments import highlight
|
|
206
|
+
from pygments.formatters import TerminalTrueColorFormatter
|
|
207
|
+
from pygments.lexers import TextLexer, get_lexer_by_name
|
|
208
|
+
from pygments.util import ClassNotFound
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
lexer = get_lexer_by_name(language) if language else TextLexer()
|
|
212
|
+
except ClassNotFound:
|
|
213
|
+
lexer = TextLexer()
|
|
214
|
+
|
|
215
|
+
print(highlight(content, lexer, TerminalTrueColorFormatter(style="monokai")), end="")
|
|
216
|
+
return
|
|
217
|
+
except ImportError:
|
|
218
|
+
pass
|
|
219
|
+
|
|
220
|
+
print(content)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def cmd_view(args) -> None:
|
|
224
|
+
from urllib.parse import urldefrag, urlparse
|
|
225
|
+
|
|
226
|
+
url_no_frag, fragment = urldefrag(args.url)
|
|
227
|
+
parsed = urlparse(url_no_frag)
|
|
228
|
+
server = f"{parsed.scheme}://{parsed.netloc}"
|
|
229
|
+
paste_id = parsed.path.strip("/")
|
|
230
|
+
|
|
231
|
+
if not paste_id or not parsed.scheme:
|
|
232
|
+
print("Error: invalid URL.", file=sys.stderr)
|
|
233
|
+
sys.exit(1)
|
|
234
|
+
|
|
235
|
+
# Fragment format: KEY_B64URL~DELETE_TOKEN (no password)
|
|
236
|
+
# ~DELETE_TOKEN (password-protected)
|
|
237
|
+
key_b64url = fragment.partition("~")[0]
|
|
238
|
+
is_password = not key_b64url
|
|
239
|
+
|
|
240
|
+
api_url = f"{server}/api/v1/pastes/{paste_id}"
|
|
241
|
+
req = urllib.request.Request(api_url, headers={"User-Agent": USER_AGENT})
|
|
242
|
+
try:
|
|
243
|
+
with urllib.request.urlopen(req, timeout=15, context=SSL_CTX) as resp:
|
|
244
|
+
data = json.loads(resp.read())
|
|
245
|
+
except urllib.error.HTTPError as e:
|
|
246
|
+
body = e.read().decode(errors="replace")
|
|
247
|
+
try:
|
|
248
|
+
detail = json.loads(body).get("detail", body)
|
|
249
|
+
except Exception: # noqa: BLE001
|
|
250
|
+
detail = body
|
|
251
|
+
print(f"Error {e.code}: {detail}", file=sys.stderr)
|
|
252
|
+
sys.exit(1)
|
|
253
|
+
except urllib.error.URLError as e:
|
|
254
|
+
print(f"Could not connect to {server}: {e.reason}", file=sys.stderr)
|
|
255
|
+
sys.exit(1)
|
|
256
|
+
|
|
257
|
+
require_crypto()
|
|
258
|
+
|
|
259
|
+
if is_password:
|
|
260
|
+
password = getpass.getpass("Password: ")
|
|
261
|
+
kdf_salt = data.get("kdf_salt")
|
|
262
|
+
if not kdf_salt:
|
|
263
|
+
print("Error: no KDF salt — paste is not password-protected.", file=sys.stderr)
|
|
264
|
+
sys.exit(1)
|
|
265
|
+
key = derive_key(password, kdf_salt)
|
|
266
|
+
else:
|
|
267
|
+
# Restore base64 padding stripped for URL safety.
|
|
268
|
+
padded = key_b64url + "=" * (-len(key_b64url) % 4)
|
|
269
|
+
key = base64.urlsafe_b64decode(padded)
|
|
270
|
+
|
|
271
|
+
try:
|
|
272
|
+
plaintext = decrypt(data["content"], data["nonce"], key)
|
|
273
|
+
except Exception: # noqa: BLE001
|
|
274
|
+
print("Error: decryption failed — wrong key or corrupted paste.", file=sys.stderr)
|
|
275
|
+
sys.exit(1)
|
|
276
|
+
|
|
277
|
+
# Warn if this view just burned the paste.
|
|
278
|
+
burned = data.get("burn") or (
|
|
279
|
+
data.get("max_views") and data.get("view_count", 0) >= data["max_views"]
|
|
280
|
+
)
|
|
281
|
+
if burned and sys.stderr.isatty():
|
|
282
|
+
print(
|
|
283
|
+
"⚠️ This paste has been burned and is no longer available on the server.",
|
|
284
|
+
file=sys.stderr,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
language = data.get("language")
|
|
288
|
+
if sys.stdout.isatty():
|
|
289
|
+
_print_highlighted(plaintext, language)
|
|
290
|
+
else:
|
|
291
|
+
print(plaintext, end="")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# ── gbit delete ──────────────────────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def cmd_delete(url: str) -> None:
|
|
298
|
+
from urllib.parse import urldefrag, urlparse
|
|
299
|
+
|
|
300
|
+
url_no_frag, fragment = urldefrag(url)
|
|
301
|
+
parsed = urlparse(url_no_frag)
|
|
302
|
+
server = f"{parsed.scheme}://{parsed.netloc}"
|
|
303
|
+
paste_id = parsed.path.strip("/")
|
|
304
|
+
|
|
305
|
+
if not paste_id or not parsed.scheme:
|
|
306
|
+
print("Error: invalid URL.", file=sys.stderr)
|
|
307
|
+
sys.exit(1)
|
|
308
|
+
|
|
309
|
+
# Fragment: KEY~DELETE_TOKEN or ~DELETE_TOKEN
|
|
310
|
+
delete_token = fragment.partition("~")[2]
|
|
311
|
+
if not delete_token:
|
|
312
|
+
print(
|
|
313
|
+
"Error: delete token missing from URL fragment (expected KEY~TOKEN or ~TOKEN).",
|
|
314
|
+
file=sys.stderr,
|
|
315
|
+
)
|
|
316
|
+
sys.exit(1)
|
|
317
|
+
|
|
318
|
+
api_url = f"{server}/api/v1/pastes/{paste_id}"
|
|
319
|
+
req = urllib.request.Request(
|
|
320
|
+
api_url,
|
|
321
|
+
headers={"User-Agent": USER_AGENT, "X-Delete-Token": delete_token},
|
|
322
|
+
method="DELETE",
|
|
323
|
+
)
|
|
324
|
+
try:
|
|
325
|
+
with urllib.request.urlopen(req, timeout=15, context=SSL_CTX):
|
|
326
|
+
pass
|
|
327
|
+
print(f"Deleted {paste_id}.")
|
|
328
|
+
except urllib.error.HTTPError as e:
|
|
329
|
+
if e.code == 403:
|
|
330
|
+
print("Error: invalid delete token.", file=sys.stderr)
|
|
331
|
+
elif e.code == 404:
|
|
332
|
+
print("Error: paste not found (already deleted or expired).", file=sys.stderr)
|
|
333
|
+
else:
|
|
334
|
+
print(f"Error {e.code}.", file=sys.stderr)
|
|
335
|
+
sys.exit(1)
|
|
336
|
+
except urllib.error.URLError as e:
|
|
337
|
+
print(f"Could not connect to {server}: {e.reason}", file=sys.stderr)
|
|
338
|
+
sys.exit(1)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# ── gbit list ────────────────────────────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def cmd_list(clear: bool = False) -> None:
|
|
345
|
+
if clear:
|
|
346
|
+
if HISTORY_PATH.exists():
|
|
347
|
+
HISTORY_PATH.unlink()
|
|
348
|
+
print("History cleared.")
|
|
349
|
+
else:
|
|
350
|
+
print("No history file found.")
|
|
351
|
+
return
|
|
352
|
+
|
|
353
|
+
entries = history_load()
|
|
354
|
+
if not entries:
|
|
355
|
+
print("No pastes in local history.", file=sys.stderr)
|
|
356
|
+
print(f" History file: {HISTORY_PATH}", file=sys.stderr)
|
|
357
|
+
return
|
|
358
|
+
|
|
359
|
+
now = int(time.time())
|
|
360
|
+
|
|
361
|
+
def _age(ts: int) -> str:
|
|
362
|
+
delta = now - ts
|
|
363
|
+
if delta < 120:
|
|
364
|
+
return "just now"
|
|
365
|
+
if delta < 3600:
|
|
366
|
+
return f"{delta // 60}m ago"
|
|
367
|
+
if delta < 86400:
|
|
368
|
+
return f"{delta // 3600}h ago"
|
|
369
|
+
return f"{delta // 86400}d ago"
|
|
370
|
+
|
|
371
|
+
def _expiry(expires_at) -> str:
|
|
372
|
+
if not expires_at:
|
|
373
|
+
return "never"
|
|
374
|
+
delta = expires_at - now
|
|
375
|
+
if delta <= 0:
|
|
376
|
+
return "expired"
|
|
377
|
+
if delta < 3600:
|
|
378
|
+
return f"in {delta // 60}m"
|
|
379
|
+
if delta < 86400:
|
|
380
|
+
return f"in {delta // 3600}h"
|
|
381
|
+
return f"in {delta // 86400}d"
|
|
382
|
+
|
|
383
|
+
print(f"{'ID':<12} {'Lang':<14} {'Created':<12} {'Expires':<10} URL")
|
|
384
|
+
print("-" * 80)
|
|
385
|
+
for e in reversed(entries):
|
|
386
|
+
row_id = e.get("id", "?")[:10]
|
|
387
|
+
lang = (e.get("language") or "plain")[:12]
|
|
388
|
+
created = _age(e.get("created_at", 0))
|
|
389
|
+
expires = _expiry(e.get("expires_at"))
|
|
390
|
+
full_url = e.get("full_url", e.get("url", ""))
|
|
391
|
+
print(f"{row_id:<12} {lang:<14} {created:<12} {expires:<10} {full_url}")
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def cmd_completion(shell: str) -> None:
|
|
395
|
+
"""Thin wrapper over `_completion.cmd_completion` that injects LANGUAGES."""
|
|
396
|
+
_cmd_completion_raw(shell, LANGUAGES)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
# ── Config subcommand parser (local, calls into _config.cmd_config) ──────────
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _run_config(argv) -> None:
|
|
403
|
+
parser = argparse.ArgumentParser(
|
|
404
|
+
prog="gbit config", description="Manage Ghostbit CLI configuration."
|
|
405
|
+
)
|
|
406
|
+
sub = parser.add_subparsers(dest="action")
|
|
407
|
+
|
|
408
|
+
sub.add_parser("show", help="Show current configuration.")
|
|
409
|
+
|
|
410
|
+
p_set = sub.add_parser("set", help="Set a config value.")
|
|
411
|
+
p_set.add_argument("key", help="Config key (e.g. server)")
|
|
412
|
+
p_set.add_argument("value", help="Value to set")
|
|
413
|
+
|
|
414
|
+
p_unset = sub.add_parser("unset", help="Remove a config key.")
|
|
415
|
+
p_unset.add_argument("key", help="Config key to remove")
|
|
416
|
+
|
|
417
|
+
args = parser.parse_args(argv)
|
|
418
|
+
if not args.action:
|
|
419
|
+
parser.print_help()
|
|
420
|
+
sys.exit(0)
|
|
421
|
+
cmd_config(args.action, getattr(args, "key", None), getattr(args, "value", None))
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
# ── Entry point ──────────────────────────────────────────────────────────────
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def main() -> None:
|
|
428
|
+
# Route subcommands early so file paths aren't mistaken for them.
|
|
429
|
+
if len(sys.argv) > 1 and sys.argv[1] == "config":
|
|
430
|
+
_run_config(sys.argv[2:])
|
|
431
|
+
return
|
|
432
|
+
|
|
433
|
+
if len(sys.argv) > 1 and sys.argv[1] == "delete":
|
|
434
|
+
if len(sys.argv) < 3:
|
|
435
|
+
print("Usage: gbit delete <url>", file=sys.stderr)
|
|
436
|
+
sys.exit(1)
|
|
437
|
+
cmd_delete(sys.argv[2])
|
|
438
|
+
return
|
|
439
|
+
|
|
440
|
+
if len(sys.argv) > 1 and sys.argv[1] == "list":
|
|
441
|
+
clear = "--clear" in sys.argv
|
|
442
|
+
cmd_list(clear=clear)
|
|
443
|
+
return
|
|
444
|
+
|
|
445
|
+
if len(sys.argv) > 1 and sys.argv[1] == "completion":
|
|
446
|
+
shells = ["bash", "zsh", "fish"]
|
|
447
|
+
if len(sys.argv) < 3 or sys.argv[2] not in shells:
|
|
448
|
+
print(f"Usage: gbit completion [{'|'.join(shells)}]", file=sys.stderr)
|
|
449
|
+
sys.exit(1)
|
|
450
|
+
cmd_completion(sys.argv[2])
|
|
451
|
+
return
|
|
452
|
+
|
|
453
|
+
if len(sys.argv) > 1 and sys.argv[1] == "view":
|
|
454
|
+
if len(sys.argv) < 3:
|
|
455
|
+
print("Usage: gbit view <url>", file=sys.stderr)
|
|
456
|
+
sys.exit(1)
|
|
457
|
+
view_parser = argparse.ArgumentParser(prog="gbit view")
|
|
458
|
+
view_parser.add_argument("url", help="Full paste URL (including #fragment).")
|
|
459
|
+
cmd_view(view_parser.parse_args(sys.argv[2:]))
|
|
460
|
+
return
|
|
461
|
+
|
|
462
|
+
cfg = load_config()
|
|
463
|
+
current_server = cfg.get("server", DEFAULT_SERVER)
|
|
464
|
+
|
|
465
|
+
parser = argparse.ArgumentParser(
|
|
466
|
+
prog="gbit",
|
|
467
|
+
description="Ghostbit CLI — create encrypted pastes from the terminal.",
|
|
468
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
469
|
+
epilog=f"""
|
|
470
|
+
examples:
|
|
471
|
+
cat main.py | gbit
|
|
472
|
+
gbit main.py
|
|
473
|
+
gbit main.py --lang python --burn
|
|
474
|
+
gbit main.py --expires 3600 --password secret
|
|
475
|
+
gbit view https://paste.example.com/abc123#KEY~TOKEN
|
|
476
|
+
gbit config set server https://paste.example.com
|
|
477
|
+
gbit config show
|
|
478
|
+
|
|
479
|
+
current server: {current_server}
|
|
480
|
+
""",
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
parser.add_argument(
|
|
484
|
+
"file",
|
|
485
|
+
nargs="?",
|
|
486
|
+
help="File to paste. Reads from stdin if omitted.",
|
|
487
|
+
)
|
|
488
|
+
parser.add_argument(
|
|
489
|
+
"--server", "-s", default=None, metavar="URL",
|
|
490
|
+
help=f"Server URL for this invocation only (current: {current_server})",
|
|
491
|
+
) # fmt: skip
|
|
492
|
+
parser.add_argument(
|
|
493
|
+
"--lang", "-l", default=None,
|
|
494
|
+
help="Language (e.g. python, javascript, go). Auto-detected if omitted.",
|
|
495
|
+
) # fmt: skip
|
|
496
|
+
parser.add_argument(
|
|
497
|
+
"--expires", "-e", type=int, default=None, metavar="SECONDS",
|
|
498
|
+
help="Expiry TTL in seconds (3600 = 1 h, 86400 = 1 d). Default: never.",
|
|
499
|
+
) # fmt: skip
|
|
500
|
+
parser.add_argument(
|
|
501
|
+
"--burn", "-b", action="store_true",
|
|
502
|
+
help="Delete after the first view.",
|
|
503
|
+
) # fmt: skip
|
|
504
|
+
parser.add_argument(
|
|
505
|
+
"--max-views", "-m", type=int, default=None, metavar="N",
|
|
506
|
+
help="Delete after N views.",
|
|
507
|
+
) # fmt: skip
|
|
508
|
+
parser.add_argument(
|
|
509
|
+
"--password", "-p", nargs="?", const=True, default=None, metavar="PASS",
|
|
510
|
+
help="Encrypt with a password. Omit value to be prompted securely.",
|
|
511
|
+
) # fmt: skip
|
|
512
|
+
parser.add_argument(
|
|
513
|
+
"--quiet", "-q", action="store_true",
|
|
514
|
+
help="Print only the URL.",
|
|
515
|
+
) # fmt: skip
|
|
516
|
+
parser.add_argument("--json", action="store_true", help="Print the full JSON response.")
|
|
517
|
+
parser.add_argument(
|
|
518
|
+
"--no-history", action="store_true",
|
|
519
|
+
help="Don't save this paste to local history.",
|
|
520
|
+
) # fmt: skip
|
|
521
|
+
parser.add_argument(
|
|
522
|
+
"--version", "-V", action="version", version=f"%(prog)s {__version__}",
|
|
523
|
+
) # fmt: skip
|
|
524
|
+
|
|
525
|
+
args = parser.parse_args()
|
|
526
|
+
cmd_paste(args)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
# ── Backwards-compat re-exports for tests/test_cli_crypto.py ─────────────────
|
|
530
|
+
# These aliases keep `from cli import _encrypt, _decrypt, …` working after
|
|
531
|
+
# the move from a flat module to a package. Drop them once the tests are
|
|
532
|
+
# updated to the new names.
|
|
533
|
+
_encrypt = encrypt
|
|
534
|
+
_decrypt = decrypt
|
|
535
|
+
_gen_key = gen_key
|
|
536
|
+
_gen_salt = gen_salt
|
|
537
|
+
_derive_key = derive_key
|
|
538
|
+
_key_to_fragment = key_to_fragment
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
__all__ = [
|
|
542
|
+
"LANGUAGES",
|
|
543
|
+
"__version__",
|
|
544
|
+
"cmd_completion",
|
|
545
|
+
"cmd_config",
|
|
546
|
+
"cmd_delete",
|
|
547
|
+
"cmd_list",
|
|
548
|
+
"cmd_paste",
|
|
549
|
+
"cmd_view",
|
|
550
|
+
"main",
|
|
551
|
+
]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""HTTP plumbing — user-agent, SSL context, typed error reporting.
|
|
2
|
+
|
|
3
|
+
Kept deliberately thin (no requests/httpx dependency) so the installed
|
|
4
|
+
CLI wheel stays small. certifi is an optional hard-dep from pyproject
|
|
5
|
+
mostly to make macOS behave; everywhere else the system trust store is
|
|
6
|
+
fine.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import ssl
|
|
13
|
+
import sys
|
|
14
|
+
import urllib.error
|
|
15
|
+
import urllib.request
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
from importlib.metadata import PackageNotFoundError
|
|
19
|
+
from importlib.metadata import version as _pkg_version
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
_VERSION = _pkg_version("ghostbit-cli")
|
|
23
|
+
except PackageNotFoundError:
|
|
24
|
+
_VERSION = "dev"
|
|
25
|
+
except ImportError:
|
|
26
|
+
_VERSION = "dev"
|
|
27
|
+
|
|
28
|
+
USER_AGENT = f"Ghostbit-CLI/{_VERSION}"
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
import certifi
|
|
32
|
+
|
|
33
|
+
SSL_CTX = ssl.create_default_context(cafile=certifi.where())
|
|
34
|
+
except ImportError:
|
|
35
|
+
SSL_CTX = ssl.create_default_context()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def api_create(server: str, payload: dict) -> dict:
|
|
39
|
+
url = server.rstrip("/") + "/api/v1/pastes"
|
|
40
|
+
data = json.dumps(payload).encode()
|
|
41
|
+
req = urllib.request.Request(
|
|
42
|
+
url,
|
|
43
|
+
data=data,
|
|
44
|
+
headers={"Content-Type": "application/json", "User-Agent": USER_AGENT},
|
|
45
|
+
method="POST",
|
|
46
|
+
)
|
|
47
|
+
try:
|
|
48
|
+
with urllib.request.urlopen(req, timeout=15, context=SSL_CTX) as resp:
|
|
49
|
+
return json.loads(resp.read())
|
|
50
|
+
except urllib.error.HTTPError as e:
|
|
51
|
+
body = e.read().decode(errors="replace")
|
|
52
|
+
try:
|
|
53
|
+
detail = json.loads(body).get("detail", body)
|
|
54
|
+
except Exception: # noqa: BLE001
|
|
55
|
+
detail = body
|
|
56
|
+
print(f"Error {e.code}: {detail}", file=sys.stderr)
|
|
57
|
+
sys.exit(1)
|
|
58
|
+
except urllib.error.URLError as e:
|
|
59
|
+
print(f"Could not connect to {server}: {e.reason}", file=sys.stderr)
|
|
60
|
+
print(" Tip: run `gbit config set server <URL>` to set your server.", file=sys.stderr)
|
|
61
|
+
sys.exit(1)
|